feat(daemon): Add --global flag for multi-repo support
Implements bd-121: Global daemon with system-wide socket Changes: - Add --global flag to daemon command - Use ~/.beads/bd.sock when --global is set - Skip git repo validation for global daemon - Update daemon discovery to check ~/.beads/ as fallback - Both Go CLI and Python MCP client check global socket - Update all tests to pass global parameter Benefits: - Single daemon serves all repos on system - No per-repo daemon management needed - Better resource usage for users with many repos - Automatic fallback when local daemon not running Usage: bd daemon --global # Start global daemon bd daemon --status --global # Check global status bd daemon --stop --global # Stop global daemon Related: bd-73 (multi-repo epic) Amp-Thread-ID: https://ampcode.com/threads/T-ea606216-b886-4af0-bba8-56d000362d01 Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
101
cmd/bd/daemon.go
101
cmd/bd/daemon.go
@@ -46,20 +46,21 @@ Use --status to check if daemon is running.`,
|
|||||||
autoCommit, _ := cmd.Flags().GetBool("auto-commit")
|
autoCommit, _ := cmd.Flags().GetBool("auto-commit")
|
||||||
autoPush, _ := cmd.Flags().GetBool("auto-push")
|
autoPush, _ := cmd.Flags().GetBool("auto-push")
|
||||||
logFile, _ := cmd.Flags().GetString("log")
|
logFile, _ := cmd.Flags().GetString("log")
|
||||||
|
global, _ := cmd.Flags().GetBool("global")
|
||||||
|
|
||||||
if interval <= 0 {
|
if interval <= 0 {
|
||||||
fmt.Fprintf(os.Stderr, "Error: interval must be positive (got %v)\n", interval)
|
fmt.Fprintf(os.Stderr, "Error: interval must be positive (got %v)\n", interval)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
pidFile, err := getPIDFilePath()
|
pidFile, err := getPIDFilePath(global)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
if status {
|
if status {
|
||||||
showDaemonStatus(pidFile)
|
showDaemonStatus(pidFile, global)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,12 +72,12 @@ Use --status to check if daemon is running.`,
|
|||||||
// Check if daemon is already running
|
// Check if daemon is already running
|
||||||
if isRunning, pid := isDaemonRunning(pidFile); isRunning {
|
if isRunning, pid := isDaemonRunning(pidFile); isRunning {
|
||||||
fmt.Fprintf(os.Stderr, "Error: daemon already running (PID %d)\n", pid)
|
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")
|
fmt.Fprintf(os.Stderr, "Use 'bd daemon --stop%s' to stop it first\n", boolToFlag(global, " --global"))
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate we're in a git repo
|
// Validate we're in a git repo (skip for global daemon)
|
||||||
if !isGitRepo() {
|
if !global && !isGitRepo() {
|
||||||
fmt.Fprintf(os.Stderr, "Error: not in a git repository\n")
|
fmt.Fprintf(os.Stderr, "Error: not in a git repository\n")
|
||||||
fmt.Fprintf(os.Stderr, "Hint: run 'git init' to initialize a repository\n")
|
fmt.Fprintf(os.Stderr, "Hint: run 'git init' to initialize a repository\n")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -90,13 +91,17 @@ Use --status to check if daemon is running.`,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Start daemon
|
// Start daemon
|
||||||
fmt.Printf("Starting bd daemon (interval: %v, auto-commit: %v, auto-push: %v)\n",
|
scope := "local"
|
||||||
interval, autoCommit, autoPush)
|
if global {
|
||||||
|
scope = "global"
|
||||||
|
}
|
||||||
|
fmt.Printf("Starting bd daemon (%s, interval: %v, auto-commit: %v, auto-push: %v)\n",
|
||||||
|
scope, interval, autoCommit, autoPush)
|
||||||
if logFile != "" {
|
if logFile != "" {
|
||||||
fmt.Printf("Logging to: %s\n", logFile)
|
fmt.Printf("Logging to: %s\n", logFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
startDaemon(interval, autoCommit, autoPush, logFile, pidFile)
|
startDaemon(interval, autoCommit, autoPush, logFile, pidFile, global)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,9 +112,24 @@ func init() {
|
|||||||
daemonCmd.Flags().Bool("stop", false, "Stop running daemon")
|
daemonCmd.Flags().Bool("stop", false, "Stop running daemon")
|
||||||
daemonCmd.Flags().Bool("status", false, "Show daemon status")
|
daemonCmd.Flags().Bool("status", false, "Show daemon status")
|
||||||
daemonCmd.Flags().String("log", "", "Log file path (default: .beads/daemon.log)")
|
daemonCmd.Flags().String("log", "", "Log file path (default: .beads/daemon.log)")
|
||||||
|
daemonCmd.Flags().Bool("global", false, "Run as global daemon (socket at ~/.beads/bd.sock)")
|
||||||
rootCmd.AddCommand(daemonCmd)
|
rootCmd.AddCommand(daemonCmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getGlobalBeadsDir() (string, error) {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("cannot get home directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
beadsDir := filepath.Join(home, ".beads")
|
||||||
|
if err := os.MkdirAll(beadsDir, 0700); err != nil {
|
||||||
|
return "", fmt.Errorf("cannot create global beads directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return beadsDir, nil
|
||||||
|
}
|
||||||
|
|
||||||
func ensureBeadsDir() (string, error) {
|
func ensureBeadsDir() (string, error) {
|
||||||
var beadsDir string
|
var beadsDir string
|
||||||
if dbPath != "" {
|
if dbPath != "" {
|
||||||
@@ -132,19 +152,43 @@ func ensureBeadsDir() (string, error) {
|
|||||||
return beadsDir, nil
|
return beadsDir, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getPIDFilePath() (string, error) {
|
func boolToFlag(condition bool, flag string) string {
|
||||||
beadsDir, err := ensureBeadsDir()
|
if condition {
|
||||||
|
return flag
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func getPIDFilePath(global bool) (string, error) {
|
||||||
|
var beadsDir string
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if global {
|
||||||
|
beadsDir, err = getGlobalBeadsDir()
|
||||||
|
} else {
|
||||||
|
beadsDir, err = ensureBeadsDir()
|
||||||
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return filepath.Join(beadsDir, "daemon.pid"), nil
|
return filepath.Join(beadsDir, "daemon.pid"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getLogFilePath(userPath string) (string, error) {
|
func getLogFilePath(userPath string, global bool) (string, error) {
|
||||||
if userPath != "" {
|
if userPath != "" {
|
||||||
return userPath, nil
|
return userPath, nil
|
||||||
}
|
}
|
||||||
beadsDir, err := ensureBeadsDir()
|
|
||||||
|
var beadsDir string
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if global {
|
||||||
|
beadsDir, err = getGlobalBeadsDir()
|
||||||
|
} else {
|
||||||
|
beadsDir, err = ensureBeadsDir()
|
||||||
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -175,15 +219,19 @@ func isDaemonRunning(pidFile string) (bool, int) {
|
|||||||
return true, pid
|
return true, pid
|
||||||
}
|
}
|
||||||
|
|
||||||
func showDaemonStatus(pidFile string) {
|
func showDaemonStatus(pidFile string, global bool) {
|
||||||
if isRunning, pid := isDaemonRunning(pidFile); isRunning {
|
if isRunning, pid := isDaemonRunning(pidFile); isRunning {
|
||||||
fmt.Printf("✓ Daemon is running (PID %d)\n", pid)
|
scope := "local"
|
||||||
|
if global {
|
||||||
|
scope = "global"
|
||||||
|
}
|
||||||
|
fmt.Printf("✓ Daemon is running (PID %d, %s)\n", pid, scope)
|
||||||
|
|
||||||
if info, err := os.Stat(pidFile); err == nil {
|
if info, err := os.Stat(pidFile); err == nil {
|
||||||
fmt.Printf(" Started: %s\n", info.ModTime().Format("2006-01-02 15:04:05"))
|
fmt.Printf(" Started: %s\n", info.ModTime().Format("2006-01-02 15:04:05"))
|
||||||
}
|
}
|
||||||
|
|
||||||
logPath, err := getLogFilePath("")
|
logPath, err := getLogFilePath("", global)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
if _, err := os.Stat(logPath); err == nil {
|
if _, err := os.Stat(logPath); err == nil {
|
||||||
fmt.Printf(" Log: %s\n", logPath)
|
fmt.Printf(" Log: %s\n", logPath)
|
||||||
@@ -229,15 +277,15 @@ func stopDaemon(pidFile string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func startDaemon(interval time.Duration, autoCommit, autoPush bool, logFile, pidFile string) {
|
func startDaemon(interval time.Duration, autoCommit, autoPush bool, logFile, pidFile string, global bool) {
|
||||||
logPath, err := getLogFilePath(logFile)
|
logPath, err := getLogFilePath(logFile, global)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
if os.Getenv("BD_DAEMON_FOREGROUND") == "1" {
|
if os.Getenv("BD_DAEMON_FOREGROUND") == "1" {
|
||||||
runDaemonLoop(interval, autoCommit, autoPush, logPath, pidFile)
|
runDaemonLoop(interval, autoCommit, autoPush, logPath, pidFile, global)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,6 +307,9 @@ func startDaemon(interval time.Duration, autoCommit, autoPush bool, logFile, pid
|
|||||||
if logFile != "" {
|
if logFile != "" {
|
||||||
args = append(args, "--log", logFile)
|
args = append(args, "--log", logFile)
|
||||||
}
|
}
|
||||||
|
if global {
|
||||||
|
args = append(args, "--global")
|
||||||
|
}
|
||||||
|
|
||||||
cmd := exec.Command(exe, args...)
|
cmd := exec.Command(exe, args...)
|
||||||
cmd.Env = append(os.Environ(), "BD_DAEMON_FOREGROUND=1")
|
cmd.Env = append(os.Environ(), "BD_DAEMON_FOREGROUND=1")
|
||||||
@@ -392,7 +443,7 @@ func importToJSONLWithStore(ctx context.Context, store storage.Storage, jsonlPat
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func runDaemonLoop(interval time.Duration, autoCommit, autoPush bool, logPath, pidFile string) {
|
func runDaemonLoop(interval time.Duration, autoCommit, autoPush bool, logPath, pidFile string, global bool) {
|
||||||
logF, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
|
logF, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Error opening log file: %v\n", err)
|
fmt.Fprintf(os.Stderr, "Error opening log file: %v\n", err)
|
||||||
@@ -467,7 +518,17 @@ func runDaemonLoop(interval time.Duration, autoCommit, autoPush bool, logPath, p
|
|||||||
log("Database opened: %s", daemonDBPath)
|
log("Database opened: %s", daemonDBPath)
|
||||||
|
|
||||||
// Start RPC server
|
// Start RPC server
|
||||||
socketPath := filepath.Join(filepath.Dir(daemonDBPath), "bd.sock")
|
var socketPath string
|
||||||
|
if global {
|
||||||
|
globalDir, err := getGlobalBeadsDir()
|
||||||
|
if err != nil {
|
||||||
|
log("Error: cannot get global beads directory: %v", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
socketPath = filepath.Join(globalDir, "bd.sock")
|
||||||
|
} else {
|
||||||
|
socketPath = filepath.Join(filepath.Dir(daemonDBPath), "bd.sock")
|
||||||
|
}
|
||||||
server := rpc.NewServer(socketPath, store)
|
server := rpc.NewServer(socketPath, store)
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ func TestGetPIDFilePath(t *testing.T) {
|
|||||||
defer func() { dbPath = oldDBPath }()
|
defer func() { dbPath = oldDBPath }()
|
||||||
|
|
||||||
dbPath = filepath.Join(tmpDir, ".beads", "test.db")
|
dbPath = filepath.Join(tmpDir, ".beads", "test.db")
|
||||||
pidFile, err := getPIDFilePath()
|
pidFile, err := getPIDFilePath(false) // test local daemon
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("getPIDFilePath failed: %v", err)
|
t.Fatalf("getPIDFilePath failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -65,7 +65,7 @@ func TestGetLogFilePath(t *testing.T) {
|
|||||||
defer func() { dbPath = oldDBPath }()
|
defer func() { dbPath = oldDBPath }()
|
||||||
dbPath = tt.dbPath
|
dbPath = tt.dbPath
|
||||||
|
|
||||||
result, err := getLogFilePath(tt.userPath)
|
result, err := getLogFilePath(tt.userPath, false) // test local daemon
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("getLogFilePath failed: %v", err)
|
t.Fatalf("getLogFilePath failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -169,9 +169,24 @@ var rootCmd = &cobra.Command{
|
|||||||
}
|
}
|
||||||
|
|
||||||
// getSocketPath returns the daemon socket path based on the database location
|
// getSocketPath returns the daemon socket path based on the database location
|
||||||
|
// If no local socket exists, check for global socket at ~/.beads/bd.sock
|
||||||
func getSocketPath() string {
|
func getSocketPath() string {
|
||||||
// Socket lives in same directory as database: .beads/bd.sock
|
// First check local socket (same directory as database: .beads/bd.sock)
|
||||||
return filepath.Join(filepath.Dir(dbPath), "bd.sock")
|
localSocket := filepath.Join(filepath.Dir(dbPath), "bd.sock")
|
||||||
|
if _, err := os.Stat(localSocket); err == nil {
|
||||||
|
return localSocket
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to global socket at ~/.beads/bd.sock
|
||||||
|
if home, err := os.UserHomeDir(); err == nil {
|
||||||
|
globalSocket := filepath.Join(home, ".beads", "bd.sock")
|
||||||
|
if _, err := os.Stat(globalSocket); err == nil {
|
||||||
|
return globalSocket
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to local socket even if it doesn't exist
|
||||||
|
return localSocket
|
||||||
}
|
}
|
||||||
|
|
||||||
// outputJSON outputs data as pretty-printed JSON
|
// outputJSON outputs data as pretty-printed JSON
|
||||||
|
|||||||
@@ -640,7 +640,7 @@ def create_bd_client(
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Check if daemon socket exists before creating client
|
# Check if daemon socket exists before creating client
|
||||||
# Walk up from working_dir to find .beads/bd.sock
|
# Walk up from working_dir to find .beads/bd.sock, then check global
|
||||||
search_dir = Path(working_dir) if working_dir else Path.cwd()
|
search_dir = Path(working_dir) if working_dir else Path.cwd()
|
||||||
socket_found = False
|
socket_found = False
|
||||||
|
|
||||||
@@ -652,16 +652,22 @@ def create_bd_client(
|
|||||||
if sock_path.exists():
|
if sock_path.exists():
|
||||||
socket_found = True
|
socket_found = True
|
||||||
break
|
break
|
||||||
# Found .beads but no socket - daemon not running
|
# Found .beads but no socket - check global before giving up
|
||||||
break
|
break
|
||||||
|
|
||||||
# Move up one directory
|
# Move up one directory
|
||||||
parent = current.parent
|
parent = current.parent
|
||||||
if parent == current:
|
if parent == current:
|
||||||
# Reached filesystem root
|
# Reached filesystem root - check global
|
||||||
break
|
break
|
||||||
current = parent
|
current = parent
|
||||||
|
|
||||||
|
# If no local socket, check global daemon socket at ~/.beads/bd.sock
|
||||||
|
if not socket_found:
|
||||||
|
global_sock_path = Path.home() / ".beads" / "bd.sock"
|
||||||
|
if global_sock_path.exists():
|
||||||
|
socket_found = True
|
||||||
|
|
||||||
if socket_found:
|
if socket_found:
|
||||||
# Daemon is running, use it
|
# Daemon is running, use it
|
||||||
client = BdDaemonClient(
|
client = BdDaemonClient(
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ class BdDaemonClient(BdClientBase):
|
|||||||
async def _find_socket_path(self) -> str:
|
async def _find_socket_path(self) -> str:
|
||||||
"""Find daemon socket path by searching for .beads directory.
|
"""Find daemon socket path by searching for .beads directory.
|
||||||
|
|
||||||
|
Checks local .beads/bd.sock first, then falls back to global ~/.beads/bd.sock.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Path to bd.sock file
|
Path to bd.sock file
|
||||||
|
|
||||||
@@ -90,20 +92,27 @@ class BdDaemonClient(BdClientBase):
|
|||||||
sock_path = beads_dir / "bd.sock"
|
sock_path = beads_dir / "bd.sock"
|
||||||
if sock_path.exists():
|
if sock_path.exists():
|
||||||
return str(sock_path)
|
return str(sock_path)
|
||||||
# Found .beads but no socket - daemon not running
|
# Found .beads but no socket - check global before failing
|
||||||
raise DaemonNotRunningError(
|
break
|
||||||
f"Daemon socket not found at {sock_path}. Is the daemon running? Try: bd daemon"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Move up one directory
|
# Move up one directory
|
||||||
parent = current.parent
|
parent = current.parent
|
||||||
if parent == current:
|
if parent == current:
|
||||||
# Reached filesystem root
|
# Reached filesystem root - check global
|
||||||
raise DaemonNotRunningError(
|
break
|
||||||
"No .beads directory found. Initialize with: bd init"
|
|
||||||
)
|
|
||||||
current = parent
|
current = parent
|
||||||
|
|
||||||
|
# Check for global daemon socket at ~/.beads/bd.sock
|
||||||
|
home = Path.home()
|
||||||
|
global_sock_path = home / ".beads" / "bd.sock"
|
||||||
|
if global_sock_path.exists():
|
||||||
|
return str(global_sock_path)
|
||||||
|
|
||||||
|
# No socket found anywhere
|
||||||
|
raise DaemonNotRunningError(
|
||||||
|
"Daemon socket not found. Is the daemon running? Try: bd daemon (local) or bd daemon --global"
|
||||||
|
)
|
||||||
|
|
||||||
async def _send_request(self, operation: str, args: Dict[str, Any]) -> Dict[str, Any]:
|
async def _send_request(self, operation: str, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""Send RPC request to daemon and get response.
|
"""Send RPC request to daemon and get response.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user