From 4e87ae18e5866c9ef126393c1909dc3b09bf1fe6 Mon Sep 17 00:00:00 2001 From: cc-vps Date: Mon, 15 Dec 2025 09:03:20 -0800 Subject: [PATCH] feat: show daemon config in 'bd daemon --status' output Add auto-commit, auto-push, local mode, sync interval, and daemon mode to the status output when querying a running daemon. This helps users understand the current daemon configuration without having to check logs or remember what flags were used at startup. Changes: - Add config fields to StatusResponse in protocol.go - Add SetConfig() method to Server for daemon to set its config - Update handleStatus() to include config in response - Update showDaemonStatus() to query and display config via RPC - Add comprehensive test coverage for new functionality Co-authored-by: Christian Catalan --- cmd/bd/daemon.go | 16 +- cmd/bd/daemon_lifecycle.go | 29 +++ internal/rpc/protocol.go | 6 + internal/rpc/server_core.go | 17 ++ .../server_routing_validation_diagnostics.go | 14 ++ internal/rpc/status_test.go | 229 ++++++++++++++++++ 6 files changed, 305 insertions(+), 6 deletions(-) diff --git a/cmd/bd/daemon.go b/cmd/bd/daemon.go index 2f2bf203..04fc9154 100644 --- a/cmd/bd/daemon.go +++ b/cmd/bd/daemon.go @@ -454,6 +454,15 @@ func runDaemonLoop(interval time.Duration, autoCommit, autoPush, localMode bool, return } + // Choose event loop based on BEADS_DAEMON_MODE (need to determine early for SetConfig) + daemonMode := os.Getenv("BEADS_DAEMON_MODE") + if daemonMode == "" { + daemonMode = "events" // Default to event-driven mode (production-ready as of v0.21.0) + } + + // Set daemon configuration for status reporting + server.SetConfig(autoCommit, autoPush, localMode, interval.String(), daemonMode) + // Register daemon in global registry registry, err := daemon.NewRegistry() if err != nil { @@ -496,12 +505,7 @@ func runDaemonLoop(interval time.Duration, autoCommit, autoPush, localMode bool, parentPID := computeDaemonParentPID() log.log("Monitoring parent process (PID %d)", parentPID) - // Choose event loop based on BEADS_DAEMON_MODE - daemonMode := os.Getenv("BEADS_DAEMON_MODE") - if daemonMode == "" { - daemonMode = "events" // Default to event-driven mode (production-ready as of v0.21.0) - } - + // daemonMode already determined above for SetConfig switch daemonMode { case "events": log.log("Using event-driven mode") diff --git a/cmd/bd/daemon_lifecycle.go b/cmd/bd/daemon_lifecycle.go index 9f6be619..0605a7cc 100644 --- a/cmd/bd/daemon_lifecycle.go +++ b/cmd/bd/daemon_lifecycle.go @@ -54,6 +54,17 @@ func showDaemonStatus(pidFile string) { } } + // Try to get detailed status from daemon via RPC + var rpcStatus *rpc.StatusResponse + beadsDir := filepath.Dir(pidFile) + socketPath := filepath.Join(beadsDir, "bd.sock") + if client, err := rpc.TryConnectWithTimeout(socketPath, 1*time.Second); err == nil && client != nil { + if status, err := client.Status(); err == nil { + rpcStatus = status + } + _ = client.Close() + } + if jsonOutput { status := map[string]interface{}{ "running": true, @@ -65,6 +76,14 @@ func showDaemonStatus(pidFile string) { if logPath != "" { status["log_path"] = logPath } + // Add config from RPC status if available + if rpcStatus != nil { + status["auto_commit"] = rpcStatus.AutoCommit + status["auto_push"] = rpcStatus.AutoPush + status["local_mode"] = rpcStatus.LocalMode + status["sync_interval"] = rpcStatus.SyncInterval + status["daemon_mode"] = rpcStatus.DaemonMode + } outputJSON(status) return } @@ -76,6 +95,16 @@ func showDaemonStatus(pidFile string) { if logPath != "" { fmt.Printf(" Log: %s\n", logPath) } + // Display config from RPC status if available + if rpcStatus != nil { + fmt.Printf(" Mode: %s\n", rpcStatus.DaemonMode) + fmt.Printf(" Sync Interval: %s\n", rpcStatus.SyncInterval) + fmt.Printf(" Auto-Commit: %v\n", rpcStatus.AutoCommit) + fmt.Printf(" Auto-Push: %v\n", rpcStatus.AutoPush) + if rpcStatus.LocalMode { + fmt.Printf(" Local Mode: %v (no git sync)\n", rpcStatus.LocalMode) + } + } } else { if jsonOutput { outputJSON(map[string]interface{}{"running": false}) diff --git a/internal/rpc/protocol.go b/internal/rpc/protocol.go index 35ded488..61b0a7a3 100644 --- a/internal/rpc/protocol.go +++ b/internal/rpc/protocol.go @@ -275,6 +275,12 @@ type StatusResponse struct { LastActivityTime string `json:"last_activity_time"` // ISO 8601 timestamp of last request ExclusiveLockActive bool `json:"exclusive_lock_active"` // Whether an exclusive lock is held ExclusiveLockHolder string `json:"exclusive_lock_holder,omitempty"` // Lock holder name if active + // Daemon configuration + AutoCommit bool `json:"auto_commit"` // Whether auto-commit is enabled + AutoPush bool `json:"auto_push"` // Whether auto-push is enabled + LocalMode bool `json:"local_mode"` // Whether running in local-only mode (no git) + SyncInterval string `json:"sync_interval"` // Sync interval (e.g., "5s") + DaemonMode string `json:"daemon_mode"` // Sync mode: "poll" or "events" } // HealthResponse is the response for a health check operation diff --git a/internal/rpc/server_core.go b/internal/rpc/server_core.go index 54b588c8..a6a1aee2 100644 --- a/internal/rpc/server_core.go +++ b/internal/rpc/server_core.go @@ -54,6 +54,12 @@ type Server struct { recentMutations []MutationEvent recentMutationsMu sync.RWMutex maxMutationBuffer int + // Daemon configuration (set via SetConfig after creation) + autoCommit bool + autoPush bool + localMode bool + syncInterval string + daemonMode string } // Mutation event types @@ -152,6 +158,17 @@ func (s *Server) MutationChan() <-chan MutationEvent { return s.mutationChan } +// SetConfig sets the daemon configuration for status reporting +func (s *Server) SetConfig(autoCommit, autoPush, localMode bool, syncInterval, daemonMode string) { + s.mu.Lock() + defer s.mu.Unlock() + s.autoCommit = autoCommit + s.autoPush = autoPush + s.localMode = localMode + s.syncInterval = syncInterval + s.daemonMode = daemonMode +} + // ResetDroppedEventsCount resets the dropped events counter and returns the previous value func (s *Server) ResetDroppedEventsCount() int64 { return s.droppedEvents.Swap(0) diff --git a/internal/rpc/server_routing_validation_diagnostics.go b/internal/rpc/server_routing_validation_diagnostics.go index 224c9665..4dcadbb0 100644 --- a/internal/rpc/server_routing_validation_diagnostics.go +++ b/internal/rpc/server_routing_validation_diagnostics.go @@ -276,6 +276,15 @@ func (s *Server) handleStatus(_ *Request) Response { } } + // Read config under lock + s.mu.RLock() + autoCommit := s.autoCommit + autoPush := s.autoPush + localMode := s.localMode + syncInterval := s.syncInterval + daemonMode := s.daemonMode + s.mu.RUnlock() + statusResp := StatusResponse{ Version: ServerVersion, WorkspacePath: s.workspacePath, @@ -286,6 +295,11 @@ func (s *Server) handleStatus(_ *Request) Response { LastActivityTime: lastActivity.Format(time.RFC3339), ExclusiveLockActive: lockActive, ExclusiveLockHolder: lockHolder, + AutoCommit: autoCommit, + AutoPush: autoPush, + LocalMode: localMode, + SyncInterval: syncInterval, + DaemonMode: daemonMode, } data, _ := json.Marshal(statusResp) diff --git a/internal/rpc/status_test.go b/internal/rpc/status_test.go index 35e48772..5b219529 100644 --- a/internal/rpc/status_test.go +++ b/internal/rpc/status_test.go @@ -83,3 +83,232 @@ func TestStatusEndpoint(t *testing.T) { t.Errorf("last activity time too old: %v", lastActivity) } } + +func TestStatusEndpointWithConfig(t *testing.T) { + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "test.db") + socketPath := filepath.Join(tmpDir, "test.sock") + + store, err := sqlite.New(context.Background(), dbPath) + if err != nil { + t.Fatalf("failed to create storage: %v", err) + } + defer store.Close() + + server := NewServer(socketPath, store, tmpDir, dbPath) + + // Set config before starting + server.SetConfig(true, true, false, "10s", "events") + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { + _ = server.Start(ctx) + }() + + <-server.WaitReady() + defer server.Stop() + + client, err := TryConnect(socketPath) + if err != nil { + t.Fatalf("failed to connect: %v", err) + } + if client == nil { + t.Fatal("client is nil") + } + defer client.Close() + + // Test status endpoint + status, err := client.Status() + if err != nil { + t.Fatalf("status call failed: %v", err) + } + + // Verify config fields + if !status.AutoCommit { + t.Error("expected AutoCommit to be true") + } + if !status.AutoPush { + t.Error("expected AutoPush to be true") + } + if status.LocalMode { + t.Error("expected LocalMode to be false") + } + if status.SyncInterval != "10s" { + t.Errorf("expected SyncInterval '10s', got '%s'", status.SyncInterval) + } + if status.DaemonMode != "events" { + t.Errorf("expected DaemonMode 'events', got '%s'", status.DaemonMode) + } +} + +func TestStatusEndpointLocalMode(t *testing.T) { + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "test.db") + socketPath := filepath.Join(tmpDir, "test.sock") + + store, err := sqlite.New(context.Background(), dbPath) + if err != nil { + t.Fatalf("failed to create storage: %v", err) + } + defer store.Close() + + server := NewServer(socketPath, store, tmpDir, dbPath) + + // Set config for local mode + server.SetConfig(false, false, true, "5s", "poll") + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { + _ = server.Start(ctx) + }() + + <-server.WaitReady() + defer server.Stop() + + client, err := TryConnect(socketPath) + if err != nil { + t.Fatalf("failed to connect: %v", err) + } + if client == nil { + t.Fatal("client is nil") + } + defer client.Close() + + // Test status endpoint + status, err := client.Status() + if err != nil { + t.Fatalf("status call failed: %v", err) + } + + // Verify local mode config + if status.AutoCommit { + t.Error("expected AutoCommit to be false in local mode") + } + if status.AutoPush { + t.Error("expected AutoPush to be false in local mode") + } + if !status.LocalMode { + t.Error("expected LocalMode to be true") + } + if status.SyncInterval != "5s" { + t.Errorf("expected SyncInterval '5s', got '%s'", status.SyncInterval) + } + if status.DaemonMode != "poll" { + t.Errorf("expected DaemonMode 'poll', got '%s'", status.DaemonMode) + } +} + +func TestStatusEndpointDefaultConfig(t *testing.T) { + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "test.db") + socketPath := filepath.Join(tmpDir, "test.sock") + + store, err := sqlite.New(context.Background(), dbPath) + if err != nil { + t.Fatalf("failed to create storage: %v", err) + } + defer store.Close() + + server := NewServer(socketPath, store, tmpDir, dbPath) + // Don't call SetConfig - test default values + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { + _ = server.Start(ctx) + }() + + <-server.WaitReady() + defer server.Stop() + + client, err := TryConnect(socketPath) + if err != nil { + t.Fatalf("failed to connect: %v", err) + } + if client == nil { + t.Fatal("client is nil") + } + defer client.Close() + + // Test status endpoint + status, err := client.Status() + if err != nil { + t.Fatalf("status call failed: %v", err) + } + + // Verify default config (all false/empty when SetConfig not called) + if status.AutoCommit { + t.Error("expected AutoCommit to be false by default") + } + if status.AutoPush { + t.Error("expected AutoPush to be false by default") + } + if status.LocalMode { + t.Error("expected LocalMode to be false by default") + } + if status.SyncInterval != "" { + t.Errorf("expected SyncInterval to be empty by default, got '%s'", status.SyncInterval) + } + if status.DaemonMode != "" { + t.Errorf("expected DaemonMode to be empty by default, got '%s'", status.DaemonMode) + } +} + +func TestSetConfigConcurrency(t *testing.T) { + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "test.db") + socketPath := filepath.Join(tmpDir, "test.sock") + + store, err := sqlite.New(context.Background(), dbPath) + if err != nil { + t.Fatalf("failed to create storage: %v", err) + } + defer store.Close() + + server := NewServer(socketPath, store, tmpDir, dbPath) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { + _ = server.Start(ctx) + }() + + <-server.WaitReady() + defer server.Stop() + + // Test concurrent SetConfig calls don't race + done := make(chan bool) + for i := 0; i < 10; i++ { + go func(n int) { + server.SetConfig(n%2 == 0, n%3 == 0, n%4 == 0, "5s", "events") + done <- true + }(i) + } + + // Wait for all goroutines + for i := 0; i < 10; i++ { + <-done + } + + // Verify we can still get status (server didn't crash) + client, err := TryConnect(socketPath) + if err != nil { + t.Fatalf("failed to connect: %v", err) + } + defer client.Close() + + status, err := client.Status() + if err != nil { + t.Fatalf("status call failed after concurrent SetConfig: %v", err) + } + + // Just verify the status call succeeded - values will be from last SetConfig + t.Logf("Final config: AutoCommit=%v, AutoPush=%v, LocalMode=%v", + status.AutoCommit, status.AutoPush, status.LocalMode) +}