/{cmd,docs,internal,website}: make dolt backend explicitly single process
This commit is contained in:
@@ -47,6 +47,7 @@ Common operations:
|
|||||||
bd daemon killall Stop all running daemons
|
bd daemon killall Stop all running daemons
|
||||||
|
|
||||||
Run 'bd daemon --help' to see all subcommands.`,
|
Run 'bd daemon --help' to see all subcommands.`,
|
||||||
|
PersistentPreRunE: guardDaemonUnsupportedForDolt,
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
start, _ := cmd.Flags().GetBool("start")
|
start, _ := cmd.Flags().GetBool("start")
|
||||||
stop, _ := cmd.Flags().GetBool("stop")
|
stop, _ := cmd.Flags().GetBool("stop")
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/steveyegge/beads/internal/beads"
|
||||||
"github.com/steveyegge/beads/internal/config"
|
"github.com/steveyegge/beads/internal/config"
|
||||||
|
"github.com/steveyegge/beads/internal/configfile"
|
||||||
"github.com/steveyegge/beads/internal/debug"
|
"github.com/steveyegge/beads/internal/debug"
|
||||||
"github.com/steveyegge/beads/internal/lockfile"
|
"github.com/steveyegge/beads/internal/lockfile"
|
||||||
"github.com/steveyegge/beads/internal/rpc"
|
"github.com/steveyegge/beads/internal/rpc"
|
||||||
@@ -45,8 +47,38 @@ var (
|
|||||||
sendStopSignalFn = sendStopSignal
|
sendStopSignalFn = sendStopSignal
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// singleProcessOnlyBackend returns true if the current workspace backend is configured
|
||||||
|
// as single-process-only (currently Dolt embedded).
|
||||||
|
//
|
||||||
|
// Best-effort: if we can't determine the backend, we return false and defer to other logic.
|
||||||
|
func singleProcessOnlyBackend() bool {
|
||||||
|
// Prefer dbPath if set; it points to either .beads/<db>.db (sqlite) or .beads/dolt (dolt dir).
|
||||||
|
beadsDir := ""
|
||||||
|
if dbPath != "" {
|
||||||
|
beadsDir = filepath.Dir(dbPath)
|
||||||
|
} else if found := beads.FindDatabasePath(); found != "" {
|
||||||
|
beadsDir = filepath.Dir(found)
|
||||||
|
} else {
|
||||||
|
beadsDir = beads.FindBeadsDir()
|
||||||
|
}
|
||||||
|
if beadsDir == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := configfile.Load(beadsDir)
|
||||||
|
if err != nil || cfg == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return configfile.CapabilitiesForBackend(cfg.GetBackend()).SingleProcessOnly
|
||||||
|
}
|
||||||
|
|
||||||
// shouldAutoStartDaemon checks if daemon auto-start is enabled
|
// shouldAutoStartDaemon checks if daemon auto-start is enabled
|
||||||
func shouldAutoStartDaemon() bool {
|
func shouldAutoStartDaemon() bool {
|
||||||
|
// Dolt backend is single-process-only; do not auto-start daemon.
|
||||||
|
if singleProcessOnlyBackend() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// Check BEADS_NO_DAEMON first (escape hatch for single-user workflows)
|
// Check BEADS_NO_DAEMON first (escape hatch for single-user workflows)
|
||||||
noDaemon := strings.ToLower(strings.TrimSpace(os.Getenv("BEADS_NO_DAEMON")))
|
noDaemon := strings.ToLower(strings.TrimSpace(os.Getenv("BEADS_NO_DAEMON")))
|
||||||
if noDaemon == "1" || noDaemon == "true" || noDaemon == "yes" || noDaemon == "on" {
|
if noDaemon == "1" || noDaemon == "true" || noDaemon == "yes" || noDaemon == "on" {
|
||||||
@@ -70,6 +102,12 @@ func shouldAutoStartDaemon() bool {
|
|||||||
// restartDaemonForVersionMismatch stops the old daemon and starts a new one
|
// restartDaemonForVersionMismatch stops the old daemon and starts a new one
|
||||||
// Returns true if restart was successful
|
// Returns true if restart was successful
|
||||||
func restartDaemonForVersionMismatch() bool {
|
func restartDaemonForVersionMismatch() bool {
|
||||||
|
// Dolt backend is single-process-only; do not restart/spawn daemon.
|
||||||
|
if singleProcessOnlyBackend() {
|
||||||
|
debugLog("single-process backend: skipping daemon restart for version mismatch")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
pidFile, err := getPIDFilePath()
|
pidFile, err := getPIDFilePath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
debug.Logf("failed to get PID file path: %v", err)
|
debug.Logf("failed to get PID file path: %v", err)
|
||||||
@@ -173,6 +211,11 @@ func isDaemonRunningQuiet(pidFile string) bool {
|
|||||||
// tryAutoStartDaemon attempts to start the daemon in the background
|
// tryAutoStartDaemon attempts to start the daemon in the background
|
||||||
// Returns true if daemon was started successfully and socket is ready
|
// Returns true if daemon was started successfully and socket is ready
|
||||||
func tryAutoStartDaemon(socketPath string) bool {
|
func tryAutoStartDaemon(socketPath string) bool {
|
||||||
|
// Dolt backend is single-process-only; do not auto-start daemon.
|
||||||
|
if singleProcessOnlyBackend() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
if !canRetryDaemonStart() {
|
if !canRetryDaemonStart() {
|
||||||
debugLog("skipping auto-start due to recent failures")
|
debugLog("skipping auto-start due to recent failures")
|
||||||
return false
|
return false
|
||||||
@@ -351,6 +394,12 @@ func ensureLockDirectory(lockPath string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func startDaemonProcess(socketPath string) bool {
|
func startDaemonProcess(socketPath string) bool {
|
||||||
|
// Dolt backend is single-process-only; do not spawn a daemon.
|
||||||
|
if singleProcessOnlyBackend() {
|
||||||
|
debugLog("single-process backend: skipping daemon start")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// Early check: daemon requires a git repository (unless --local mode)
|
// Early check: daemon requires a git repository (unless --local mode)
|
||||||
// Skip attempting to start and avoid the 5-second wait if not in git repo
|
// Skip attempting to start and avoid the 5-second wait if not in git repo
|
||||||
if !isGitRepo() {
|
if !isGitRepo() {
|
||||||
@@ -366,28 +415,11 @@ func startDaemonProcess(socketPath string) bool {
|
|||||||
binPath = os.Args[0]
|
binPath = os.Args[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
// IMPORTANT: Use --foreground for auto-start.
|
// Keep sqlite auto-start behavior unchanged: start the daemon via the public
|
||||||
//
|
// `bd daemon start` entrypoint (it will daemonize itself as needed).
|
||||||
// Rationale:
|
args := []string{"daemon", "start"}
|
||||||
// - `bd daemon start` (without --foreground) spawns an additional child process
|
|
||||||
// (`bd daemon --start` with BD_DAEMON_FOREGROUND=1). For Dolt, that extra
|
|
||||||
// daemonization layer can introduce startup races/lock contention (Dolt's
|
|
||||||
// LOCK acquisition timeout is 100ms). If the daemon isn't ready quickly,
|
|
||||||
// the parent falls back to direct mode and may fail to open Dolt because the
|
|
||||||
// daemon holds the write lock.
|
|
||||||
// - Here we already daemonize via SysProcAttr + stdio redirection, so a second
|
|
||||||
// layer is unnecessary.
|
|
||||||
args := []string{"daemon", "start", "--foreground"}
|
|
||||||
|
|
||||||
cmd := execCommandFn(binPath, args...)
|
cmd := execCommandFn(binPath, args...)
|
||||||
// Mark this as a daemon-foreground child so we don't track/kill based on the
|
|
||||||
// short-lived launcher process PID (see computeDaemonParentPID()).
|
|
||||||
// Also force the daemon to bind the same socket we're probing for readiness,
|
|
||||||
// avoiding any mismatch between workspace-derived paths.
|
|
||||||
cmd.Env = append(os.Environ(),
|
|
||||||
"BD_DAEMON_FOREGROUND=1",
|
|
||||||
"BD_SOCKET="+socketPath,
|
|
||||||
)
|
|
||||||
setupDaemonIO(cmd)
|
setupDaemonIO(cmd)
|
||||||
|
|
||||||
if dbPath != "" {
|
if dbPath != "" {
|
||||||
@@ -549,6 +581,9 @@ func emitVerboseWarning() {
|
|||||||
case FallbackWorktreeSafety:
|
case FallbackWorktreeSafety:
|
||||||
// Don't warn - this is expected behavior. User can configure sync-branch to enable daemon.
|
// Don't warn - this is expected behavior. User can configure sync-branch to enable daemon.
|
||||||
return
|
return
|
||||||
|
case FallbackSingleProcessOnly:
|
||||||
|
// Don't warn - daemon is intentionally disabled for single-process backends (e.g., Dolt).
|
||||||
|
return
|
||||||
case FallbackFlagNoDaemon:
|
case FallbackFlagNoDaemon:
|
||||||
// Don't warn when user explicitly requested --no-daemon
|
// Don't warn when user explicitly requested --no-daemon
|
||||||
return
|
return
|
||||||
|
|||||||
66
cmd/bd/daemon_guard.go
Normal file
66
cmd/bd/daemon_guard.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/steveyegge/beads/internal/beads"
|
||||||
|
"github.com/steveyegge/beads/internal/configfile"
|
||||||
|
)
|
||||||
|
|
||||||
|
func singleProcessBackendHelp(backend string) string {
|
||||||
|
b := strings.TrimSpace(backend)
|
||||||
|
if b == "" {
|
||||||
|
b = "unknown"
|
||||||
|
}
|
||||||
|
// Keep this short; Cobra will prefix with "Error:".
|
||||||
|
return fmt.Sprintf("daemon mode is not supported with the %q backend (single-process only). To use daemon mode, initialize with %q (e.g. `bd init --backend sqlite`). Otherwise run commands in direct mode (default for dolt)", b, configfile.BackendSQLite)
|
||||||
|
}
|
||||||
|
|
||||||
|
// guardDaemonUnsupportedForDolt blocks all daemon-related commands when the current
|
||||||
|
// workspace backend is Dolt.
|
||||||
|
//
|
||||||
|
// Rationale: embedded Dolt is effectively single-writer at the OS-process level. The
|
||||||
|
// daemon architecture relies on multiple processes (CLI + daemon + helper spawns),
|
||||||
|
// which can trigger lock contention and transient "read-only" failures.
|
||||||
|
//
|
||||||
|
// We still allow help output so users can discover the command surface.
|
||||||
|
func guardDaemonUnsupportedForDolt(cmd *cobra.Command, _ []string) error {
|
||||||
|
// Allow `--help` for any daemon subcommand.
|
||||||
|
if helpFlag := cmd.Flags().Lookup("help"); helpFlag != nil {
|
||||||
|
if help, _ := cmd.Flags().GetBool("help"); help {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best-effort determine the active workspace backend. If we can't determine it,
|
||||||
|
// don't block (the command will likely fail later anyway).
|
||||||
|
beadsDir := beads.FindBeadsDir()
|
||||||
|
if beadsDir == "" {
|
||||||
|
// Fall back to configured dbPath if set; daemon commands often run from workspace root,
|
||||||
|
// but tests may set BEADS_DB explicitly.
|
||||||
|
if dbPath != "" {
|
||||||
|
beadsDir = filepath.Dir(dbPath)
|
||||||
|
} else if found := beads.FindDatabasePath(); found != "" {
|
||||||
|
beadsDir = filepath.Dir(found)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if beadsDir == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := configfile.Load(beadsDir)
|
||||||
|
if err != nil || cfg == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
backend := cfg.GetBackend()
|
||||||
|
if configfile.CapabilitiesForBackend(backend).SingleProcessOnly {
|
||||||
|
return fmt.Errorf("%s", singleProcessBackendHelp(backend))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
@@ -376,6 +376,13 @@ func startDaemon(interval time.Duration, autoCommit, autoPush, autoPull, localMo
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Guardrail: single-process backends (e.g., Dolt) must never spawn a daemon process.
|
||||||
|
// This should already be blocked by command guards, but keep it defensive.
|
||||||
|
if singleProcessOnlyBackend() {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: daemon mode is not supported for single-process backends (e.g., dolt). Hint: use sqlite backend for daemon mode, or run commands in direct mode\n")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
// Run in foreground if --foreground flag set or if we're the forked child process
|
// Run in foreground if --foreground flag set or if we're the forked child process
|
||||||
if foreground || os.Getenv("BD_DAEMON_FOREGROUND") == "1" {
|
if foreground || os.Getenv("BD_DAEMON_FOREGROUND") == "1" {
|
||||||
runDaemonLoop(interval, autoCommit, autoPush, autoPull, localMode, logPath, pidFile, logLevel, logJSON)
|
runDaemonLoop(interval, autoCommit, autoPush, autoPull, localMode, logPath, pidFile, logLevel, logJSON)
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/steveyegge/beads/internal/beads"
|
||||||
|
"github.com/steveyegge/beads/internal/configfile"
|
||||||
"github.com/steveyegge/beads/internal/daemon"
|
"github.com/steveyegge/beads/internal/daemon"
|
||||||
"github.com/steveyegge/beads/internal/utils"
|
"github.com/steveyegge/beads/internal/utils"
|
||||||
)
|
)
|
||||||
@@ -77,6 +79,7 @@ Subcommands:
|
|||||||
logs - View daemon logs
|
logs - View daemon logs
|
||||||
killall - Stop all running daemons
|
killall - Stop all running daemons
|
||||||
restart - Restart a specific daemon (not yet implemented)`,
|
restart - Restart a specific daemon (not yet implemented)`,
|
||||||
|
PersistentPreRunE: guardDaemonUnsupportedForDolt,
|
||||||
}
|
}
|
||||||
var daemonsListCmd = &cobra.Command{
|
var daemonsListCmd = &cobra.Command{
|
||||||
Use: "list",
|
Use: "list",
|
||||||
@@ -251,6 +254,22 @@ Stops the daemon gracefully, then starts a new one.`,
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
workspace := targetDaemon.WorkspacePath
|
workspace := targetDaemon.WorkspacePath
|
||||||
|
|
||||||
|
// Guardrail: don't (re)start daemons for single-process backends (e.g., Dolt).
|
||||||
|
// This command may be run from a different workspace, so check the target workspace.
|
||||||
|
targetBeadsDir := beads.FollowRedirect(filepath.Join(workspace, ".beads"))
|
||||||
|
if cfg, err := configfile.Load(targetBeadsDir); err == nil && cfg != nil {
|
||||||
|
if configfile.CapabilitiesForBackend(cfg.GetBackend()).SingleProcessOnly {
|
||||||
|
if jsonOutput {
|
||||||
|
outputJSON(map[string]string{"error": fmt.Sprintf("daemon mode is not supported for backend %q (single-process only)", cfg.GetBackend())})
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: cannot restart daemon for workspace %s: backend %q is single-process-only\n", workspace, cfg.GetBackend())
|
||||||
|
fmt.Fprintf(os.Stderr, "Hint: initialize the workspace with sqlite backend for daemon mode (e.g. `bd init --backend sqlite`)\n")
|
||||||
|
}
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Stop the daemon
|
// Stop the daemon
|
||||||
if !jsonOutput {
|
if !jsonOutput {
|
||||||
fmt.Printf("Stopping daemon for workspace: %s (PID %d)\n", workspace, targetDaemon.PID)
|
fmt.Printf("Stopping daemon for workspace: %s (PID %d)\n", workspace, targetDaemon.PID)
|
||||||
|
|||||||
@@ -242,7 +242,7 @@ func TestImportFromJSONLInlineAfterDaemonDisconnect(t *testing.T) {
|
|||||||
daemonClient = nil
|
daemonClient = nil
|
||||||
|
|
||||||
// BUG: Without ensureStoreActive(), importFromJSONLInline fails
|
// BUG: Without ensureStoreActive(), importFromJSONLInline fails
|
||||||
err = importFromJSONLInline(ctx, jsonlPath, false, false)
|
err = importFromJSONLInline(ctx, jsonlPath, false, false, false)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected importFromJSONLInline to fail when store is nil")
|
t.Fatal("expected importFromJSONLInline to fail when store is nil")
|
||||||
}
|
}
|
||||||
@@ -256,7 +256,7 @@ func TestImportFromJSONLInlineAfterDaemonDisconnect(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Now importFromJSONLInline should work
|
// Now importFromJSONLInline should work
|
||||||
err = importFromJSONLInline(ctx, jsonlPath, false, false)
|
err = importFromJSONLInline(ctx, jsonlPath, false, false, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("importFromJSONLInline failed after ensureStoreActive: %v", err)
|
t.Fatalf("importFromJSONLInline failed after ensureStoreActive: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
132
cmd/bd/dolt_singleprocess_test.go
Normal file
132
cmd/bd/dolt_singleprocess_test.go
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeDoltWorkspace(t *testing.T, workspaceDir string) (beadsDir string, doltDir string) {
|
||||||
|
t.Helper()
|
||||||
|
beadsDir = filepath.Join(workspaceDir, ".beads")
|
||||||
|
doltDir = filepath.Join(beadsDir, "dolt")
|
||||||
|
if err := os.MkdirAll(doltDir, 0o700); err != nil {
|
||||||
|
t.Fatalf("mkdir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata := `{
|
||||||
|
"database": "dolt",
|
||||||
|
"backend": "dolt"
|
||||||
|
}`
|
||||||
|
if err := os.WriteFile(filepath.Join(beadsDir, "metadata.json"), []byte(metadata), 0o600); err != nil {
|
||||||
|
t.Fatalf("write metadata.json: %v", err)
|
||||||
|
}
|
||||||
|
return beadsDir, doltDir
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoltSingleProcess_ShouldAutoStartDaemonFalse(t *testing.T) {
|
||||||
|
oldDBPath := dbPath
|
||||||
|
t.Cleanup(func() { dbPath = oldDBPath })
|
||||||
|
dbPath = ""
|
||||||
|
|
||||||
|
ws := t.TempDir()
|
||||||
|
beadsDir, _ := writeDoltWorkspace(t, ws)
|
||||||
|
|
||||||
|
t.Setenv("BEADS_DIR", beadsDir)
|
||||||
|
// Ensure the finder sees a workspace root (and not the repo running tests).
|
||||||
|
oldWD, _ := os.Getwd()
|
||||||
|
_ = os.Chdir(ws)
|
||||||
|
t.Cleanup(func() { _ = os.Chdir(oldWD) })
|
||||||
|
|
||||||
|
if shouldAutoStartDaemon() {
|
||||||
|
t.Fatalf("expected shouldAutoStartDaemon() to be false for dolt backend")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoltSingleProcess_TryAutoStartDoesNotCreateStartlock(t *testing.T) {
|
||||||
|
oldDBPath := dbPath
|
||||||
|
t.Cleanup(func() { dbPath = oldDBPath })
|
||||||
|
dbPath = ""
|
||||||
|
|
||||||
|
ws := t.TempDir()
|
||||||
|
beadsDir, _ := writeDoltWorkspace(t, ws)
|
||||||
|
t.Setenv("BEADS_DIR", beadsDir)
|
||||||
|
|
||||||
|
socketPath := filepath.Join(ws, "bd.sock")
|
||||||
|
lockPath := socketPath + ".startlock"
|
||||||
|
|
||||||
|
ok := tryAutoStartDaemon(socketPath)
|
||||||
|
if ok {
|
||||||
|
t.Fatalf("expected tryAutoStartDaemon() to return false for dolt backend")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(lockPath); err == nil {
|
||||||
|
t.Fatalf("expected startlock not to be created for dolt backend: %s", lockPath)
|
||||||
|
} else if !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("stat startlock: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoltSingleProcess_DaemonGuardBlocksCommands(t *testing.T) {
|
||||||
|
oldDBPath := dbPath
|
||||||
|
t.Cleanup(func() { dbPath = oldDBPath })
|
||||||
|
dbPath = ""
|
||||||
|
|
||||||
|
ws := t.TempDir()
|
||||||
|
beadsDir, _ := writeDoltWorkspace(t, ws)
|
||||||
|
t.Setenv("BEADS_DIR", beadsDir)
|
||||||
|
|
||||||
|
// Ensure help flag exists (cobra adds it during execution; for unit testing we add it explicitly).
|
||||||
|
cmd := daemonCmd
|
||||||
|
cmd.Flags().Bool("help", false, "help")
|
||||||
|
err := guardDaemonUnsupportedForDolt(cmd, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected daemon guard error for dolt backend")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "single-process") {
|
||||||
|
t.Fatalf("expected error to mention single-process, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// This test uses a helper subprocess because startDaemon calls os.Exit on failure.
|
||||||
|
func TestDoltSingleProcess_StartDaemonGuardrailExitsNonZero(t *testing.T) {
|
||||||
|
if os.Getenv("BD_TEST_HELPER_STARTDAEMON") == "1" {
|
||||||
|
// Helper mode: set up environment and invoke startDaemon (should os.Exit(1)).
|
||||||
|
ws := os.Getenv("BD_TEST_WORKSPACE")
|
||||||
|
_, doltDir := writeDoltWorkspace(t, ws)
|
||||||
|
// Ensure FindDatabasePath can resolve.
|
||||||
|
_ = os.Chdir(ws)
|
||||||
|
_ = os.Setenv("BEADS_DB", doltDir)
|
||||||
|
dbPath = ""
|
||||||
|
|
||||||
|
pidFile := filepath.Join(ws, ".beads", "daemon.pid")
|
||||||
|
startDaemon(5*time.Second, false, false, false, false, false, "", pidFile, "info", false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ws := t.TempDir()
|
||||||
|
// Pre-create workspace structure so helper can just use it.
|
||||||
|
_, doltDir := writeDoltWorkspace(t, ws)
|
||||||
|
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("os.Executable: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(exe, "-test.run", "^TestDoltSingleProcess_StartDaemonGuardrailExitsNonZero$", "-test.v")
|
||||||
|
cmd.Env = append(os.Environ(),
|
||||||
|
"BD_TEST_HELPER_STARTDAEMON=1",
|
||||||
|
"BD_TEST_WORKSPACE="+ws,
|
||||||
|
"BEADS_DB="+doltDir,
|
||||||
|
)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected non-zero exit; output:\n%s", string(out))
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(out), "daemon mode is not supported") {
|
||||||
|
t.Fatalf("expected output to mention daemon unsupported; got:\n%s", string(out))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
@@ -18,6 +19,7 @@ import (
|
|||||||
"github.com/steveyegge/beads/internal/git"
|
"github.com/steveyegge/beads/internal/git"
|
||||||
"github.com/steveyegge/beads/internal/storage"
|
"github.com/steveyegge/beads/internal/storage"
|
||||||
"github.com/steveyegge/beads/internal/storage/factory"
|
"github.com/steveyegge/beads/internal/storage/factory"
|
||||||
|
"github.com/steveyegge/beads/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
// hookCmd is the main "bd hook" command that git hooks call into.
|
// hookCmd is the main "bd hook" command that git hooks call into.
|
||||||
@@ -663,7 +665,7 @@ func hookPostMergeDolt(beadsDir string) int {
|
|||||||
|
|
||||||
// Import JSONL to the import branch
|
// Import JSONL to the import branch
|
||||||
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
|
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
|
||||||
if err := importFromJSONLToStore(store, jsonlPath); err != nil {
|
if err := importFromJSONLToStore(ctx, store, jsonlPath); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Warning: could not import JSONL: %v\n", err)
|
fmt.Fprintf(os.Stderr, "Warning: could not import JSONL: %v\n", err)
|
||||||
// Checkout back to original branch
|
// Checkout back to original branch
|
||||||
_ = doltStore.Checkout(ctx, currentBranch)
|
_ = doltStore.Checkout(ctx, currentBranch)
|
||||||
@@ -831,13 +833,41 @@ func hookPostCheckout(args []string) int {
|
|||||||
|
|
||||||
// importFromJSONLToStore imports issues from JSONL to a store.
|
// importFromJSONLToStore imports issues from JSONL to a store.
|
||||||
// This is a placeholder - the actual implementation should use the store's methods.
|
// This is a placeholder - the actual implementation should use the store's methods.
|
||||||
func importFromJSONLToStore(store interface{}, jsonlPath string) error {
|
func importFromJSONLToStore(ctx context.Context, store storage.Storage, jsonlPath string) error {
|
||||||
_ = store
|
// Parse JSONL into issues
|
||||||
_ = jsonlPath
|
// #nosec G304 - jsonlPath is derived from beadsDir (trusted workspace path)
|
||||||
// Use bd sync --import-only for now
|
f, err := os.Open(jsonlPath)
|
||||||
// TODO: Implement direct store import
|
if err != nil {
|
||||||
cmd := exec.Command("bd", "sync", "--import-only", "--no-git-history", "--no-daemon")
|
return err
|
||||||
return cmd.Run()
|
}
|
||||||
|
defer func() { _ = f.Close() }()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(f)
|
||||||
|
// 2MB buffer for large issues
|
||||||
|
scanner.Buffer(make([]byte, 0, 1024), 2*1024*1024)
|
||||||
|
|
||||||
|
var allIssues []*types.Issue
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var issue types.Issue
|
||||||
|
if err := json.Unmarshal([]byte(line), &issue); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
issue.SetDefaults()
|
||||||
|
allIssues = append(allIssues, &issue)
|
||||||
|
}
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import using shared logic (no subprocess).
|
||||||
|
// Use store.Path() as the database path (works for both sqlite and dolt).
|
||||||
|
opts := ImportOptions{}
|
||||||
|
_, err = importIssuesCore(ctx, store.Path(), store, allIssues, opts)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
|||||||
@@ -570,6 +570,16 @@ var rootCmd = &cobra.Command{
|
|||||||
debug.Logf("wisp operation detected, using direct mode")
|
debug.Logf("wisp operation detected, using direct mode")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dolt backend (embedded) is single-process-only; never use daemon/RPC.
|
||||||
|
// 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 (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)
|
// Try to connect to daemon first (unless --no-daemon flag is set or worktree safety check fails)
|
||||||
if noDaemon {
|
if noDaemon {
|
||||||
// Only set FallbackFlagNoDaemon if not already set by auto-bypass logic
|
// Only set FallbackFlagNoDaemon if not already set by auto-bypass logic
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const (
|
|||||||
FallbackConnectFailed = "connect_failed"
|
FallbackConnectFailed = "connect_failed"
|
||||||
FallbackHealthFailed = "health_failed"
|
FallbackHealthFailed = "health_failed"
|
||||||
FallbackWorktreeSafety = "worktree_safety"
|
FallbackWorktreeSafety = "worktree_safety"
|
||||||
|
FallbackSingleProcessOnly = "single_process_only"
|
||||||
cmdDaemon = "daemon"
|
cmdDaemon = "daemon"
|
||||||
cmdImport = "import"
|
cmdImport = "import"
|
||||||
statusHealthy = "healthy"
|
statusHealthy = "healthy"
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ The --full flag provides the legacy full sync behavior for backwards compatibili
|
|||||||
fmt.Println("→ [DRY RUN] Would import from JSONL")
|
fmt.Println("→ [DRY RUN] Would import from JSONL")
|
||||||
} else {
|
} else {
|
||||||
fmt.Println("→ Importing from JSONL...")
|
fmt.Println("→ Importing from JSONL...")
|
||||||
if err := importFromJSONLInline(ctx, jsonlPath, renameOnImport, noGitHistory); err != nil {
|
if err := importFromJSONLInline(ctx, jsonlPath, renameOnImport, noGitHistory, false); err != nil {
|
||||||
FatalError("importing: %v", err)
|
FatalError("importing: %v", err)
|
||||||
}
|
}
|
||||||
fmt.Println("✓ Import complete")
|
fmt.Println("✓ Import complete")
|
||||||
@@ -1066,7 +1066,7 @@ func resolveSyncConflicts(ctx context.Context, jsonlPath string, strategy config
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Import to database
|
// Import to database
|
||||||
if err := importFromJSONLInline(ctx, jsonlPath, false, false); err != nil {
|
if err := importFromJSONLInline(ctx, jsonlPath, false, false, false); err != nil {
|
||||||
return fmt.Errorf("importing merged state: %w", err)
|
return fmt.Errorf("importing merged state: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1198,7 +1198,7 @@ func resolveSyncConflictsManually(ctx context.Context, jsonlPath, beadsDir strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Import to database
|
// Import to database
|
||||||
if err := importFromJSONLInline(ctx, jsonlPath, false, false); err != nil {
|
if err := importFromJSONLInline(ctx, jsonlPath, false, false, false); err != nil {
|
||||||
return fmt.Errorf("importing merged state: %w", err)
|
return fmt.Errorf("importing merged state: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/steveyegge/beads/internal/beads"
|
"github.com/steveyegge/beads/internal/beads"
|
||||||
@@ -33,6 +34,12 @@ func importFromJSONL(ctx context.Context, jsonlPath string, renameOnImport bool,
|
|||||||
protectLeftSnapshot = opts[1]
|
protectLeftSnapshot = opts[1]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Guardrail: single-process backends (e.g., Dolt) must not spawn a helper `bd import`
|
||||||
|
// process while the parent holds an open store. Use inline import instead.
|
||||||
|
if singleProcessOnlyBackend() {
|
||||||
|
return importFromJSONLInline(ctx, jsonlPath, renameOnImport, noGitHistory, protectLeftSnapshot)
|
||||||
|
}
|
||||||
|
|
||||||
// Build args for import command
|
// Build args for import command
|
||||||
// Use --no-daemon to ensure subprocess uses direct mode, avoiding daemon connection issues
|
// Use --no-daemon to ensure subprocess uses direct mode, avoiding daemon connection issues
|
||||||
args := []string{"--no-daemon", "import", "-i", jsonlPath}
|
args := []string{"--no-daemon", "import", "-i", jsonlPath}
|
||||||
@@ -66,7 +73,7 @@ func importFromJSONL(ctx context.Context, jsonlPath string, renameOnImport bool,
|
|||||||
// This avoids path resolution issues when running from directories with .beads/redirect.
|
// This avoids path resolution issues when running from directories with .beads/redirect.
|
||||||
// The parent process's store and dbPath are used, ensuring consistent path resolution.
|
// The parent process's store and dbPath are used, ensuring consistent path resolution.
|
||||||
// (bd-ysal fix)
|
// (bd-ysal fix)
|
||||||
func importFromJSONLInline(ctx context.Context, jsonlPath string, renameOnImport bool, _ /* noGitHistory */ bool) error {
|
func importFromJSONLInline(ctx context.Context, jsonlPath string, renameOnImport bool, _ /* noGitHistory */ bool, protectLeftSnapshot bool) error {
|
||||||
// Verify we have an active store
|
// Verify we have an active store
|
||||||
if store == nil {
|
if store == nil {
|
||||||
return fmt.Errorf("no database store available for inline import")
|
return fmt.Errorf("no database store available for inline import")
|
||||||
@@ -107,6 +114,23 @@ func importFromJSONLInline(ctx context.Context, jsonlPath string, renameOnImport
|
|||||||
opts := ImportOptions{
|
opts := ImportOptions{
|
||||||
RenameOnImport: renameOnImport,
|
RenameOnImport: renameOnImport,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GH#865: timestamp-aware protection for post-pull imports (bd-sync-deletion fix).
|
||||||
|
// Match `bd import --protect-left-snapshot` behavior.
|
||||||
|
if protectLeftSnapshot {
|
||||||
|
beadsDir := filepath.Dir(jsonlPath)
|
||||||
|
leftSnapshotPath := filepath.Join(beadsDir, "beads.left.jsonl")
|
||||||
|
if _, err := os.Stat(leftSnapshotPath); err == nil {
|
||||||
|
sm := NewSnapshotManager(jsonlPath)
|
||||||
|
leftTimestamps, err := sm.BuildIDToTimestampMap(leftSnapshotPath)
|
||||||
|
if err != nil {
|
||||||
|
debug.Logf("Warning: failed to read left snapshot: %v", err)
|
||||||
|
} else if len(leftTimestamps) > 0 {
|
||||||
|
opts.ProtectLocalExportIDs = leftTimestamps
|
||||||
|
fmt.Fprintf(os.Stderr, "Protecting %d issue(s) from left snapshot (timestamp-aware)\n", len(leftTimestamps))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
result, err := importIssuesCore(ctx, dbPath, store, allIssues, opts)
|
result, err := importIssuesCore(ctx, dbPath, store, allIssues, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("import failed: %w", err)
|
return fmt.Errorf("import failed: %w", err)
|
||||||
|
|||||||
@@ -57,6 +57,10 @@ Tool-level settings you can configure:
|
|||||||
| `daemon-log-max-age` | - | `BEADS_DAEMON_LOG_MAX_AGE` | `30` | Max days to keep old log files |
|
| `daemon-log-max-age` | - | `BEADS_DAEMON_LOG_MAX_AGE` | `30` | Max days to keep old log files |
|
||||||
| `daemon-log-compress` | - | `BEADS_DAEMON_LOG_COMPRESS` | `true` | Compress rotated log files |
|
| `daemon-log-compress` | - | `BEADS_DAEMON_LOG_COMPRESS` | `true` | Compress rotated log files |
|
||||||
|
|
||||||
|
**Backend note (SQLite vs Dolt):**
|
||||||
|
- **SQLite** supports daemon mode and auto-start.
|
||||||
|
- **Dolt (embedded)** is treated as **single-process-only**. Daemon mode and auto-start are disabled; `auto-start-daemon` has no effect. If you need daemon mode, use the SQLite backend (`bd init --backend sqlite`).
|
||||||
|
|
||||||
### Actor Identity Resolution
|
### Actor Identity Resolution
|
||||||
|
|
||||||
The actor name (used for `created_by` in issues and audit trails) is resolved in this order:
|
The actor name (used for `created_by` in issues and audit trails) is resolved in this order:
|
||||||
|
|||||||
@@ -36,11 +36,12 @@ The wizard will:
|
|||||||
- Import existing issues from git (if any)
|
- Import existing issues from git (if any)
|
||||||
- Prompt to install git hooks (recommended)
|
- Prompt to install git hooks (recommended)
|
||||||
- Prompt to configure git merge driver (recommended)
|
- Prompt to configure git merge driver (recommended)
|
||||||
- Auto-start daemon for sync
|
- Auto-start daemon for sync (SQLite backend only)
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- SQLite backend stores data in `.beads/beads.db`.
|
- SQLite backend stores data in `.beads/beads.db`.
|
||||||
- Dolt backend stores data in `.beads/dolt/` and records `"database": "dolt"` in `.beads/metadata.json`.
|
- Dolt backend stores data in `.beads/dolt/` and records `"database": "dolt"` in `.beads/metadata.json`.
|
||||||
|
- Dolt backend runs **single-process-only**; daemon mode is disabled.
|
||||||
|
|
||||||
## Your First Issues
|
## Your First Issues
|
||||||
|
|
||||||
|
|||||||
@@ -149,6 +149,35 @@ const (
|
|||||||
BackendDolt = "dolt"
|
BackendDolt = "dolt"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// BackendCapabilities describes behavioral constraints for a storage backend.
|
||||||
|
//
|
||||||
|
// This is intentionally small and stable: callers should use these flags to decide
|
||||||
|
// whether to enable features like daemon/RPC/autostart and process spawning.
|
||||||
|
//
|
||||||
|
// NOTE: The embedded Dolt driver is effectively single-writer at the OS-process level.
|
||||||
|
// Even if multiple goroutines are safe within one process, multiple processes opening
|
||||||
|
// the same Dolt directory concurrently can cause lock contention and transient
|
||||||
|
// "read-only" failures. Therefore, Dolt is treated as single-process-only.
|
||||||
|
type BackendCapabilities struct {
|
||||||
|
// SingleProcessOnly indicates the backend must not be accessed from multiple
|
||||||
|
// Beads OS processes concurrently (no daemon mode, no RPC client/server split,
|
||||||
|
// no helper-process spawning).
|
||||||
|
SingleProcessOnly bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// CapabilitiesForBackend returns capabilities for a backend string.
|
||||||
|
// Unknown backends are treated conservatively as single-process-only.
|
||||||
|
func CapabilitiesForBackend(backend string) BackendCapabilities {
|
||||||
|
switch strings.TrimSpace(strings.ToLower(backend)) {
|
||||||
|
case "", BackendSQLite:
|
||||||
|
return BackendCapabilities{SingleProcessOnly: false}
|
||||||
|
case BackendDolt:
|
||||||
|
return BackendCapabilities{SingleProcessOnly: true}
|
||||||
|
default:
|
||||||
|
return BackendCapabilities{SingleProcessOnly: true}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// GetBackend returns the configured backend type, defaulting to SQLite.
|
// GetBackend returns the configured backend type, defaulting to SQLite.
|
||||||
func (c *Config) GetBackend() string {
|
func (c *Config) GetBackend() string {
|
||||||
if c.Backend == "" {
|
if c.Backend == "" {
|
||||||
|
|||||||
@@ -37,11 +37,12 @@ The wizard will:
|
|||||||
- Import existing issues from git (if any)
|
- Import existing issues from git (if any)
|
||||||
- Prompt to install git hooks (recommended)
|
- Prompt to install git hooks (recommended)
|
||||||
- Prompt to configure git merge driver (recommended)
|
- Prompt to configure git merge driver (recommended)
|
||||||
- Auto-start daemon for sync
|
- Auto-start daemon for sync (SQLite backend only)
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- SQLite backend stores data in `.beads/beads.db`.
|
- SQLite backend stores data in `.beads/beads.db`.
|
||||||
- Dolt backend stores data in `.beads/dolt/` and records `"database": "dolt"` in `.beads/metadata.json`.
|
- Dolt backend stores data in `.beads/dolt/` and records `"database": "dolt"` in `.beads/metadata.json`.
|
||||||
|
- Dolt backend runs **single-process-only**; daemon mode is disabled.
|
||||||
|
|
||||||
## Your First Issues
|
## Your First Issues
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user