729 lines
27 KiB
Go
729 lines
27 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/spf13/cobra"
|
|
"github.com/steveyegge/beads/cmd/bd/doctor"
|
|
"github.com/steveyegge/beads/internal/beads"
|
|
"github.com/steveyegge/beads/internal/daemon"
|
|
"github.com/steveyegge/beads/internal/rpc"
|
|
"github.com/steveyegge/beads/internal/storage/sqlite"
|
|
"github.com/steveyegge/beads/internal/syncbranch"
|
|
)
|
|
|
|
var daemonCmd = &cobra.Command{
|
|
Use: "daemon",
|
|
GroupID: "sync",
|
|
Short: "Manage background sync daemon",
|
|
Long: `Manage 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
|
|
|
|
Common operations:
|
|
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 --help' to see all subcommands.`,
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
start, _ := cmd.Flags().GetBool("start")
|
|
stop, _ := cmd.Flags().GetBool("stop")
|
|
stopAll, _ := cmd.Flags().GetBool("stop-all")
|
|
status, _ := cmd.Flags().GetBool("status")
|
|
health, _ := cmd.Flags().GetBool("health")
|
|
metrics, _ := cmd.Flags().GetBool("metrics")
|
|
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")
|
|
|
|
// If no operation flags provided, show help
|
|
if !start && !stop && !stopAll && !status && !health && !metrics {
|
|
_ = cmd.Help()
|
|
return
|
|
}
|
|
|
|
// Show deprecation warnings for flag-based actions (skip in JSON mode for agent ergonomics)
|
|
if !jsonOutput {
|
|
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)
|
|
if start && !stop && !status && !health && !metrics {
|
|
// 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)
|
|
}
|
|
|
|
if status {
|
|
showDaemonStatus(pidFile)
|
|
return
|
|
}
|
|
|
|
if health {
|
|
showDaemonHealth()
|
|
return
|
|
}
|
|
|
|
if metrics {
|
|
showDaemonMetrics()
|
|
return
|
|
}
|
|
|
|
if stop {
|
|
stopDaemon(pidFile)
|
|
return
|
|
}
|
|
|
|
if stopAll {
|
|
stopAllDaemons()
|
|
return
|
|
}
|
|
|
|
// If we get here and --start wasn't provided, something is wrong
|
|
// (should have been caught by help check above)
|
|
if !start {
|
|
fmt.Fprintf(os.Stderr, "Error: --start flag is required to start the daemon\n")
|
|
fmt.Fprintf(os.Stderr, "Run 'bd daemon --help' to see available options\n")
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Skip daemon-running check if we're the forked child (BD_DAEMON_FOREGROUND=1)
|
|
// because the check happens in the parent process before forking
|
|
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)
|
|
// Continue with daemon startup
|
|
}
|
|
} else {
|
|
// Can't check version - assume incompatible
|
|
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
|
|
// When sync-branch is configured, check that branch's upstream instead of current HEAD.
|
|
// This fixes compatibility with jj/jujutsu which always operates in detached HEAD mode.
|
|
if autoPush {
|
|
hasUpstream := false
|
|
if syncBranch := syncbranch.GetFromYAML(); syncBranch != "" {
|
|
// sync-branch configured: check that branch's upstream
|
|
hasUpstream = gitBranchHasUpstream(syncBranch)
|
|
} else {
|
|
// No sync-branch: check current HEAD's upstream (original behavior)
|
|
hasUpstream = gitHasUpstream()
|
|
}
|
|
if !hasUpstream {
|
|
fmt.Fprintf(os.Stderr, "Error: no upstream configured (required for --auto-push)\n")
|
|
fmt.Fprintf(os.Stderr, "Hint: git push -u origin <branch-name>\n")
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
// Warn if starting daemon in a git worktree
|
|
// Ensure dbPath is set for warning
|
|
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() {
|
|
// 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 (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)")
|
|
daemonCmd.Flags().String("log-level", "info", "Log level (debug, info, warn, error)")
|
|
daemonCmd.Flags().Bool("log-json", false, "Output logs in JSON format (structured logging)")
|
|
daemonCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output JSON format")
|
|
rootCmd.AddCommand(daemonCmd)
|
|
}
|
|
|
|
// computeDaemonParentPID determines which parent PID the daemon should track.
|
|
// When BD_DAEMON_FOREGROUND=1 (used by startDaemon for background CLI launches),
|
|
// we return 0 to disable parent tracking, since the short-lived launcher
|
|
// process is expected to exit immediately after spawning the daemon.
|
|
// In all other cases we track the current OS parent PID.
|
|
func computeDaemonParentPID() int {
|
|
if os.Getenv("BD_DAEMON_FOREGROUND") == "1" {
|
|
// 0 means "not tracked" in checkParentProcessAlive
|
|
return 0
|
|
}
|
|
return os.Getppid()
|
|
}
|
|
func runDaemonLoop(interval time.Duration, autoCommit, autoPush, autoPull, localMode bool, logPath, pidFile, logLevel string, logJSON bool) {
|
|
level := parseLogLevel(logLevel)
|
|
logF, log := setupDaemonLogger(logPath, logJSON, level)
|
|
defer func() { _ = logF.Close() }()
|
|
|
|
// Set up signal-aware context for graceful shutdown
|
|
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
defer cancel()
|
|
|
|
// Top-level panic recovery to ensure clean shutdown and diagnostics
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
log.Error("daemon crashed", "panic", r)
|
|
|
|
// Capture stack trace
|
|
stackBuf := make([]byte, 4096)
|
|
stackSize := runtime.Stack(stackBuf, false)
|
|
stackTrace := string(stackBuf[:stackSize])
|
|
log.Error("stack trace", "trace", stackTrace)
|
|
|
|
// Write crash report to daemon-error file for user visibility
|
|
var beadsDir string
|
|
if dbPath != "" {
|
|
beadsDir = filepath.Dir(dbPath)
|
|
} else if foundDB := beads.FindDatabasePath(); foundDB != "" {
|
|
beadsDir = filepath.Dir(foundDB)
|
|
}
|
|
|
|
if beadsDir != "" {
|
|
errFile := filepath.Join(beadsDir, "daemon-error")
|
|
crashReport := fmt.Sprintf("Daemon crashed at %s\n\nPanic: %v\n\nStack trace:\n%s\n",
|
|
time.Now().Format(time.RFC3339), r, stackTrace)
|
|
// nolint:gosec // G306: Error file needs to be readable for debugging
|
|
if err := os.WriteFile(errFile, []byte(crashReport), 0644); err != nil {
|
|
log.Warn("could not write crash report", "error", err)
|
|
}
|
|
}
|
|
|
|
// Clean up PID file
|
|
_ = os.Remove(pidFile)
|
|
|
|
log.Info("daemon terminated after panic")
|
|
}
|
|
}()
|
|
|
|
// Determine database path first (needed for lock file metadata)
|
|
daemonDBPath := dbPath
|
|
if daemonDBPath == "" {
|
|
if foundDB := beads.FindDatabasePath(); foundDB != "" {
|
|
daemonDBPath = foundDB
|
|
} else {
|
|
log.Error("no beads database found")
|
|
log.Info("hint: run 'bd init' to create a database or set BEADS_DB environment variable")
|
|
return // Use return instead of os.Exit to allow defers to run
|
|
}
|
|
}
|
|
|
|
lock, err := setupDaemonLock(pidFile, daemonDBPath, log)
|
|
if err != nil {
|
|
return // Use return instead of os.Exit to allow defers to run
|
|
}
|
|
defer func() { _ = lock.Close() }()
|
|
defer func() { _ = os.Remove(pidFile) }()
|
|
|
|
if localMode {
|
|
log.log("Daemon started in LOCAL mode (interval: %v, no git sync)", interval)
|
|
} else {
|
|
log.log("Daemon started (interval: %v, auto-commit: %v, auto-push: %v)", interval, autoCommit, autoPush)
|
|
}
|
|
|
|
// Check for multiple .db files (ambiguity error)
|
|
beadsDir := filepath.Dir(daemonDBPath)
|
|
|
|
// Reset backoff on daemon start (fresh start, but preserve NeedsManualSync hint)
|
|
if !localMode {
|
|
ResetBackoffOnDaemonStart(beadsDir)
|
|
}
|
|
matches, err := filepath.Glob(filepath.Join(beadsDir, "*.db"))
|
|
if err == nil && len(matches) > 1 {
|
|
// Filter out backup files (*.backup-*.db, *.backup.db)
|
|
var validDBs []string
|
|
for _, match := range matches {
|
|
baseName := filepath.Base(match)
|
|
// Skip if it's a backup file (contains ".backup" in name)
|
|
if !strings.Contains(baseName, ".backup") && baseName != "vc.db" {
|
|
validDBs = append(validDBs, match)
|
|
}
|
|
}
|
|
if len(validDBs) > 1 {
|
|
errMsg := fmt.Sprintf("Error: Multiple database files found in %s:\n", beadsDir)
|
|
for _, db := range validDBs {
|
|
errMsg += fmt.Sprintf(" - %s\n", filepath.Base(db))
|
|
}
|
|
errMsg += fmt.Sprintf("\nBeads requires a single canonical database: %s\n", beads.CanonicalDatabaseName)
|
|
errMsg += "Run 'bd init' to migrate legacy databases or manually remove old databases\n"
|
|
errMsg += "Or run 'bd doctor' for more diagnostics"
|
|
|
|
log.log(errMsg)
|
|
|
|
// Write error to file so user can see it without checking logs
|
|
errFile := filepath.Join(beadsDir, "daemon-error")
|
|
// nolint:gosec // G306: Error file needs to be readable for debugging
|
|
if err := os.WriteFile(errFile, []byte(errMsg), 0644); err != nil {
|
|
log.Warn("could not write daemon-error file", "error", err)
|
|
}
|
|
|
|
return // Use return instead of os.Exit to allow defers to run
|
|
}
|
|
}
|
|
|
|
// Validate using canonical name
|
|
dbBaseName := filepath.Base(daemonDBPath)
|
|
if dbBaseName != beads.CanonicalDatabaseName {
|
|
log.Error("non-canonical database name", "name", dbBaseName, "expected", beads.CanonicalDatabaseName)
|
|
log.Info("run 'bd init' to migrate to canonical name")
|
|
return // Use return instead of os.Exit to allow defers to run
|
|
}
|
|
|
|
log.Info("using database", "path", daemonDBPath)
|
|
|
|
// Clear any previous daemon-error file on successful startup
|
|
errFile := filepath.Join(beadsDir, "daemon-error")
|
|
if err := os.Remove(errFile); err != nil && !os.IsNotExist(err) {
|
|
log.Warn("could not remove daemon-error file", "error", err)
|
|
}
|
|
|
|
store, err := sqlite.New(ctx, daemonDBPath)
|
|
if err != nil {
|
|
log.Error("cannot open database", "error", err)
|
|
return // Use return instead of os.Exit to allow defers to run
|
|
}
|
|
defer func() { _ = store.Close() }()
|
|
|
|
// Enable freshness checking to detect external database file modifications
|
|
// (e.g., when git merge replaces the database file)
|
|
store.EnableFreshnessChecking()
|
|
log.Info("database opened", "path", daemonDBPath, "freshness_checking", true)
|
|
|
|
// Auto-upgrade .beads/.gitignore if outdated
|
|
gitignoreCheck := doctor.CheckGitignore()
|
|
if gitignoreCheck.Status == "warning" || gitignoreCheck.Status == "error" {
|
|
log.Info("upgrading .beads/.gitignore")
|
|
if err := doctor.FixGitignore(); err != nil {
|
|
log.Warn("failed to upgrade .gitignore", "error", err)
|
|
} else {
|
|
log.Info("successfully upgraded .beads/.gitignore")
|
|
}
|
|
}
|
|
|
|
// Hydrate from multi-repo if configured
|
|
if results, err := store.HydrateFromMultiRepo(ctx); err != nil {
|
|
log.Error("multi-repo hydration failed", "error", err)
|
|
return // Use return instead of os.Exit to allow defers to run
|
|
} else if results != nil {
|
|
log.Info("multi-repo hydration complete")
|
|
for repo, count := range results {
|
|
log.Info("hydrated issues", "repo", repo, "count", count)
|
|
}
|
|
}
|
|
|
|
// Validate database fingerprint (skip in local mode - no git available)
|
|
if localMode {
|
|
log.Info("skipping fingerprint validation (local mode)")
|
|
} else if err := validateDatabaseFingerprint(ctx, store, &log); err != nil {
|
|
if os.Getenv("BEADS_IGNORE_REPO_MISMATCH") != "1" {
|
|
log.Error("repository fingerprint validation failed", "error", err)
|
|
// Write error to daemon-error file so user sees it instead of just "daemon took too long"
|
|
errFile := filepath.Join(beadsDir, "daemon-error")
|
|
// nolint:gosec // G306: Error file needs to be readable for debugging
|
|
if writeErr := os.WriteFile(errFile, []byte(err.Error()), 0644); writeErr != nil {
|
|
log.Warn("could not write daemon-error file", "error", writeErr)
|
|
}
|
|
return // Use return instead of os.Exit to allow defers to run
|
|
}
|
|
log.Warn("repository mismatch ignored (BEADS_IGNORE_REPO_MISMATCH=1)")
|
|
}
|
|
|
|
// Validate schema version matches daemon version
|
|
versionCtx := context.Background()
|
|
dbVersion, err := store.GetMetadata(versionCtx, "bd_version")
|
|
if err != nil && err.Error() != "metadata key not found: bd_version" {
|
|
log.Error("failed to read database version", "error", err)
|
|
return // Use return instead of os.Exit to allow defers to run
|
|
}
|
|
|
|
if dbVersion != "" && dbVersion != Version {
|
|
log.Warn("database schema version mismatch", "db_version", dbVersion, "daemon_version", Version)
|
|
log.Info("auto-upgrading database to daemon version")
|
|
|
|
// Auto-upgrade database to daemon version
|
|
// The daemon operates on its own database, so it should always use its own version
|
|
if err := store.SetMetadata(versionCtx, "bd_version", Version); err != nil {
|
|
log.Error("failed to update database version", "error", err)
|
|
|
|
// Allow override via environment variable for emergencies
|
|
if os.Getenv("BEADS_IGNORE_VERSION_MISMATCH") != "1" {
|
|
return // Use return instead of os.Exit to allow defers to run
|
|
}
|
|
log.Warn("proceeding despite version update failure (BEADS_IGNORE_VERSION_MISMATCH=1)")
|
|
} else {
|
|
log.Info("database version updated", "version", Version)
|
|
}
|
|
} else if dbVersion == "" {
|
|
// Old database without version metadata - set it now
|
|
log.Warn("database missing version metadata", "setting_to", Version)
|
|
if err := store.SetMetadata(versionCtx, "bd_version", Version); err != nil {
|
|
log.Error("failed to set database version", "error", err)
|
|
return // Use return instead of os.Exit to allow defers to run
|
|
}
|
|
}
|
|
|
|
// Get workspace path (.beads directory) - beadsDir already defined above
|
|
// Get actual workspace root (parent of .beads)
|
|
workspacePath := filepath.Dir(beadsDir)
|
|
// Use short socket path to avoid Unix socket path length limits (macOS: 104 chars)
|
|
socketPath, err := rpc.EnsureSocketDir(rpc.ShortSocketPath(workspacePath))
|
|
if err != nil {
|
|
log.Error("failed to create socket directory", "error", err)
|
|
return
|
|
}
|
|
serverCtx, serverCancel := context.WithCancel(ctx)
|
|
defer serverCancel()
|
|
|
|
server, serverErrChan, err := startRPCServer(serverCtx, socketPath, store, workspacePath, daemonDBPath, log)
|
|
if err != nil {
|
|
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, autoPull, localMode, interval.String(), daemonMode)
|
|
|
|
// Register daemon in global registry
|
|
registry, err := daemon.NewRegistry()
|
|
if err != nil {
|
|
log.Warn("failed to create registry", "error", err)
|
|
} else {
|
|
entry := daemon.RegistryEntry{
|
|
WorkspacePath: workspacePath,
|
|
SocketPath: socketPath,
|
|
DatabasePath: daemonDBPath,
|
|
PID: os.Getpid(),
|
|
Version: Version,
|
|
StartedAt: time.Now(),
|
|
}
|
|
if err := registry.Register(entry); err != nil {
|
|
log.Warn("failed to register daemon", "error", err)
|
|
} else {
|
|
log.Info("registered in global registry")
|
|
}
|
|
// Ensure we unregister on exit
|
|
defer func() {
|
|
if err := registry.Unregister(workspacePath, os.Getpid()); err != nil {
|
|
log.Warn("failed to unregister daemon", "error", err)
|
|
}
|
|
}()
|
|
}
|
|
|
|
ticker := time.NewTicker(interval)
|
|
defer ticker.Stop()
|
|
|
|
// Create sync function based on mode
|
|
var doSync func()
|
|
if localMode {
|
|
doSync = createLocalSyncFunc(ctx, store, log)
|
|
} else {
|
|
doSync = createSyncFunc(ctx, store, autoCommit, autoPush, log)
|
|
}
|
|
doSync()
|
|
|
|
// Get parent PID for monitoring (exit if parent dies)
|
|
parentPID := computeDaemonParentPID()
|
|
log.Info("monitoring parent process", "pid", parentPID)
|
|
|
|
// daemonMode already determined above for SetConfig
|
|
switch daemonMode {
|
|
case "events":
|
|
log.Info("using event-driven mode")
|
|
jsonlPath := findJSONLPath()
|
|
if jsonlPath == "" {
|
|
log.Error("JSONL path not found, cannot use event-driven mode")
|
|
log.Info("falling back to polling mode")
|
|
runEventLoop(ctx, cancel, ticker, doSync, server, serverErrChan, parentPID, log)
|
|
} else {
|
|
// Event-driven mode uses separate export-only and import-only functions
|
|
var doExport, doAutoImport func()
|
|
if localMode {
|
|
doExport = createLocalExportFunc(ctx, store, log)
|
|
doAutoImport = createLocalAutoImportFunc(ctx, store, log)
|
|
} else {
|
|
doExport = createExportFunc(ctx, store, autoCommit, autoPush, log)
|
|
doAutoImport = createAutoImportFunc(ctx, store, log)
|
|
}
|
|
runEventDrivenLoop(ctx, cancel, server, serverErrChan, store, jsonlPath, doExport, doAutoImport, autoPull, parentPID, log)
|
|
}
|
|
case "poll":
|
|
log.Info("using polling mode", "interval", interval)
|
|
runEventLoop(ctx, cancel, ticker, doSync, server, serverErrChan, parentPID, log)
|
|
default:
|
|
log.Warn("unknown BEADS_DAEMON_MODE, defaulting to poll", "mode", daemonMode, "valid", "poll, events")
|
|
runEventLoop(ctx, cancel, ticker, doSync, server, serverErrChan, parentPID, log)
|
|
}
|
|
}
|
|
|
|
// loadDaemonAutoSettings loads daemon sync mode settings.
|
|
//
|
|
// # Two Sync Modes
|
|
//
|
|
// Read/Write Mode (full sync):
|
|
//
|
|
// daemon.auto-sync: true (or BEADS_AUTO_SYNC=true)
|
|
//
|
|
// Enables auto-commit, auto-push, AND auto-pull. Full bidirectional sync
|
|
// with team. Eliminates need for manual `bd sync`. This is the default
|
|
// when sync-branch is configured.
|
|
//
|
|
// Read-Only Mode:
|
|
//
|
|
// daemon.auto-pull: true (or BEADS_AUTO_PULL=true)
|
|
//
|
|
// Only enables auto-pull (receive updates from team). Does NOT auto-publish
|
|
// your changes. Useful for experimental work or manual review before sharing.
|
|
//
|
|
// # Precedence
|
|
//
|
|
// 1. auto-sync=true → Read/Write mode (all three ON, no exceptions)
|
|
// 2. auto-sync=false → Write-side OFF, auto-pull can still be enabled
|
|
// 3. auto-sync not set → Legacy compat mode:
|
|
// - If either BEADS_AUTO_COMMIT/daemon.auto_commit or BEADS_AUTO_PUSH/daemon.auto_push
|
|
// is enabled, treat as auto-sync=true (full read/write)
|
|
// - Otherwise check auto-pull for read-only mode
|
|
// 4. Fallback: all default to true when sync-branch configured
|
|
//
|
|
// Note: The individual auto-commit/auto-push settings are deprecated.
|
|
// Use auto-sync for read/write mode, auto-pull for read-only mode.
|
|
func loadDaemonAutoSettings(cmd *cobra.Command, autoCommit, autoPush, autoPull bool) (bool, bool, bool) {
|
|
dbPath := beads.FindDatabasePath()
|
|
if dbPath == "" {
|
|
return autoCommit, autoPush, autoPull
|
|
}
|
|
|
|
ctx := context.Background()
|
|
store, err := sqlite.New(ctx, dbPath)
|
|
if err != nil {
|
|
return autoCommit, autoPush, autoPull
|
|
}
|
|
defer func() { _ = store.Close() }()
|
|
|
|
// Check if sync-branch is configured (used for defaults)
|
|
syncBranch, _ := store.GetConfig(ctx, "sync.branch")
|
|
hasSyncBranch := syncBranch != ""
|
|
|
|
// Check unified auto-sync setting first (controls auto-commit + auto-push)
|
|
unifiedAutoSync := ""
|
|
if envVal := os.Getenv("BEADS_AUTO_SYNC"); envVal != "" {
|
|
unifiedAutoSync = envVal
|
|
} else if configVal, _ := store.GetConfig(ctx, "daemon.auto-sync"); configVal != "" {
|
|
unifiedAutoSync = configVal
|
|
}
|
|
|
|
// Handle unified auto-sync setting
|
|
if unifiedAutoSync != "" {
|
|
enabled := unifiedAutoSync == "true" || unifiedAutoSync == "1"
|
|
if enabled {
|
|
// auto-sync=true: MASTER CONTROL, forces all three ON
|
|
// Individual CLI flags are ignored - you said "full sync"
|
|
autoCommit = true
|
|
autoPush = true
|
|
autoPull = true
|
|
return autoCommit, autoPush, autoPull
|
|
}
|
|
// auto-sync=false: Write-side (commit/push) locked OFF
|
|
// Only auto-pull can be individually enabled (for read-only mode)
|
|
autoCommit = false
|
|
autoPush = false
|
|
// Auto-pull can still be enabled via CLI flag or individual config
|
|
if cmd.Flags().Changed("auto-pull") {
|
|
// Use the CLI flag value (already in autoPull)
|
|
} else if envVal := os.Getenv("BEADS_AUTO_PULL"); envVal != "" {
|
|
autoPull = envVal == "true" || envVal == "1"
|
|
} else if configVal, _ := store.GetConfig(ctx, "daemon.auto-pull"); configVal != "" {
|
|
autoPull = configVal == "true"
|
|
} else if configVal, _ := store.GetConfig(ctx, "daemon.auto_pull"); configVal != "" {
|
|
autoPull = configVal == "true"
|
|
} else if hasSyncBranch {
|
|
// Default auto-pull to true when sync-branch configured
|
|
autoPull = true
|
|
} else {
|
|
autoPull = false
|
|
}
|
|
return autoCommit, autoPush, autoPull
|
|
}
|
|
|
|
// No unified setting - check legacy individual settings for backward compat
|
|
// If either legacy auto-commit or auto-push is enabled, treat as auto-sync=true
|
|
legacyCommit := false
|
|
legacyPush := false
|
|
|
|
// Check legacy auto-commit (env var or config)
|
|
if envVal := os.Getenv("BEADS_AUTO_COMMIT"); envVal != "" {
|
|
legacyCommit = envVal == "true" || envVal == "1"
|
|
} else if configVal, _ := store.GetConfig(ctx, "daemon.auto_commit"); configVal != "" {
|
|
legacyCommit = configVal == "true"
|
|
}
|
|
|
|
// Check legacy auto-push (env var or config)
|
|
if envVal := os.Getenv("BEADS_AUTO_PUSH"); envVal != "" {
|
|
legacyPush = envVal == "true" || envVal == "1"
|
|
} else if configVal, _ := store.GetConfig(ctx, "daemon.auto_push"); configVal != "" {
|
|
legacyPush = configVal == "true"
|
|
}
|
|
|
|
// If either legacy write-side option is enabled, enable full auto-sync
|
|
// (backward compat: user wanted writes, so give them full sync)
|
|
if legacyCommit || legacyPush {
|
|
autoCommit = true
|
|
autoPush = true
|
|
autoPull = true
|
|
return autoCommit, autoPush, autoPull
|
|
}
|
|
|
|
// Neither legacy write option enabled - check auto-pull for read-only mode
|
|
if !cmd.Flags().Changed("auto-pull") {
|
|
if envVal := os.Getenv("BEADS_AUTO_PULL"); envVal != "" {
|
|
autoPull = envVal == "true" || envVal == "1"
|
|
} else if configVal, _ := store.GetConfig(ctx, "daemon.auto-pull"); configVal != "" {
|
|
autoPull = configVal == "true"
|
|
} else if configVal, _ := store.GetConfig(ctx, "daemon.auto_pull"); configVal != "" {
|
|
autoPull = configVal == "true"
|
|
} else if hasSyncBranch {
|
|
// Default auto-pull to true when sync-branch configured
|
|
autoPull = true
|
|
}
|
|
}
|
|
|
|
// Fallback: if sync-branch configured and no explicit settings, default to full sync
|
|
if hasSyncBranch && !cmd.Flags().Changed("auto-commit") && !cmd.Flags().Changed("auto-push") {
|
|
autoCommit = true
|
|
autoPush = true
|
|
autoPull = true
|
|
}
|
|
|
|
return autoCommit, autoPush, autoPull
|
|
}
|