Add --auto-pull flag to control whether the daemon periodically pulls from remote to check for updates from other clones. Configuration precedence: 1. --auto-pull CLI flag (highest) 2. BEADS_AUTO_PULL environment variable 3. daemon.auto_pull in database config 4. Default: true when sync.branch is configured When auto_pull is enabled, the daemon creates a remoteSyncTicker that periodically calls doAutoImport() to pull remote changes. When disabled, users must manually run 'git pull' to sync remote changes. Changes: - cmd/bd/daemon.go: Add --auto-pull flag and config reading logic - cmd/bd/daemon_event_loop.go: Gate remoteSyncTicker on autoPull parameter - cmd/bd/daemon_lifecycle.go: Add auto-pull to status output and spawn args - internal/rpc/protocol.go: Add AutoPull field to StatusResponse - internal/rpc/server_core.go: Add autoPull to Server struct and SetConfig - internal/rpc/server_routing_validation_diagnostics.go: Include in status - Tests updated to pass autoPull parameter Closes #TBD
313 lines
7.3 KiB
Go
313 lines
7.3 KiB
Go
package rpc
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/steveyegge/beads/internal/storage/sqlite"
|
|
)
|
|
|
|
func TestStatusEndpoint(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
dbPath := filepath.Join(tmpDir, "test.db")
|
|
socketPath := newTestSocketPath(t)
|
|
|
|
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()
|
|
|
|
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 response fields
|
|
if status.Version == "" {
|
|
t.Error("expected version to be set")
|
|
}
|
|
if status.WorkspacePath != tmpDir {
|
|
t.Errorf("expected workspace path %s, got %s", tmpDir, status.WorkspacePath)
|
|
}
|
|
if status.DatabasePath != dbPath {
|
|
t.Errorf("expected database path %s, got %s", dbPath, status.DatabasePath)
|
|
}
|
|
if status.SocketPath != socketPath {
|
|
t.Errorf("expected socket path %s, got %s", socketPath, status.SocketPath)
|
|
}
|
|
if status.PID != os.Getpid() {
|
|
t.Errorf("expected PID %d, got %d", os.Getpid(), status.PID)
|
|
}
|
|
if status.UptimeSeconds <= 0 {
|
|
t.Error("expected positive uptime")
|
|
}
|
|
if status.LastActivityTime == "" {
|
|
t.Error("expected last activity time to be set")
|
|
}
|
|
if status.ExclusiveLockActive {
|
|
t.Error("expected no exclusive lock in test")
|
|
}
|
|
|
|
// Verify last activity time is recent
|
|
lastActivity, err := time.Parse(time.RFC3339, status.LastActivityTime)
|
|
if err != nil {
|
|
t.Errorf("failed to parse last activity time: %v", err)
|
|
}
|
|
if time.Since(lastActivity) > 5*time.Second {
|
|
t.Errorf("last activity time too old: %v", lastActivity)
|
|
}
|
|
}
|
|
|
|
func TestStatusEndpointWithConfig(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
dbPath := filepath.Join(tmpDir, "test.db")
|
|
socketPath := newTestSocketPath(t)
|
|
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, 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 := newTestSocketPath(t)
|
|
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, 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 := newTestSocketPath(t)
|
|
|
|
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 := newTestSocketPath(t)
|
|
|
|
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%5 == 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)
|
|
}
|