Files
beads/cmd/bd/main.go
mayor 13c362e67e feat(dolt): add server mode support for multi-client access
- Add dolt_mode, dolt_server_host, dolt_server_port fields to configfile.Config
- Add IsDoltServerMode(), GetDoltServerHost(), GetDoltServerPort() helpers
- Update factory to read server mode config and set Options accordingly
- Skip bootstrap in server mode (database lives on server)
- Pass Database name to dolt.Config for USE statement after connecting
- Disable dolt stats collection to avoid lock issues in embedded mode

This enables bd to connect to a running dolt sql-server (started via
'gt dolt start') instead of using embedded mode, allowing multi-client
access without file locking conflicts.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 22:35:34 -08:00

1058 lines
39 KiB
Go
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"context"
"fmt"
"os"
"os/exec"
"os/signal"
"path/filepath"
"runtime/pprof"
"runtime/trace"
"slices"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/beads"
"github.com/steveyegge/beads/internal/config"
"github.com/steveyegge/beads/internal/configfile"
"github.com/steveyegge/beads/internal/debug"
"github.com/steveyegge/beads/internal/hooks"
"github.com/steveyegge/beads/internal/molecules"
"github.com/steveyegge/beads/internal/rpc"
"github.com/steveyegge/beads/internal/storage"
"github.com/steveyegge/beads/internal/storage/factory"
"github.com/steveyegge/beads/internal/storage/memory"
"github.com/steveyegge/beads/internal/utils"
)
var (
dbPath string
actor string
store storage.Storage
jsonOutput bool
daemonStatus DaemonStatus // Tracks daemon connection state for current command
// Daemon mode
daemonClient *rpc.Client // RPC client when daemon is running
noDaemon bool // Force direct mode (no daemon)
// Signal-aware context for graceful cancellation
rootCtx context.Context
rootCancel context.CancelFunc
// Auto-flush state
autoFlushEnabled = true // Can be disabled with --no-auto-flush
flushMutex sync.Mutex
storeMutex sync.Mutex // Protects store access from background goroutine
storeActive = false // Tracks if store is available
flushFailureCount = 0 // Consecutive flush failures
lastFlushError error // Last flush error for debugging
// Auto-flush manager (event-driven, fixes race condition)
flushManager *FlushManager
// Hook runner for extensibility
hookRunner *hooks.Runner
// skipFinalFlush is set by sync command when sync.branch mode completes successfully.
// This prevents PersistentPostRun from re-exporting and dirtying the working directory.
skipFinalFlush = false
// Auto-import state
autoImportEnabled = true // Can be disabled with --no-auto-import
// Version upgrade tracking
versionUpgradeDetected = false // Set to true if bd version changed since last run
previousVersion = "" // The last bd version user had (empty = first run or unknown)
upgradeAcknowledged = false // Set to true after showing upgrade notification once per session
)
var (
noAutoFlush bool
noAutoImport bool
sandboxMode bool
allowStale bool // Use --allow-stale: skip staleness check (emergency escape hatch)
noDb bool // Use --no-db mode: load from JSONL, write back after each command
readonlyMode bool // Read-only mode: block write operations (for worker sandboxes)
storeIsReadOnly bool // Track if store was opened read-only (for staleness checks)
lockTimeout time.Duration // SQLite busy_timeout (default 30s, 0 = fail immediately)
profileEnabled bool
profileFile *os.File
traceFile *os.File
verboseFlag bool // Enable verbose/debug output
quietFlag bool // Suppress non-essential output
// Dolt auto-commit policy (flag/config). Values: off | on
doltAutoCommit string
// commandDidWrite is set when a command performs a write that should trigger
// auto-flush. Used to decide whether to auto-commit Dolt after the command completes.
// Thread-safe via atomic.Bool to avoid data races in concurrent flush operations.
commandDidWrite atomic.Bool
// commandDidExplicitDoltCommit is set when a command already created a Dolt commit
// explicitly (e.g., bd sync in dolt-native mode, hook flows, bd vc commit).
// This prevents a redundant auto-commit attempt in PersistentPostRun.
commandDidExplicitDoltCommit bool
// commandDidWriteTipMetadata is set when a command records a tip as "shown" by writing
// metadata (tip_*_last_shown). This will be used to create a separate Dolt commit for
// tip writes, even when the main command is read-only.
commandDidWriteTipMetadata bool
// commandTipIDsShown tracks which tip IDs were shown in this command (deduped).
// This is used for tip-commit message formatting.
commandTipIDsShown map[string]struct{}
)
// readOnlyCommands lists commands that only read from the database.
// These commands open SQLite in read-only mode to avoid modifying the
// database file (which breaks file watchers). See GH#804.
var readOnlyCommands = map[string]bool{
"list": true,
"ready": true,
"show": true,
"stats": true,
"blocked": true,
"count": true,
"search": true,
"graph": true,
"duplicates": true,
"comments": true, // list comments (not add)
// NOTE: "export" is NOT read-only - it writes to clear dirty issues and update jsonl_file_hash
}
// isReadOnlyCommand returns true if the command only reads from the database.
// This is used to open SQLite in read-only mode, preventing file modifications
// that would trigger file watchers. See GH#804.
func isReadOnlyCommand(cmdName string) bool {
return readOnlyCommands[cmdName]
}
// getActorWithGit returns the actor for audit trails with git config fallback.
// Priority: --actor flag > BD_ACTOR env > BEADS_ACTOR env > git config user.name > $USER > "unknown"
// This provides a sensible default for developers: their git identity is used unless
// explicitly overridden
func getActorWithGit() string {
// If actor is already set (from --actor flag), use it
if actor != "" {
return actor
}
// Check BD_ACTOR env var (primary env override)
if bdActor := os.Getenv("BD_ACTOR"); bdActor != "" {
return bdActor
}
// Check BEADS_ACTOR env var (alias for MCP/integration compatibility)
if beadsActor := os.Getenv("BEADS_ACTOR"); beadsActor != "" {
return beadsActor
}
// Try git config user.name - the natural default for a git-native tool
if out, err := exec.Command("git", "config", "user.name").Output(); err == nil {
if gitUser := strings.TrimSpace(string(out)); gitUser != "" {
return gitUser
}
}
// Fall back to system username
if user := os.Getenv("USER"); user != "" {
return user
}
return "unknown"
}
// getOwner returns the human owner for CV attribution.
// Priority: GIT_AUTHOR_EMAIL env > git config user.email > "" (empty)
// This is the foundation for HOP CV (curriculum vitae) chains per Decision 008.
// Unlike actor (which tracks who executed), owner tracks the human responsible.
func getOwner() string {
// Check GIT_AUTHOR_EMAIL first - this is set during git commit operations
if authorEmail := os.Getenv("GIT_AUTHOR_EMAIL"); authorEmail != "" {
return authorEmail
}
// Fall back to git config user.email - the natural default
if out, err := exec.Command("git", "config", "user.email").Output(); err == nil {
if gitEmail := strings.TrimSpace(string(out)); gitEmail != "" {
return gitEmail
}
}
// Return empty if no email found (owner is optional)
return ""
}
func init() {
// Initialize viper configuration
if err := config.Initialize(); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to initialize config: %v\n", err)
}
// Add command groups for organized help output
rootCmd.AddGroup(
&cobra.Group{ID: GroupMaintenance, Title: "Maintenance:"},
&cobra.Group{ID: GroupIntegrations, Title: "Integrations & Advanced:"},
)
// Register persistent flags
rootCmd.PersistentFlags().StringVar(&dbPath, "db", "", "Database path (default: auto-discover .beads/*.db)")
rootCmd.PersistentFlags().StringVar(&actor, "actor", "", "Actor name for audit trail (default: $BD_ACTOR, git user.name, $USER)")
rootCmd.PersistentFlags().BoolVar(&jsonOutput, "json", false, "Output in JSON format")
rootCmd.PersistentFlags().BoolVar(&noDaemon, "no-daemon", false, "Force direct storage mode, bypass daemon if running")
rootCmd.PersistentFlags().BoolVar(&noAutoFlush, "no-auto-flush", false, "Disable automatic JSONL sync after CRUD operations")
rootCmd.PersistentFlags().BoolVar(&noAutoImport, "no-auto-import", false, "Disable automatic JSONL import when newer than DB")
rootCmd.PersistentFlags().BoolVar(&sandboxMode, "sandbox", false, "Sandbox mode: disables daemon and auto-sync")
rootCmd.PersistentFlags().BoolVar(&allowStale, "allow-stale", false, "Allow operations on potentially stale data (skip staleness check)")
rootCmd.PersistentFlags().BoolVar(&noDb, "no-db", false, "Use no-db mode: load from JSONL, no SQLite")
rootCmd.PersistentFlags().BoolVar(&readonlyMode, "readonly", false, "Read-only mode: block write operations (for worker sandboxes)")
rootCmd.PersistentFlags().StringVar(&doltAutoCommit, "dolt-auto-commit", "", "Dolt backend: auto-commit after write commands (off|on). Default from config key dolt.auto-commit")
rootCmd.PersistentFlags().DurationVar(&lockTimeout, "lock-timeout", 30*time.Second, "SQLite busy timeout (0 = fail immediately if locked)")
rootCmd.PersistentFlags().BoolVar(&profileEnabled, "profile", false, "Generate CPU profile for performance analysis")
rootCmd.PersistentFlags().BoolVarP(&verboseFlag, "verbose", "v", false, "Enable verbose/debug output")
rootCmd.PersistentFlags().BoolVarP(&quietFlag, "quiet", "q", false, "Suppress non-essential output (errors only)")
// Add --version flag to root command (same behavior as version subcommand)
rootCmd.Flags().BoolP("version", "V", false, "Print version information")
// Command groups for organized help output (Tufte-inspired)
rootCmd.AddGroup(&cobra.Group{ID: "issues", Title: "Working With Issues:"})
rootCmd.AddGroup(&cobra.Group{ID: "views", Title: "Views & Reports:"})
rootCmd.AddGroup(&cobra.Group{ID: "deps", Title: "Dependencies & Structure:"})
rootCmd.AddGroup(&cobra.Group{ID: "sync", Title: "Sync & Data:"})
rootCmd.AddGroup(&cobra.Group{ID: "setup", Title: "Setup & Configuration:"})
// NOTE: Many maintenance commands (clean, cleanup, compact, validate, repair-deps)
// should eventually be consolidated into 'bd doctor' and 'bd doctor --fix' to simplify
// the user experience. The doctor command can detect issues and offer fixes interactively.
rootCmd.AddGroup(&cobra.Group{ID: "maint", Title: "Maintenance:"})
rootCmd.AddGroup(&cobra.Group{ID: "advanced", Title: "Integrations & Advanced:"})
// Custom help function with semantic coloring (Tufte-inspired)
// Note: Usage output (shown on errors) is not styled to avoid recursion issues
rootCmd.SetHelpFunc(colorizedHelpFunc)
}
var rootCmd = &cobra.Command{
Use: "bd",
Short: "bd - Dependency-aware issue tracker",
Long: `Issues chained together like beads. A lightweight issue tracker with first-class dependency support.`,
Run: func(cmd *cobra.Command, args []string) {
// Handle --version flag on root command
if v, _ := cmd.Flags().GetBool("version"); v {
fmt.Printf("bd version %s (%s)\n", Version, Build)
return
}
// No subcommand - show help
_ = cmd.Help()
},
PersistentPreRun: func(cmd *cobra.Command, args []string) {
// Initialize CommandContext to hold runtime state (replaces scattered globals)
initCommandContext()
// Reset per-command write tracking (used by Dolt auto-commit).
commandDidWrite.Store(false)
commandDidExplicitDoltCommit = false
commandDidWriteTipMetadata = false
commandTipIDsShown = make(map[string]struct{})
// Set up signal-aware context for graceful cancellation
rootCtx, rootCancel = signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
// Apply verbosity flags early (before any output)
debug.SetVerbose(verboseFlag)
debug.SetQuiet(quietFlag)
// Apply viper configuration if flags weren't explicitly set
// Priority: flags > viper (config file + env vars) > defaults
// Do this BEFORE early-return so init/version/help respect config
// Track flag overrides for notification (only in verbose mode)
flagOverrides := make(map[string]struct {
Value interface{}
WasSet bool
})
// If flag wasn't explicitly set, use viper value
if !cmd.Flags().Changed("json") {
jsonOutput = config.GetBool("json")
} else {
flagOverrides["json"] = struct {
Value interface{}
WasSet bool
}{jsonOutput, true}
}
if !cmd.Flags().Changed("no-daemon") {
noDaemon = config.GetBool("no-daemon")
} else {
flagOverrides["no-daemon"] = struct {
Value interface{}
WasSet bool
}{noDaemon, true}
}
if !cmd.Flags().Changed("no-auto-flush") {
noAutoFlush = config.GetBool("no-auto-flush")
} else {
flagOverrides["no-auto-flush"] = struct {
Value interface{}
WasSet bool
}{noAutoFlush, true}
}
if !cmd.Flags().Changed("no-auto-import") {
noAutoImport = config.GetBool("no-auto-import")
} else {
flagOverrides["no-auto-import"] = struct {
Value interface{}
WasSet bool
}{noAutoImport, true}
}
if !cmd.Flags().Changed("no-db") {
noDb = config.GetBool("no-db")
} else {
flagOverrides["no-db"] = struct {
Value interface{}
WasSet bool
}{noDb, true}
}
if !cmd.Flags().Changed("readonly") {
readonlyMode = config.GetBool("readonly")
} else {
flagOverrides["readonly"] = struct {
Value interface{}
WasSet bool
}{readonlyMode, true}
}
if !cmd.Flags().Changed("lock-timeout") {
lockTimeout = config.GetDuration("lock-timeout")
} else {
flagOverrides["lock-timeout"] = struct {
Value interface{}
WasSet bool
}{lockTimeout, true}
}
if !cmd.Flags().Changed("db") && dbPath == "" {
dbPath = config.GetString("db")
} else if cmd.Flags().Changed("db") {
flagOverrides["db"] = struct {
Value interface{}
WasSet bool
}{dbPath, true}
}
if !cmd.Flags().Changed("actor") && actor == "" {
actor = config.GetString("actor")
} else if cmd.Flags().Changed("actor") {
flagOverrides["actor"] = struct {
Value interface{}
WasSet bool
}{actor, true}
}
if !cmd.Flags().Changed("dolt-auto-commit") && strings.TrimSpace(doltAutoCommit) == "" {
doltAutoCommit = config.GetString("dolt.auto-commit")
} else if cmd.Flags().Changed("dolt-auto-commit") {
flagOverrides["dolt-auto-commit"] = struct {
Value interface{}
WasSet bool
}{doltAutoCommit, true}
}
// Check for and log configuration overrides (only in verbose mode)
if verboseFlag {
overrides := config.CheckOverrides(flagOverrides)
for _, override := range overrides {
config.LogOverride(override)
}
}
// Validate Dolt auto-commit mode early so all commands fail fast on invalid config.
if _, err := getDoltAutoCommitMode(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
// GH#1093: Check noDbCommands BEFORE expensive operations (ensureForkProtection,
// signalOrchestratorActivity) to avoid spawning git subprocesses for simple commands
// like "bd version" that don't need database access.
noDbCommands := []string{
cmdDaemon,
"__complete", // Cobra's internal completion command (shell completions work without db)
"__completeNoDesc", // Cobra's completion without descriptions (used by fish)
"bash",
"completion",
"doctor",
"fish",
"help",
"hooks",
"human",
"init",
"merge",
"onboard",
"powershell",
"prime",
"quickstart",
"repair",
"resolve-conflicts",
"setup",
"version",
"zsh",
}
// Check both the command name and parent command name for subcommands
cmdName := cmd.Name()
if cmd.Parent() != nil {
parentName := cmd.Parent().Name()
if slices.Contains(noDbCommands, parentName) {
return
}
}
if slices.Contains(noDbCommands, cmdName) {
return
}
// Skip for root command with no subcommand (just shows help)
if cmd.Parent() == nil && cmdName == "bd" {
return
}
// Also skip for --version flag on root command (cmdName would be "bd")
if v, _ := cmd.Flags().GetBool("version"); v {
return
}
// Signal orchestrator daemon about bd activity (best-effort, for exponential backoff)
// GH#1093: Moved after noDbCommands check to avoid git subprocesses for simple commands
defer signalOrchestratorActivity()
// Protect forks from accidentally committing upstream issue database
ensureForkProtection()
// Performance profiling setup
// When --profile is enabled, force direct mode to capture actual database operations
// rather than just RPC serialization/network overhead. This gives accurate profiles
// of the storage layer, query performance, and business logic.
if profileEnabled {
noDaemon = true
timestamp := time.Now().Format("20060102-150405")
if f, _ := os.Create(fmt.Sprintf("bd-profile-%s-%s.prof", cmd.Name(), timestamp)); f != nil {
profileFile = f
_ = pprof.StartCPUProfile(f)
}
if f, _ := os.Create(fmt.Sprintf("bd-trace-%s-%s.out", cmd.Name(), timestamp)); f != nil {
traceFile = f
_ = trace.Start(f)
}
}
// Auto-detect sandboxed environment (Phase 2 for GH #353)
// Only auto-enable if user hasn't explicitly set --sandbox or --no-daemon
if !cmd.Flags().Changed("sandbox") && !cmd.Flags().Changed("no-daemon") {
if isSandboxed() {
sandboxMode = true
fmt.Fprintf(os.Stderr, " Sandbox detected, using direct mode\n")
}
}
// If sandbox mode is set, enable all sandbox flags
if sandboxMode {
noDaemon = true
noAutoFlush = true
noAutoImport = true
// Use shorter lock timeout in sandbox mode unless explicitly set
if !cmd.Flags().Changed("lock-timeout") {
lockTimeout = 100 * time.Millisecond
}
}
// Force direct mode for human-only interactive commands
// edit: can take minutes in $EDITOR, daemon connection times out (GH #227)
if cmd.Name() == "edit" {
noDaemon = true
}
// Set auto-flush based on flag (invert no-auto-flush)
autoFlushEnabled = !noAutoFlush
// Set auto-import based on flag (invert no-auto-import)
autoImportEnabled = !noAutoImport
// Handle --no-db mode: load from JSONL, use in-memory storage
if noDb {
if err := initializeNoDbMode(); err != nil {
fmt.Fprintf(os.Stderr, "Error initializing --no-db mode: %v\n", err)
os.Exit(1)
}
// Set actor for audit trail
actor = getActorWithGit()
// Skip daemon and SQLite initialization - we're in memory mode
return
}
// Initialize database path
if dbPath == "" {
// Use public API to find database (same logic as extensions)
if foundDB := beads.FindDatabasePath(); foundDB != "" {
dbPath = foundDB
} else {
// No database found - check if this is JSONL-only mode
beadsDir := beads.FindBeadsDir()
if beadsDir != "" {
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
// Check if JSONL exists and config.yaml has no-db: true
jsonlExists := false
if _, err := os.Stat(jsonlPath); err == nil {
jsonlExists = true
}
// Use proper YAML parsing to detect no-db mode
isNoDbMode := isNoDbModeConfigured(beadsDir)
// If JSONL-only mode is configured, auto-enable it
if jsonlExists && isNoDbMode {
noDb = true
if err := initializeNoDbMode(); err != nil {
fmt.Fprintf(os.Stderr, "Error initializing JSONL-only mode: %v\n", err)
os.Exit(1)
}
// Set actor for audit trail
actor = getActorWithGit()
return
}
}
// Allow some commands to run without a database
// - import: auto-initializes database if missing
// - setup: creates editor integration files (no DB needed)
// - config set/get for yaml-only keys: writes to config.yaml, not SQLite (GH#536)
isYamlOnlyConfigOp := false
if (cmd.Name() == "set" || cmd.Name() == "get") && cmd.Parent() != nil && cmd.Parent().Name() == "config" {
if len(args) > 0 && config.IsYamlOnlyKey(args[0]) {
isYamlOnlyConfigOp = true
}
}
// Allow read-only commands to auto-bootstrap from JSONL (GH#b09)
// This enables `bd --no-daemon show` after cold-start when DB is missing
canAutoBootstrap := false
if isReadOnlyCommand(cmd.Name()) && beadsDir != "" {
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
if _, err := os.Stat(jsonlPath); err == nil {
canAutoBootstrap = true
debug.Logf("cold-start bootstrap: JSONL exists, allowing auto-create for %s", cmd.Name())
}
}
if cmd.Name() != "import" && cmd.Name() != "setup" && !isYamlOnlyConfigOp && !canAutoBootstrap {
// No database found - provide context-aware error message
fmt.Fprintf(os.Stderr, "Error: no beads database found\n")
// Check if JSONL exists without no-db mode configured
if beadsDir != "" {
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
if _, err := os.Stat(jsonlPath); err == nil {
// JSONL exists but no-db mode not configured
fmt.Fprintf(os.Stderr, "\nFound JSONL file: %s\n", jsonlPath)
fmt.Fprintf(os.Stderr, "This looks like a fresh clone or JSONL-only project.\n\n")
fmt.Fprintf(os.Stderr, "Options:\n")
fmt.Fprintf(os.Stderr, " • Run 'bd init' to create database and import issues\n")
fmt.Fprintf(os.Stderr, " • Use 'bd --no-db %s' for JSONL-only mode\n", cmd.Name())
fmt.Fprintf(os.Stderr, " • Add 'no-db: true' to .beads/config.yaml for permanent JSONL-only mode\n")
os.Exit(1)
}
}
// Generic error - no beads directory or JSONL found
fmt.Fprintf(os.Stderr, "Hint: run 'bd init' to create a database in the current directory\n")
fmt.Fprintf(os.Stderr, " or use 'bd --no-db' to work with JSONL only (no SQLite)\n")
fmt.Fprintf(os.Stderr, " or set BEADS_DIR to point to your .beads directory\n")
os.Exit(1)
}
// For import/setup commands, set default database path
// Invariant: dbPath must always be absolute for filepath.Rel() compatibility
// in daemon sync-branch code path. Use CanonicalizePath for OS-agnostic
// handling (symlinks, case normalization on macOS).
//
// IMPORTANT: Use FindBeadsDir() to get the correct .beads directory,
// which follows redirect files. Without this, a redirected .beads
// would create a local database instead of using the redirect target.
// (GH#bd-0qel)
targetBeadsDir := beads.FindBeadsDir()
if targetBeadsDir == "" {
targetBeadsDir = ".beads"
}
dbPath = utils.CanonicalizePath(filepath.Join(targetBeadsDir, beads.CanonicalDatabaseName))
}
}
// Set actor for audit trail
actor = getActorWithGit()
// Track bd version changes
// Best-effort tracking - failures are silent
trackBdVersion()
// Initialize daemon status
socketPath := getSocketPath()
daemonStatus = DaemonStatus{
Mode: "direct",
Connected: false,
Degraded: true,
SocketPath: socketPath,
AutoStartEnabled: shouldAutoStartDaemon(),
FallbackReason: FallbackNone,
}
// Doctor should always run in direct mode. It's specifically used to diagnose and
// repair daemon/DB issues, so attempting to connect to (or auto-start) a daemon
// can add noise and timeouts.
if cmd.Name() == "doctor" {
noDaemon = true
}
// Wisp operations auto-bypass daemon
// Wisps are ephemeral (Ephemeral=true) and never exported to JSONL,
// so daemon can't help anyway. This reduces friction in wisp workflows.
if isWispOperation(cmd, args) {
noDaemon = true
daemonStatus.FallbackReason = FallbackWispOperation
debug.Logf("wisp operation detected, using direct mode")
}
// Embedded Dolt is single-process-only; never use daemon/RPC.
// (Dolt server mode supports multi-process and won't trigger this.)
// This must be checked after dbPath is resolved.
if !noDaemon && singleProcessOnlyBackend() {
noDaemon = true
daemonStatus.AutoStartEnabled = false
daemonStatus.FallbackReason = FallbackSingleProcessOnly
daemonStatus.Detail = "backend is single-process-only (embedded dolt): daemon mode disabled; using direct mode"
debug.Logf("single-process backend detected, using direct mode")
}
// Try to connect to daemon first (unless --no-daemon flag is set or worktree safety check fails)
if noDaemon {
// Only set FallbackFlagNoDaemon if not already set by auto-bypass logic
if daemonStatus.FallbackReason == FallbackNone {
daemonStatus.FallbackReason = FallbackFlagNoDaemon
debug.Logf("--no-daemon flag set, using direct mode")
}
} else if shouldDisableDaemonForWorktree() {
// In a git worktree without sync-branch configured - daemon is unsafe
// because all worktrees share the same .beads directory and the daemon
// would commit to whatever branch its working directory has checked out.
daemonStatus.FallbackReason = FallbackWorktreeSafety
debug.Logf("git worktree detected without sync-branch, using direct mode for safety")
} else {
// Attempt daemon connection
client, err := rpc.TryConnect(socketPath)
if err == nil && client != nil {
// Set expected database path for validation
if dbPath != "" {
absDBPath, _ := filepath.Abs(dbPath)
client.SetDatabasePath(absDBPath)
}
// Perform health check
health, healthErr := client.Health()
if healthErr == nil && health.Status == statusHealthy {
// Check version compatibility
if !health.Compatible {
debug.Logf("daemon version mismatch (daemon: %s, client: %s), restarting daemon",
health.Version, Version)
_ = client.Close()
// Kill old daemon and restart with new version
if restartDaemonForVersionMismatch() {
// Retry connection after restart
client, err = rpc.TryConnect(socketPath)
if err == nil && client != nil {
if dbPath != "" {
absDBPath, _ := filepath.Abs(dbPath)
client.SetDatabasePath(absDBPath)
}
health, healthErr = client.Health()
if healthErr == nil && health.Status == statusHealthy {
client.SetActor(actor)
daemonClient = client
daemonStatus.Mode = cmdDaemon
daemonStatus.Connected = true
daemonStatus.Degraded = false
daemonStatus.Health = health.Status
debug.Logf("connected to restarted daemon (version: %s)", health.Version)
warnWorktreeDaemon(dbPath)
return
}
}
}
// If restart failed, fall through to direct mode
daemonStatus.FallbackReason = FallbackHealthFailed
daemonStatus.Detail = fmt.Sprintf("version mismatch (daemon: %s, client: %s) and restart failed",
health.Version, Version)
} else {
// Daemon is healthy and compatible - use it
client.SetActor(actor)
daemonClient = client
daemonStatus.Mode = cmdDaemon
daemonStatus.Connected = true
daemonStatus.Degraded = false
daemonStatus.Health = health.Status
debug.Logf("connected to daemon at %s (health: %s)", socketPath, health.Status)
// Warn if using daemon with git worktrees
warnWorktreeDaemon(dbPath)
return // Skip direct storage initialization
}
} else {
// Health check failed or daemon unhealthy
_ = client.Close()
daemonStatus.FallbackReason = FallbackHealthFailed
if healthErr != nil {
daemonStatus.Detail = healthErr.Error()
debug.Logf("daemon health check failed: %v", healthErr)
} else {
daemonStatus.Health = health.Status
daemonStatus.Detail = health.Error
debug.Logf("daemon unhealthy (status=%s): %s", health.Status, health.Error)
}
}
} else {
// Connection failed
daemonStatus.FallbackReason = FallbackConnectFailed
if err != nil {
daemonStatus.Detail = err.Error()
debug.Logf("daemon connect failed at %s: %v", socketPath, err)
}
}
// Daemon not running or unhealthy - try auto-start if enabled
if daemonStatus.AutoStartEnabled {
daemonStatus.AutoStartAttempted = true
debug.Logf("attempting to auto-start daemon")
startTime := time.Now()
if tryAutoStartDaemon(socketPath) {
// Retry connection after auto-start
client, err := rpc.TryConnect(socketPath)
if err == nil && client != nil {
// Set expected database path for validation
if dbPath != "" {
absDBPath, _ := filepath.Abs(dbPath)
client.SetDatabasePath(absDBPath)
}
// Check health of auto-started daemon
health, healthErr := client.Health()
if healthErr == nil && health.Status == statusHealthy {
client.SetActor(actor)
daemonClient = client
daemonStatus.Mode = cmdDaemon
daemonStatus.Connected = true
daemonStatus.Degraded = false
daemonStatus.AutoStartSucceeded = true
daemonStatus.Health = health.Status
daemonStatus.FallbackReason = FallbackNone
elapsed := time.Since(startTime).Milliseconds()
debug.Logf("auto-start succeeded; connected at %s in %dms", socketPath, elapsed)
// Warn if using daemon with git worktrees
warnWorktreeDaemon(dbPath)
return // Skip direct storage initialization
} else {
// Auto-started daemon is unhealthy
_ = client.Close()
daemonStatus.FallbackReason = FallbackHealthFailed
if healthErr != nil {
daemonStatus.Detail = healthErr.Error()
} else {
daemonStatus.Health = health.Status
daemonStatus.Detail = health.Error
}
debug.Logf("auto-started daemon is unhealthy; falling back to direct mode")
}
} else {
// Auto-start completed but connection still failed
daemonStatus.FallbackReason = FallbackAutoStartFailed
if err != nil {
daemonStatus.Detail = err.Error()
}
// Check for daemon-error file to provide better error message
if beadsDir := filepath.Dir(socketPath); beadsDir != "" {
errFile := filepath.Join(beadsDir, "daemon-error")
// nolint:gosec // G304: errFile is derived from secure beads directory
if errMsg, readErr := os.ReadFile(errFile); readErr == nil && len(errMsg) > 0 {
fmt.Fprintf(os.Stderr, "\n%s\n", string(errMsg))
daemonStatus.Detail = string(errMsg)
}
}
debug.Logf("auto-start did not yield a running daemon; falling back to direct mode")
}
} else {
// Auto-start itself failed
daemonStatus.FallbackReason = FallbackAutoStartFailed
debug.Logf("auto-start failed; falling back to direct mode")
}
} else {
// Auto-start disabled - preserve the actual failure reason
// Don't override connect_failed or health_failed with auto_start_disabled
// This preserves important diagnostic info (daemon crashed vs not running)
debug.Logf("auto-start disabled by BEADS_AUTO_START_DAEMON")
}
// Emit BD_VERBOSE warning if falling back to direct mode
if os.Getenv("BD_VERBOSE") != "" {
emitVerboseWarning()
}
debug.Logf("using direct mode (reason: %s)", daemonStatus.FallbackReason)
}
// Check if this is a read-only command (GH#804)
// Read-only commands open SQLite in read-only mode to avoid modifying
// the database file (which breaks file watchers).
useReadOnly := isReadOnlyCommand(cmd.Name())
// Auto-migrate database on version bump
// Skip for read-only commands - they can't write anyway
// Do this AFTER daemon check but BEFORE opening database for main operation
// This ensures: 1) no daemon has DB open, 2) we don't open DB twice
if !useReadOnly {
autoMigrateOnVersionBump(dbPath)
}
// Fall back to direct storage access
var err error
var needsBootstrap bool // Track if DB needs initial import (GH#b09)
beadsDir := filepath.Dir(dbPath)
// Detect backend from metadata.json
backend := factory.GetBackendFromConfig(beadsDir)
// Create storage with appropriate options
opts := factory.Options{
ReadOnly: useReadOnly,
LockTimeout: lockTimeout,
}
if backend == configfile.BackendDolt {
// For Dolt, use the dolt subdirectory
doltPath := filepath.Join(beadsDir, "dolt")
// Check if server mode is configured in metadata.json
cfg, cfgErr := configfile.Load(beadsDir)
if cfgErr == nil && cfg != nil && cfg.IsDoltServerMode() {
opts.ServerMode = true
opts.ServerHost = cfg.GetDoltServerHost()
opts.ServerPort = cfg.GetDoltServerPort()
if cfg.Database != "" {
opts.Database = cfg.Database
}
}
store, err = factory.NewWithOptions(rootCtx, backend, doltPath, opts)
} else {
// SQLite backend
store, err = factory.NewWithOptions(rootCtx, backend, dbPath, opts)
if err != nil && useReadOnly {
// If read-only fails (e.g., DB doesn't exist), fall back to read-write
// This handles the case where user runs "bd list" before "bd init"
debug.Logf("read-only open failed, falling back to read-write: %v", err)
opts.ReadOnly = false
store, err = factory.NewWithOptions(rootCtx, backend, dbPath, opts)
needsBootstrap = true // New DB needs auto-import (GH#b09)
}
}
// Track final read-only state for staleness checks (GH#1089)
// opts.ReadOnly may have changed if read-only open failed and fell back
storeIsReadOnly = opts.ReadOnly
if err != nil {
// Check for fresh clone scenario
if handleFreshCloneError(err, beadsDir) {
os.Exit(1)
}
fmt.Fprintf(os.Stderr, "Error: failed to open database: %v\n", err)
os.Exit(1)
}
// Mark store as active for flush goroutine safety
storeMutex.Lock()
storeActive = true
storeMutex.Unlock()
// Initialize flush manager (fixes race condition in auto-flush)
// Skip FlushManager creation in sandbox mode - no background goroutines needed
// (improves Windows exit behavior and container scenarios)
// Skip for read-only commands - they don't write anything (GH#804)
// For in-process test scenarios where commands run multiple times,
// we create a new manager each time. Shutdown() is idempotent so
// PostRun can safely shutdown whichever manager is active.
if !sandboxMode && !useReadOnly {
flushManager = NewFlushManager(autoFlushEnabled, getDebounceDuration())
}
// Initialize hook runner
// dbPath is .beads/something.db, so workspace root is parent of .beads
if dbPath != "" {
beadsDir := filepath.Dir(dbPath)
hookRunner = hooks.NewRunner(filepath.Join(beadsDir, "hooks"))
}
// Warn if multiple databases detected in directory hierarchy
warnMultipleDatabases(dbPath)
// Auto-import if JSONL is newer than DB (e.g., after git pull)
// Skip for import command itself to avoid recursion
// Skip for delete command to prevent resurrection of deleted issues
// Skip if sync --dry-run to avoid modifying DB in dry-run mode
// Skip for read-only commands - they can't write anyway (GH#804)
// Exception: allow auto-import for read-only commands that fell back to
// read-write mode due to missing DB (needsBootstrap) - fixes GH#b09
if cmd.Name() != "import" && cmd.Name() != "delete" && autoImportEnabled && (!useReadOnly || needsBootstrap) {
// Check if this is sync command with --dry-run flag
if cmd.Name() == "sync" {
if dryRun, _ := cmd.Flags().GetBool("dry-run"); dryRun {
// Skip auto-import in dry-run mode
debug.Logf("auto-import skipped for sync --dry-run")
} else {
autoImportIfNewer()
}
} else {
autoImportIfNewer()
}
}
// Load molecule templates from hierarchical catalog locations
// Templates are loaded after auto-import to ensure the database is up-to-date.
// Skip for import command to avoid conflicts during import operations.
if cmd.Name() != "import" && store != nil {
beadsDir := filepath.Dir(dbPath)
loader := molecules.NewLoader(store)
if result, err := loader.LoadAll(rootCtx, beadsDir); err != nil {
debug.Logf("warning: failed to load molecules: %v", err)
} else if result.Loaded > 0 {
debug.Logf("loaded %d molecules from %v", result.Loaded, result.Sources)
}
}
// Tips (including sync conflict proactive checks) are shown via maybeShowTip()
// after successful command execution, not in PreRun
// Sync all state to CommandContext for unified access
syncCommandContext()
},
PersistentPostRun: func(cmd *cobra.Command, args []string) {
// Handle --no-db mode: write memory storage back to JSONL
if noDb {
if store != nil {
// Determine beads directory (respect BEADS_DIR)
var beadsDir string
if envDir := os.Getenv("BEADS_DIR"); envDir != "" {
// Canonicalize the path
beadsDir = utils.CanonicalizePath(envDir)
} else {
// Fall back to current directory
cwd, err := os.Getwd()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to get current directory: %v\n", err)
os.Exit(1)
}
beadsDir = filepath.Join(cwd, ".beads")
}
if memStore, ok := store.(*memory.MemoryStorage); ok {
if err := writeIssuesToJSONL(memStore, beadsDir); err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to write JSONL: %v\n", err)
os.Exit(1)
}
}
}
return
}
// Close daemon client if we're using it
if daemonClient != nil {
_ = daemonClient.Close()
return
}
// Otherwise, handle direct mode cleanup
// Shutdown flush manager (performs final flush if needed)
// Skip if sync command already handled export and restore (sync.branch mode)
if flushManager != nil && !skipFinalFlush {
if err := flushManager.Shutdown(); err != nil {
fmt.Fprintf(os.Stderr, "Warning: flush manager shutdown error: %v\n", err)
}
}
// Dolt auto-commit: after a successful write command (and after final flush),
// create a Dolt commit so changes don't remain only in the working set.
if commandDidWrite.Load() && !commandDidExplicitDoltCommit {
if err := maybeAutoCommit(rootCtx, doltAutoCommitParams{Command: cmd.Name()}); err != nil {
fmt.Fprintf(os.Stderr, "Error: dolt auto-commit failed: %v\n", err)
os.Exit(1)
}
}
// Tip metadata auto-commit: if a tip was shown, create a separate Dolt commit for the
// tip_*_last_shown metadata updates. This may happen even for otherwise read-only commands.
if commandDidWriteTipMetadata && len(commandTipIDsShown) > 0 {
// Only applies when dolt auto-commit is enabled and backend is versioned (Dolt).
if mode, err := getDoltAutoCommitMode(); err != nil {
fmt.Fprintf(os.Stderr, "Error: dolt tip auto-commit failed: %v\n", err)
os.Exit(1)
} else if mode == doltAutoCommitOn {
// Apply tip metadata writes now (deferred in recordTipShown for Dolt).
for tipID := range commandTipIDsShown {
key := fmt.Sprintf("tip_%s_last_shown", tipID)
value := time.Now().Format(time.RFC3339)
if err := store.SetMetadata(rootCtx, key, value); err != nil {
fmt.Fprintf(os.Stderr, "Error: dolt tip auto-commit failed: %v\n", err)
os.Exit(1)
}
}
ids := make([]string, 0, len(commandTipIDsShown))
for tipID := range commandTipIDsShown {
ids = append(ids, tipID)
}
msg := formatDoltAutoCommitMessage("tip", getActor(), ids)
if err := maybeAutoCommit(rootCtx, doltAutoCommitParams{Command: "tip", MessageOverride: msg}); err != nil {
fmt.Fprintf(os.Stderr, "Error: dolt tip auto-commit failed: %v\n", err)
os.Exit(1)
}
}
}
// Signal that store is closing (prevents background flush from accessing closed store)
storeMutex.Lock()
storeActive = false
storeMutex.Unlock()
if store != nil {
_ = store.Close()
}
if profileFile != nil {
pprof.StopCPUProfile()
_ = profileFile.Close()
}
if traceFile != nil {
trace.Stop()
_ = traceFile.Close()
}
// Cancel the signal context to clean up resources
if rootCancel != nil {
rootCancel()
}
},
}
func main() {
if err := rootCmd.Execute(); err != nil {
os.Exit(1)
}
}