/{cmd,internal}: get dolt backend init working and allow issue creation
This commit is contained in:
106
cmd/bd/daemon.go
106
cmd/bd/daemon.go
@@ -14,6 +14,7 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/steveyegge/beads/cmd/bd/doctor"
|
"github.com/steveyegge/beads/cmd/bd/doctor"
|
||||||
"github.com/steveyegge/beads/internal/beads"
|
"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/rpc"
|
"github.com/steveyegge/beads/internal/rpc"
|
||||||
"github.com/steveyegge/beads/internal/storage/factory"
|
"github.com/steveyegge/beads/internal/storage/factory"
|
||||||
@@ -283,6 +284,17 @@ func runDaemonLoop(interval time.Duration, autoCommit, autoPush, autoPull, local
|
|||||||
logF, log := setupDaemonLogger(logPath, logJSON, level)
|
logF, log := setupDaemonLogger(logPath, logJSON, level)
|
||||||
defer func() { _ = logF.Close() }()
|
defer func() { _ = logF.Close() }()
|
||||||
|
|
||||||
|
writeDaemonError := func(beadsDir string, msg string) {
|
||||||
|
if beadsDir == "" || msg == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
errFile := filepath.Join(beadsDir, "daemon-error")
|
||||||
|
// nolint:gosec // G306: Error file needs to be readable for debugging
|
||||||
|
if err := os.WriteFile(errFile, []byte(msg), 0644); err != nil {
|
||||||
|
log.Warn("could not write daemon-error file", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Set up signal-aware context for graceful shutdown
|
// Set up signal-aware context for graceful shutdown
|
||||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -307,13 +319,9 @@ func runDaemonLoop(interval time.Duration, autoCommit, autoPush, autoPull, local
|
|||||||
}
|
}
|
||||||
|
|
||||||
if beadsDir != "" {
|
if beadsDir != "" {
|
||||||
errFile := filepath.Join(beadsDir, "daemon-error")
|
|
||||||
crashReport := fmt.Sprintf("Daemon crashed at %s\n\nPanic: %v\n\nStack trace:\n%s\n",
|
crashReport := fmt.Sprintf("Daemon crashed at %s\n\nPanic: %v\n\nStack trace:\n%s\n",
|
||||||
time.Now().Format(time.RFC3339), r, stackTrace)
|
time.Now().Format(time.RFC3339), r, stackTrace)
|
||||||
// nolint:gosec // G306: Error file needs to be readable for debugging
|
writeDaemonError(beadsDir, crashReport)
|
||||||
if err := os.WriteFile(errFile, []byte(crashReport), 0644); err != nil {
|
|
||||||
log.Warn("could not write crash report", "error", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up PID file
|
// Clean up PID file
|
||||||
@@ -331,12 +339,17 @@ func runDaemonLoop(interval time.Duration, autoCommit, autoPush, autoPull, local
|
|||||||
} else {
|
} else {
|
||||||
log.Error("no beads database found")
|
log.Error("no beads database found")
|
||||||
log.Info("hint: run 'bd init' to create a database or set BEADS_DB environment variable")
|
log.Info("hint: run 'bd init' to create a database or set BEADS_DB environment variable")
|
||||||
|
if dbPath != "" {
|
||||||
|
writeDaemonError(filepath.Dir(dbPath),
|
||||||
|
"Error: no beads database found\n\nHint: run 'bd init' to create a database or set BEADS_DB environment variable\n")
|
||||||
|
}
|
||||||
return // Use return instead of os.Exit to allow defers to run
|
return // Use return instead of os.Exit to allow defers to run
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
lock, err := setupDaemonLock(pidFile, daemonDBPath, log)
|
lock, err := setupDaemonLock(pidFile, daemonDBPath, log)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
writeDaemonError(filepath.Dir(daemonDBPath), fmt.Sprintf("Error: failed to acquire daemon lock\n\n%v\n", err))
|
||||||
return // Use return instead of os.Exit to allow defers to run
|
return // Use return instead of os.Exit to allow defers to run
|
||||||
}
|
}
|
||||||
defer func() { _ = lock.Close() }()
|
defer func() { _ = lock.Close() }()
|
||||||
@@ -350,50 +363,65 @@ func runDaemonLoop(interval time.Duration, autoCommit, autoPush, autoPull, local
|
|||||||
|
|
||||||
// Check for multiple .db files (ambiguity error)
|
// Check for multiple .db files (ambiguity error)
|
||||||
beadsDir := filepath.Dir(daemonDBPath)
|
beadsDir := filepath.Dir(daemonDBPath)
|
||||||
|
backend := factory.GetBackendFromConfig(beadsDir)
|
||||||
|
if backend == "" {
|
||||||
|
backend = configfile.BackendSQLite
|
||||||
|
}
|
||||||
|
|
||||||
// Reset backoff on daemon start (fresh start, but preserve NeedsManualSync hint)
|
// Reset backoff on daemon start (fresh start, but preserve NeedsManualSync hint)
|
||||||
if !localMode {
|
if !localMode {
|
||||||
ResetBackoffOnDaemonStart(beadsDir)
|
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)
|
// Check for multiple .db files (ambiguity error) - SQLite only.
|
||||||
|
// Dolt is directory-backed so this check is irrelevant and can be misleading.
|
||||||
// Write error to file so user can see it without checking logs
|
if backend == configfile.BackendSQLite {
|
||||||
errFile := filepath.Join(beadsDir, "daemon-error")
|
matches, err := filepath.Glob(filepath.Join(beadsDir, "*.db"))
|
||||||
// nolint:gosec // G306: Error file needs to be readable for debugging
|
if err == nil && len(matches) > 1 {
|
||||||
if err := os.WriteFile(errFile, []byte(errMsg), 0644); err != nil {
|
// Filter out backup files (*.backup-*.db, *.backup.db)
|
||||||
log.Warn("could not write daemon-error file", "error", err)
|
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"
|
||||||
|
|
||||||
return // Use return instead of os.Exit to allow defers to run
|
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
|
// Validate using canonical name (SQLite only).
|
||||||
dbBaseName := filepath.Base(daemonDBPath)
|
// Dolt uses a directory-backed store (typically .beads/dolt), so the "beads.db"
|
||||||
if dbBaseName != beads.CanonicalDatabaseName {
|
// basename invariant does not apply.
|
||||||
log.Error("non-canonical database name", "name", dbBaseName, "expected", beads.CanonicalDatabaseName)
|
if backend == configfile.BackendSQLite {
|
||||||
log.Info("run 'bd init' to migrate to canonical name")
|
dbBaseName := filepath.Base(daemonDBPath)
|
||||||
return // Use return instead of os.Exit to allow defers to run
|
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")
|
||||||
|
writeDaemonError(beadsDir, fmt.Sprintf("Error: non-canonical database name: %s (expected %s)\n\nRun 'bd init' to migrate to canonical name.\n",
|
||||||
|
dbBaseName, beads.CanonicalDatabaseName))
|
||||||
|
return // Use return instead of os.Exit to allow defers to run
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Info("using database", "path", daemonDBPath)
|
log.Info("using database", "path", daemonDBPath)
|
||||||
@@ -407,6 +435,7 @@ func runDaemonLoop(interval time.Duration, autoCommit, autoPush, autoPull, local
|
|||||||
store, err := factory.NewFromConfig(ctx, beadsDir)
|
store, err := factory.NewFromConfig(ctx, beadsDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("cannot open database", "error", err)
|
log.Error("cannot open database", "error", err)
|
||||||
|
writeDaemonError(beadsDir, fmt.Sprintf("Error: cannot open database\n\n%v\n", err))
|
||||||
return // Use return instead of os.Exit to allow defers to run
|
return // Use return instead of os.Exit to allow defers to run
|
||||||
}
|
}
|
||||||
defer func() { _ = store.Close() }()
|
defer func() { _ = store.Close() }()
|
||||||
@@ -621,6 +650,7 @@ func runDaemonLoop(interval time.Duration, autoCommit, autoPush, autoPull, local
|
|||||||
// - If either BEADS_AUTO_COMMIT/daemon.auto_commit or BEADS_AUTO_PUSH/daemon.auto_push
|
// - 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)
|
// is enabled, treat as auto-sync=true (full read/write)
|
||||||
// - Otherwise check auto-pull for read-only mode
|
// - Otherwise check auto-pull for read-only mode
|
||||||
|
//
|
||||||
// 4. Fallback: all default to true when sync-branch configured
|
// 4. Fallback: all default to true when sync-branch configured
|
||||||
//
|
//
|
||||||
// Note: The individual auto-commit/auto-push settings are deprecated.
|
// Note: The individual auto-commit/auto-push settings are deprecated.
|
||||||
|
|||||||
@@ -366,9 +366,28 @@ func startDaemonProcess(socketPath string) bool {
|
|||||||
binPath = os.Args[0]
|
binPath = os.Args[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
args := []string{"daemon", "start"}
|
// IMPORTANT: Use --foreground for auto-start.
|
||||||
|
//
|
||||||
|
// Rationale:
|
||||||
|
// - `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 != "" {
|
||||||
|
|||||||
@@ -40,8 +40,18 @@ Examples:
|
|||||||
logLevel, _ := cmd.Flags().GetString("log-level")
|
logLevel, _ := cmd.Flags().GetString("log-level")
|
||||||
logJSON, _ := cmd.Flags().GetBool("log-json")
|
logJSON, _ := cmd.Flags().GetBool("log-json")
|
||||||
|
|
||||||
// Load auto-commit/push/pull defaults from env vars, config, or sync-branch
|
// NOTE: Only load daemon auto-settings from the database in foreground mode.
|
||||||
autoCommit, autoPush, autoPull = loadDaemonAutoSettings(cmd, autoCommit, autoPush, autoPull)
|
//
|
||||||
|
// In background mode, `bd daemon start` spawns a child process to run the
|
||||||
|
// daemon loop. Opening the database here in the parent process can briefly
|
||||||
|
// hold Dolt's LOCK file long enough for the child to time out and fall back
|
||||||
|
// to read-only mode (100ms lock timeout), which can break startup.
|
||||||
|
//
|
||||||
|
// In background mode, auto-settings are loaded in the actual daemon process
|
||||||
|
// (the BD_DAEMON_FOREGROUND=1 child spawned by startDaemon).
|
||||||
|
if foreground {
|
||||||
|
autoCommit, autoPush, autoPull = loadDaemonAutoSettings(cmd, autoCommit, autoPush, autoPull)
|
||||||
|
}
|
||||||
|
|
||||||
if interval <= 0 {
|
if interval <= 0 {
|
||||||
fmt.Fprintf(os.Stderr, "Error: interval must be positive (got %v)\n", interval)
|
fmt.Fprintf(os.Stderr, "Error: interval must be positive (got %v)\n", interval)
|
||||||
|
|||||||
@@ -439,25 +439,23 @@ func (s *DoltStore) GetStaleIssues(ctx context.Context, filter types.StaleFilter
|
|||||||
func (s *DoltStore) GetStatistics(ctx context.Context) (*types.Statistics, error) {
|
func (s *DoltStore) GetStatistics(ctx context.Context) (*types.Statistics, error) {
|
||||||
stats := &types.Statistics{}
|
stats := &types.Statistics{}
|
||||||
|
|
||||||
// Count by status
|
// Get counts (mirror SQLite semantics: exclude tombstones from TotalIssues, report separately).
|
||||||
|
// Important: COALESCE to avoid NULL scans when the table is empty.
|
||||||
err := s.db.QueryRowContext(ctx, `
|
err := s.db.QueryRowContext(ctx, `
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(*) as total,
|
COALESCE(SUM(CASE WHEN status != 'tombstone' THEN 1 ELSE 0 END), 0) as total,
|
||||||
SUM(CASE WHEN status = 'open' THEN 1 ELSE 0 END) as open_count,
|
COALESCE(SUM(CASE WHEN status = 'open' THEN 1 ELSE 0 END), 0) as open_count,
|
||||||
SUM(CASE WHEN status = 'in_progress' THEN 1 ELSE 0 END) as in_progress,
|
COALESCE(SUM(CASE WHEN status = 'in_progress' THEN 1 ELSE 0 END), 0) as in_progress,
|
||||||
SUM(CASE WHEN status = 'closed' THEN 1 ELSE 0 END) as closed,
|
COALESCE(SUM(CASE WHEN status = 'closed' THEN 1 ELSE 0 END), 0) as closed,
|
||||||
SUM(CASE WHEN status = 'blocked' THEN 1 ELSE 0 END) as blocked,
|
COALESCE(SUM(CASE WHEN status = 'deferred' THEN 1 ELSE 0 END), 0) as deferred,
|
||||||
SUM(CASE WHEN status = 'deferred' THEN 1 ELSE 0 END) as deferred,
|
COALESCE(SUM(CASE WHEN status = 'tombstone' THEN 1 ELSE 0 END), 0) as tombstone,
|
||||||
SUM(CASE WHEN status = 'tombstone' THEN 1 ELSE 0 END) as tombstone,
|
COALESCE(SUM(CASE WHEN pinned = 1 THEN 1 ELSE 0 END), 0) as pinned
|
||||||
SUM(CASE WHEN pinned = 1 THEN 1 ELSE 0 END) as pinned
|
|
||||||
FROM issues
|
FROM issues
|
||||||
WHERE status != 'tombstone'
|
|
||||||
`).Scan(
|
`).Scan(
|
||||||
&stats.TotalIssues,
|
&stats.TotalIssues,
|
||||||
&stats.OpenIssues,
|
&stats.OpenIssues,
|
||||||
&stats.InProgressIssues,
|
&stats.InProgressIssues,
|
||||||
&stats.ClosedIssues,
|
&stats.ClosedIssues,
|
||||||
&stats.BlockedIssues,
|
|
||||||
&stats.DeferredIssues,
|
&stats.DeferredIssues,
|
||||||
&stats.TombstoneIssues,
|
&stats.TombstoneIssues,
|
||||||
&stats.PinnedIssues,
|
&stats.PinnedIssues,
|
||||||
@@ -466,6 +464,27 @@ func (s *DoltStore) GetStatistics(ctx context.Context) (*types.Statistics, error
|
|||||||
return nil, fmt.Errorf("failed to get statistics: %w", err)
|
return nil, fmt.Errorf("failed to get statistics: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Blocked count (same semantics as SQLite: blocked by open deps).
|
||||||
|
err = s.db.QueryRowContext(ctx, `
|
||||||
|
SELECT COUNT(DISTINCT i.id)
|
||||||
|
FROM issues i
|
||||||
|
JOIN dependencies d ON i.id = d.issue_id
|
||||||
|
JOIN issues blocker ON d.depends_on_id = blocker.id
|
||||||
|
WHERE i.status IN ('open', 'in_progress', 'blocked', 'deferred', 'hooked')
|
||||||
|
AND d.type = 'blocks'
|
||||||
|
AND blocker.status IN ('open', 'in_progress', 'blocked', 'deferred', 'hooked')
|
||||||
|
`).Scan(&stats.BlockedIssues)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get blocked count: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ready count (use the ready_issues view).
|
||||||
|
// Note: view already excludes ephemeral issues and blocked transitive deps.
|
||||||
|
err = s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM ready_issues`).Scan(&stats.ReadyIssues)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get ready count: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return stats, nil
|
return stats, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user