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 <crcatala@gmail.com>
This commit is contained in:
cc-vps
2025-12-15 09:03:20 -08:00
parent bc3e8f6359
commit 4e87ae18e5
6 changed files with 305 additions and 6 deletions

View File

@@ -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")

View File

@@ -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})

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)
}