feat(daemons): implement discovery and list command (bd-146, bd-147)
- Add daemon discovery mechanism with socket scanning - Implement depth-limited filesystem walk to avoid hangs - Add DaemonInfo struct with metadata collection - Create 'bd daemons list' command with table and JSON output - Add FindDaemonByWorkspace and CleanupStaleSockets utilities - Fix workspace path to be parent of .beads directory - Add comprehensive tests for discovery functionality Closes bd-146 Closes bd-147
This commit is contained in:
@@ -50,8 +50,8 @@
|
||||
{"id":"bd-143","title":"Review and respond to new GitHub PRs","description":"Check for new pull requests on GitHub and review/respond to them.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-26T13:19:22.407693-07:00","updated_at":"2025-10-26T13:22:33.395599-07:00","closed_at":"2025-10-26T13:22:33.395599-07:00"}
|
||||
{"id":"bd-144","title":"Document bd edit command and verify MCP exclusion","description":"Follow-up from PR #152:\n1. Add \"bd edit\" to AGENTS.md with \"Humans only\" note\n2. Verify MCP server doesn't expose bd edit command\n3. Consider adding test for command registration","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-26T13:23:47.982295-07:00","updated_at":"2025-10-26T13:23:47.982295-07:00","dependencies":[{"issue_id":"bd-144","depends_on_id":"bd-143","type":"discovered-from","created_at":"2025-10-26T13:23:47.983557-07:00","created_by":"daemon"}]}
|
||||
{"id":"bd-145","title":"Add \"bd daemons\" command for multi-daemon management","description":"Add a new \"bd daemons\" command with subcommands to manage daemon processes across all beads repositories/worktrees. Should show all running daemons with metadata (version, workspace, uptime, last sync), allow stopping/restarting individual daemons, auto-clean stale processes, view logs, and show exclusive lock status.","design":"Subcommands:\n- list: Show all running daemons with metadata (workspace, PID, version, socket path, uptime, last activity, exclusive lock status)\n- stop \u003cpath|pid\u003e: Gracefully stop a specific daemon\n- restart \u003cpath|pid\u003e: Stop and restart daemon\n- killall: Emergency stop all daemons\n- health: Verify each daemon responds to ping\n- logs \u003cpath\u003e: View daemon logs\n\nFeatures:\n- Auto-clean stale sockets/dead processes\n- Discovery: Scan for .beads/bd.sock files + running processes\n- Communication: Use existing socket protocol, add GET /status endpoint for metadata","status":"open","priority":1,"issue_type":"epic","created_at":"2025-10-26T16:53:40.970042-07:00","updated_at":"2025-10-26T16:53:40.970042-07:00"}
|
||||
{"id":"bd-146","title":"Implement daemon discovery mechanism","description":"Build the core discovery logic to find all running bd daemons. Scan filesystem for .beads/bd.sock files, check if processes are alive, and collect metadata.","status":"in_progress","priority":1,"issue_type":"task","created_at":"2025-10-26T16:54:00.22163-07:00","updated_at":"2025-10-26T17:55:32.40408-07:00","dependencies":[{"issue_id":"bd-146","depends_on_id":"bd-145","type":"parent-child","created_at":"2025-10-26T16:54:00.222398-07:00","created_by":"daemon"}]}
|
||||
{"id":"bd-147","title":"Implement \"bd daemons list\" subcommand","description":"Create the \"bd daemons list\" command that displays all running daemons in a table with: workspace path, PID, version, socket path, uptime, last activity, exclusive lock status. Include --json flag.","status":"open","priority":1,"issue_type":"task","created_at":"2025-10-26T16:54:00.232956-07:00","updated_at":"2025-10-26T16:54:00.232956-07:00","dependencies":[{"issue_id":"bd-147","depends_on_id":"bd-145","type":"parent-child","created_at":"2025-10-26T17:47:47.922208-07:00","created_by":"stevey"}]}
|
||||
{"id":"bd-146","title":"Implement daemon discovery mechanism","description":"Build the core discovery logic to find all running bd daemons. Scan filesystem for .beads/bd.sock files, check if processes are alive, and collect metadata.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-26T16:54:00.22163-07:00","updated_at":"2025-10-26T18:05:22.257361-07:00","closed_at":"2025-10-26T18:05:22.257361-07:00","dependencies":[{"issue_id":"bd-146","depends_on_id":"bd-145","type":"parent-child","created_at":"2025-10-26T16:54:00.222398-07:00","created_by":"daemon"}]}
|
||||
{"id":"bd-147","title":"Implement \"bd daemons list\" subcommand","description":"Create the \"bd daemons list\" command that displays all running daemons in a table with: workspace path, PID, version, socket path, uptime, last activity, exclusive lock status. Include --json flag.","status":"in_progress","priority":1,"issue_type":"task","created_at":"2025-10-26T16:54:00.232956-07:00","updated_at":"2025-10-26T18:06:29.211531-07:00","dependencies":[{"issue_id":"bd-147","depends_on_id":"bd-145","type":"parent-child","created_at":"2025-10-26T17:47:47.922208-07:00","created_by":"stevey"}]}
|
||||
{"id":"bd-148","title":"Add GET /status endpoint to daemon HTTP server","description":"Add a new HTTP endpoint that returns daemon metadata: version, workspace path, PID, uptime, last activity timestamp, exclusive lock status.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-26T16:54:00.233084-07:00","updated_at":"2025-10-26T17:55:32.40399-07:00","closed_at":"2025-10-26T17:55:32.40399-07:00","dependencies":[{"issue_id":"bd-148","depends_on_id":"bd-145","type":"parent-child","created_at":"2025-10-26T16:54:00.238293-07:00","created_by":"daemon"}]}
|
||||
{"id":"bd-149","title":"Add auto-cleanup of stale sockets and dead processes","description":"When discovering daemons, automatically detect and clean up stale socket files (where process is dead) and orphaned PID files. Should be safe and only remove confirmed-dead processes.","status":"open","priority":1,"issue_type":"task","created_at":"2025-10-26T16:54:00.246629-07:00","updated_at":"2025-10-26T16:54:00.246629-07:00","dependencies":[{"issue_id":"bd-149","depends_on_id":"bd-145","type":"parent-child","created_at":"2025-10-26T16:54:00.247788-07:00","created_by":"daemon"}]}
|
||||
{"id":"bd-15","title":"Phase 4: Gradual Cutover \u0026 Production Rollout","description":"Replace SQLite implementation with Beads library in production and remove legacy code.\n\n**Goal:** Complete transition to Beads library, deprecate and remove custom SQLite implementation.\n\n**Key Tasks:**\n1. Run VC executor with Beads library in CI\n2. Dogfood: Use Beads library for VC's own development\n3. Monitor for regressions and performance issues\n4. Flip feature flag: VC_USE_BEADS_LIBRARY=true by default\n5. Monitor production logs for errors\n6. Collect user feedback\n7. Add deprecation notice to CLAUDE.md\n8. Provide migration guide for users\n9. Remove legacy code: internal/storage/sqlite/sqlite.go (~1500 lines)\n10. Remove migration framework: internal/storage/migrations/\n11. Remove manual transaction management code\n12. Update all documentation\n\n**Acceptance Criteria:**\n- Beads library enabled by default in production\n- Zero production incidents related to migration\n- Performance meets or exceeds SQLite implementation\n- All tests passing with Beads library\n- Legacy SQLite code removed\n- Documentation updated\n- Celebration documented 🎉\n\n**Rollout Strategy:**\n1. Week 1: Enable for CI/testing environments\n2. Week 2: Dogfood on VC development\n3. Week 3: Enable for 50% of production (canary)\n4. Week 4: Enable for 100% of production\n5. Week 5: Remove legacy code\n\n**Monitoring:**\n- Track error rates before/after cutover\n- Monitor database query performance\n- Track issue creation/update latency\n- Monitor executor claim performance\n\n**Rollback Plan:**\n- Keep VC_FORCE_SQLITE=true escape hatch for 2 weeks post-cutover\n- Keep legacy code for 1 sprint after cutover\n- Document rollback procedure\n\n**Success Metrics:**\n- Zero data loss\n- No performance regression (\u003c 5% latency increase acceptable)\n- Reduced maintenance burden (code LOC reduction)\n- Positive developer feedback\n\n**Dependencies:**\n- Blocked by Phase 3 (need migration tooling)\n\n**Estimated Effort:** 1 sprint","status":"closed","priority":2,"issue_type":"task","created_at":"2025-10-22T14:05:07.755107-07:00","updated_at":"2025-10-25T23:15:33.474948-07:00","closed_at":"2025-10-22T21:37:48.748919-07:00","dependencies":[{"issue_id":"bd-15","depends_on_id":"bd-11","type":"parent-child","created_at":"2025-10-24T13:17:40.324637-07:00","created_by":"renumber"},{"issue_id":"bd-15","depends_on_id":"bd-14","type":"blocks","created_at":"2025-10-24T13:17:40.324851-07:00","created_by":"renumber"}]}
|
||||
@@ -64,6 +64,7 @@
|
||||
{"id":"bd-156","title":"bd create files issues in wrong project when multiple beads databases exist","description":"When working in a directory with a beads database (e.g., /Users/stevey/src/wyvern/.beads/wy.db), bd create can file issues in a different project's database instead of the current directory's database.\n\n## Steps to reproduce:\n1. Have multiple beads projects (e.g., ~/src/wyvern with wy.db, ~/vibecoder with vc.db)\n2. cd ~/src/wyvern\n3. Run bd create --title \"Test\" --type bug\n4. Observe issue created with wrong prefix (e.g., vc-1 instead of wy-1)\n\n## Expected behavior:\nbd create should respect the current working directory and use the beads database in that directory (.beads/ folder).\n\n## Actual behavior:\nbd create appears to use a different project's database, possibly the last accessed or a global default.\n\n## Impact:\nThis can cause issues to be filed in completely wrong projects, polluting unrelated issue trackers.\n\n## Suggested fix:\n- Always check for .beads/ directory in current working directory first\n- Add --project flag to explicitly specify which database to use\n- Show which project/database is being used in command output\n- Add validation/confirmation when creating issues if current directory doesn't match database project","status":"closed","priority":2,"issue_type":"bug","created_at":"2025-10-26T17:02:30.578817-07:00","updated_at":"2025-10-26T17:08:43.009159-07:00","closed_at":"2025-10-26T17:08:43.009159-07:00","labels":["cli","project-context"]}
|
||||
{"id":"bd-157","title":"Implement \"bd daemons health\" subcommand","description":"Add health check command that pings each daemon and reports responsiveness. Should detect and report stale sockets, version mismatches, unresponsive daemons.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-26T17:09:51.138682-07:00","updated_at":"2025-10-26T17:47:47.958834-07:00","closed_at":"2025-10-26T17:47:47.958834-07:00","dependencies":[{"issue_id":"bd-157","depends_on_id":"bd-145","type":"parent-child","created_at":"2025-10-26T17:09:51.140111-07:00","created_by":"daemon"}]}
|
||||
{"id":"bd-158","title":"Implement \"bd daemons list\" subcommand","description":"Create the \"bd daemons list\" command that displays all running daemons in a table with: workspace path, PID, version, socket path, uptime, last activity, exclusive lock status. Include --json flag.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-26T17:09:51.140442-07:00","updated_at":"2025-10-26T17:47:47.929666-07:00","closed_at":"2025-10-26T17:47:47.929666-07:00","dependencies":[{"issue_id":"bd-158","depends_on_id":"bd-145","type":"parent-child","created_at":"2025-10-26T17:09:51.150077-07:00","created_by":"daemon"}]}
|
||||
{"id":"bd-159","title":"Timestamp-only changes still being exported despite dedup logic","description":"User observed timestamp-only changes in .beads/beads.jsonl causing dirty working tree. Example: bd-128's updated_at changed from 2025-10-25T23:51:09.811006-07:00 to 2025-10-26T14:12:45.207573-07:00 with no other field changes.\n\nThis should have been prevented by the export deduplication logic that's supposed to skip timestamp-only updates.\n\nNeed to investigate why timestamp-only changes are still being exported and fix the dedup logic.","status":"open","priority":1,"issue_type":"bug","created_at":"2025-10-26T17:58:15.41007-07:00","updated_at":"2025-10-26T17:58:15.41007-07:00"}
|
||||
{"id":"bd-16","title":"Add lifecycle safety docs and tests for UnderlyingDB() method","description":"The new UnderlyingDB() method exposes the raw *sql.DB connection for extensions like VC to create their own tables. While database/sql is concurrency-safe, there are lifecycle and misuse risks that need documentation and testing.\n\n**What needs to be done:**\n\n1. **Enhanced documentation** - Expand UnderlyingDB() comments to warn:\n - Callers MUST NOT call Close() on returned DB\n - Do NOT change pool/driver settings (SetMaxOpenConns, SetConnMaxIdleTime)\n - Do NOT modify SQLite PRAGMAs (WAL mode, journal, etc.)\n - Expect errors after Storage.Close() - use contexts\n - Keep write transactions short to avoid blocking core storage\n\n2. **Add lifecycle tracking** - Implement closed flag:\n - Add atomic.Bool closed field to SQLiteStorage\n - Set flag in Close(), clear in New()\n - Optional: Add IsClosed() bool method\n\n3. **Add safety tests** (run with -race):\n - TestUnderlyingDB_ConcurrentAccess - N goroutines using UnderlyingDB() during normal storage ops\n - TestUnderlyingDB_AfterClose - Verify operations fail cleanly after storage closed\n - TestUnderlyingDB_CreateExtensionTables - Create VC table with FK to issues, verify FK enforcement\n - TestUnderlyingDB_LongTxDoesNotCorrupt - Ensure long read tx doesn't block writes indefinitely\n\n**Why this matters:**\nVC will use this to create tables in the same database. Need to ensure production-ready safety without over-engineering.\n\n**Estimated effort:** S+S+S = M total (1-3h)","design":"Oracle recommends \"simple path\": enhanced docs + minimal guardrails + focused tests. See oracle output for detailed rationale on concurrency safety, lifecycle risks, and when to consider advanced path (wrapping interface).","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-22T17:07:56.812983-07:00","updated_at":"2025-10-25T23:15:33.476053-07:00","closed_at":"2025-10-22T20:10:52.636372-07:00"}
|
||||
{"id":"bd-17","title":"Update EXTENDING.md with UnderlyingDB() usage and best practices","description":"EXTENDING.md currently shows how to use direct sql.Open() to access the database, but doesn't mention the new UnderlyingDB() method that's the recommended way for extensions.\n\n**Update needed:**\n1. Add section showing UnderlyingDB() usage:\n ```go\n store, err := beads.NewSQLiteStorage(dbPath)\n db := store.UnderlyingDB()\n // Create extension tables using db\n ```\n\n2. Document when to use UnderlyingDB() vs direct sql.Open():\n - Use UnderlyingDB() when you want to share the storage connection\n - Use sql.Open() when you need independent connection management\n\n3. Add safety warnings (cross-reference from UnderlyingDB() docs):\n - Don't close the DB\n - Don't modify pool settings\n - Keep transactions short\n\n4. Update the VC example to show UnderlyingDB() pattern\n\n5. Explain beads.Storage.UnderlyingDB() in the API section","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-22T17:07:56.820056-07:00","updated_at":"2025-10-25T23:15:33.478579-07:00","closed_at":"2025-10-22T19:41:19.895847-07:00","dependencies":[{"issue_id":"bd-17","depends_on_id":"bd-10","type":"discovered-from","created_at":"2025-10-24T13:17:40.32522-07:00","created_by":"renumber"}]}
|
||||
{"id":"bd-18","title":"Consider adding UnderlyingConn(ctx) for safer scoped DB access","description":"Currently UnderlyingDB() returns *sql.DB which is correct for most uses, but for extension migrations/DDL, a scoped connection might be safer.\n\n**Proposal:** Add optional UnderlyingConn(ctx) (*sql.Conn, error) method that:\n- Returns a scoped connection via s.db.Conn(ctx)\n- Encourages lifetime-bounded usage\n- Reduces temptation to tune global pool settings\n- Better for one-time DDL operations like CREATE TABLE\n\n**Implementation:**\n```go\n// UnderlyingConn returns a single connection from the pool for scoped use\n// Useful for migrations and DDL. Close the connection when done.\nfunc (s *SQLiteStorage) UnderlyingConn(ctx context.Context) (*sql.Conn, error) {\n return s.db.Conn(ctx)\n}\n```\n\n**Benefits:**\n- Safer for migrations (explicit scope)\n- Complements UnderlyingDB() for different use cases\n- Low implementation cost\n\n**Trade-off:** Adds another method to maintain, but Oracle considers this balanced compromise between safety and flexibility.\n\n**Decision:** This is optional - evaluate based on VC's actual usage patterns.","status":"closed","priority":3,"issue_type":"feature","created_at":"2025-10-22T17:07:56.832638-07:00","updated_at":"2025-10-25T23:15:33.479496-07:00","closed_at":"2025-10-22T22:02:18.479512-07:00","dependencies":[{"issue_id":"bd-18","depends_on_id":"bd-10","type":"related","created_at":"2025-10-24T13:17:40.325463-07:00","created_by":"renumber"}]}
|
||||
|
||||
@@ -1079,8 +1079,11 @@ func runDaemonLoop(interval time.Duration, autoCommit, autoPush bool, logPath, p
|
||||
defer func() { _ = store.Close() }()
|
||||
log.log("Database opened: %s", daemonDBPath)
|
||||
|
||||
workspacePath := filepath.Dir(daemonDBPath)
|
||||
socketPath := filepath.Join(workspacePath, "bd.sock")
|
||||
// Get workspace path (.beads directory)
|
||||
beadsDir := filepath.Dir(daemonDBPath)
|
||||
// Get actual workspace root (parent of .beads)
|
||||
workspacePath := filepath.Dir(beadsDir)
|
||||
socketPath := filepath.Join(beadsDir, "bd.sock")
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
|
||||
125
cmd/bd/daemons.go
Normal file
125
cmd/bd/daemons.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/beads/internal/daemon"
|
||||
)
|
||||
|
||||
var daemonsCmd = &cobra.Command{
|
||||
Use: "daemons",
|
||||
Short: "Manage multiple bd daemons",
|
||||
Long: `Manage bd daemon processes across all repositories and worktrees.
|
||||
|
||||
Subcommands:
|
||||
list - Show all running daemons
|
||||
health - Check health of all daemons
|
||||
killall - Stop all running daemons`,
|
||||
}
|
||||
|
||||
var daemonsListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all running bd daemons",
|
||||
Long: `List all running bd daemons with metadata including workspace path, PID, version,
|
||||
uptime, last activity, and exclusive lock status.`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
searchRoots, _ := cmd.Flags().GetStringSlice("search")
|
||||
jsonOutput, _ := cmd.Flags().GetBool("json")
|
||||
|
||||
// Discover daemons
|
||||
daemons, err := daemon.DiscoverDaemons(searchRoots)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error discovering daemons: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Filter to only alive daemons
|
||||
var aliveDaemons []daemon.DaemonInfo
|
||||
for _, d := range daemons {
|
||||
if d.Alive {
|
||||
aliveDaemons = append(aliveDaemons, d)
|
||||
}
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
data, _ := json.MarshalIndent(aliveDaemons, "", " ")
|
||||
fmt.Println(string(data))
|
||||
return
|
||||
}
|
||||
|
||||
// Human-readable table output
|
||||
if len(aliveDaemons) == 0 {
|
||||
fmt.Println("No running daemons found")
|
||||
return
|
||||
}
|
||||
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "WORKSPACE\tPID\tVERSION\tUPTIME\tLAST ACTIVITY\tLOCK")
|
||||
|
||||
for _, d := range aliveDaemons {
|
||||
workspace := d.WorkspacePath
|
||||
if workspace == "" {
|
||||
workspace = "(unknown)"
|
||||
}
|
||||
|
||||
uptime := formatDaemonDuration(d.UptimeSeconds)
|
||||
|
||||
lastActivity := "(unknown)"
|
||||
if d.LastActivityTime != "" {
|
||||
if t, err := time.Parse(time.RFC3339, d.LastActivityTime); err == nil {
|
||||
lastActivity = formatDaemonRelativeTime(t)
|
||||
}
|
||||
}
|
||||
|
||||
lock := "-"
|
||||
if d.ExclusiveLockActive {
|
||||
lock = fmt.Sprintf("🔒 %s", d.ExclusiveLockHolder)
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "%s\t%d\t%s\t%s\t%s\t%s\n",
|
||||
workspace, d.PID, d.Version, uptime, lastActivity, lock)
|
||||
}
|
||||
|
||||
w.Flush()
|
||||
},
|
||||
}
|
||||
|
||||
func formatDaemonDuration(seconds float64) string {
|
||||
d := time.Duration(seconds * float64(time.Second))
|
||||
if d < time.Minute {
|
||||
return fmt.Sprintf("%.0fs", d.Seconds())
|
||||
} else if d < time.Hour {
|
||||
return fmt.Sprintf("%.0fm", d.Minutes())
|
||||
} else if d < 24*time.Hour {
|
||||
return fmt.Sprintf("%.1fh", d.Hours())
|
||||
}
|
||||
return fmt.Sprintf("%.1fd", d.Hours()/24)
|
||||
}
|
||||
|
||||
func formatDaemonRelativeTime(t time.Time) string {
|
||||
d := time.Since(t)
|
||||
if d < time.Minute {
|
||||
return "just now"
|
||||
} else if d < time.Hour {
|
||||
return fmt.Sprintf("%.0fm ago", d.Minutes())
|
||||
} else if d < 24*time.Hour {
|
||||
return fmt.Sprintf("%.1fh ago", d.Hours())
|
||||
}
|
||||
return fmt.Sprintf("%.1fd ago", d.Hours()/24)
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(daemonsCmd)
|
||||
|
||||
// Add subcommands
|
||||
daemonsCmd.AddCommand(daemonsListCmd)
|
||||
|
||||
// Flags for list command
|
||||
daemonsListCmd.Flags().StringSlice("search", nil, "Directories to search for daemons (default: home, /tmp, cwd)")
|
||||
daemonsListCmd.Flags().Bool("json", false, "Output in JSON format")
|
||||
}
|
||||
203
internal/daemon/discovery.go
Normal file
203
internal/daemon/discovery.go
Normal file
@@ -0,0 +1,203 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/beads/internal/rpc"
|
||||
)
|
||||
|
||||
// walkWithDepth walks a directory tree with depth limiting
|
||||
func walkWithDepth(root string, currentDepth, maxDepth int, fn func(path string, info os.FileInfo) error) error {
|
||||
if currentDepth > maxDepth {
|
||||
return nil
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
// Skip directories we can't read
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
path := filepath.Join(root, entry.Name())
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip common directories that won't have beads databases
|
||||
if info.IsDir() {
|
||||
name := entry.Name()
|
||||
if strings.HasPrefix(name, ".") && name != ".beads" {
|
||||
continue // Skip hidden dirs except .beads
|
||||
}
|
||||
if name == "node_modules" || name == "vendor" || name == ".git" {
|
||||
continue
|
||||
}
|
||||
// Recurse into subdirectory
|
||||
if err := walkWithDepth(path, currentDepth+1, maxDepth, fn); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// Process file
|
||||
if err := fn(path, info); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DaemonInfo represents metadata about a discovered daemon
|
||||
type DaemonInfo struct {
|
||||
WorkspacePath string
|
||||
DatabasePath string
|
||||
SocketPath string
|
||||
PID int
|
||||
Version string
|
||||
UptimeSeconds float64
|
||||
LastActivityTime string
|
||||
ExclusiveLockActive bool
|
||||
ExclusiveLockHolder string
|
||||
Alive bool
|
||||
Error string
|
||||
}
|
||||
|
||||
// DiscoverDaemons scans the filesystem for running bd daemons
|
||||
// It searches common locations and uses the Status RPC endpoint to gather metadata
|
||||
func DiscoverDaemons(searchRoots []string) ([]DaemonInfo, error) {
|
||||
var daemons []DaemonInfo
|
||||
seen := make(map[string]bool)
|
||||
|
||||
// If no search roots provided, use common locations
|
||||
if len(searchRoots) == 0 {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get home directory: %w", err)
|
||||
}
|
||||
searchRoots = []string{
|
||||
home,
|
||||
"/tmp",
|
||||
}
|
||||
// Also add current directory if in a git repo
|
||||
if cwd, err := os.Getwd(); err == nil {
|
||||
searchRoots = append(searchRoots, cwd)
|
||||
}
|
||||
}
|
||||
|
||||
// Search for .beads/bd.sock files (limit depth to avoid traversing entire filesystem)
|
||||
for _, root := range searchRoots {
|
||||
maxDepth := 10 // Limit recursion depth
|
||||
if err := walkWithDepth(root, 0, maxDepth, func(path string, info os.FileInfo) error {
|
||||
// Skip if not a socket file
|
||||
if info.Name() != "bd.sock" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Skip if already seen this socket
|
||||
if seen[path] {
|
||||
return nil
|
||||
}
|
||||
seen[path] = true
|
||||
|
||||
// Try to connect and get status
|
||||
daemon := discoverDaemon(path)
|
||||
daemons = append(daemons, daemon)
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
// Continue searching other roots even if one fails
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return daemons, nil
|
||||
}
|
||||
|
||||
// discoverDaemon attempts to connect to a daemon socket and retrieve its status
|
||||
func discoverDaemon(socketPath string) DaemonInfo {
|
||||
daemon := DaemonInfo{
|
||||
SocketPath: socketPath,
|
||||
Alive: false,
|
||||
}
|
||||
|
||||
// Try to connect with short timeout
|
||||
client, err := rpc.TryConnectWithTimeout(socketPath, 500*time.Millisecond)
|
||||
if err != nil {
|
||||
daemon.Error = fmt.Sprintf("failed to connect: %v", err)
|
||||
return daemon
|
||||
}
|
||||
if client == nil {
|
||||
daemon.Error = "daemon not responding or unhealthy"
|
||||
return daemon
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// Get status
|
||||
status, err := client.Status()
|
||||
if err != nil {
|
||||
daemon.Error = fmt.Sprintf("failed to get status: %v", err)
|
||||
return daemon
|
||||
}
|
||||
|
||||
// Populate daemon info from status
|
||||
daemon.Alive = true
|
||||
daemon.WorkspacePath = status.WorkspacePath
|
||||
daemon.DatabasePath = status.DatabasePath
|
||||
daemon.PID = status.PID
|
||||
daemon.Version = status.Version
|
||||
daemon.UptimeSeconds = status.UptimeSeconds
|
||||
daemon.LastActivityTime = status.LastActivityTime
|
||||
daemon.ExclusiveLockActive = status.ExclusiveLockActive
|
||||
daemon.ExclusiveLockHolder = status.ExclusiveLockHolder
|
||||
|
||||
return daemon
|
||||
}
|
||||
|
||||
// FindDaemonByWorkspace finds a daemon serving a specific workspace
|
||||
func FindDaemonByWorkspace(workspacePath string) (*DaemonInfo, error) {
|
||||
// First try the socket in the workspace itself
|
||||
socketPath := filepath.Join(workspacePath, ".beads", "bd.sock")
|
||||
if _, err := os.Stat(socketPath); err == nil {
|
||||
daemon := discoverDaemon(socketPath)
|
||||
if daemon.Alive {
|
||||
return &daemon, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to discovering all daemons
|
||||
daemons, err := DiscoverDaemons([]string{workspacePath})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, daemon := range daemons {
|
||||
if daemon.WorkspacePath == workspacePath && daemon.Alive {
|
||||
return &daemon, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no daemon found for workspace: %s", workspacePath)
|
||||
}
|
||||
|
||||
// CleanupStaleSockets removes socket files for dead daemons
|
||||
func CleanupStaleSockets(daemons []DaemonInfo) (int, error) {
|
||||
cleaned := 0
|
||||
for _, daemon := range daemons {
|
||||
if !daemon.Alive && daemon.SocketPath != "" {
|
||||
if err := os.Remove(daemon.SocketPath); err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
return cleaned, fmt.Errorf("failed to remove stale socket %s: %w", daemon.SocketPath, err)
|
||||
}
|
||||
} else {
|
||||
cleaned++
|
||||
}
|
||||
}
|
||||
}
|
||||
return cleaned, nil
|
||||
}
|
||||
117
internal/daemon/discovery_test.go
Normal file
117
internal/daemon/discovery_test.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/steveyegge/beads/internal/rpc"
|
||||
"github.com/steveyegge/beads/internal/storage/sqlite"
|
||||
)
|
||||
|
||||
func TestDiscoverDaemon(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
workspace := filepath.Join(tmpDir, ".beads")
|
||||
os.MkdirAll(workspace, 0755)
|
||||
|
||||
// Start daemon
|
||||
dbPath := filepath.Join(workspace, "test.db")
|
||||
socketPath := filepath.Join(workspace, "bd.sock")
|
||||
store, err := sqlite.New(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create storage: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
server := rpc.NewServer(socketPath, store, tmpDir, dbPath)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
go server.Start(ctx)
|
||||
<-server.WaitReady()
|
||||
defer server.Stop()
|
||||
|
||||
// Test discoverDaemon directly
|
||||
daemon := discoverDaemon(socketPath)
|
||||
if !daemon.Alive {
|
||||
t.Errorf("daemon not alive: %s", daemon.Error)
|
||||
}
|
||||
if daemon.PID != os.Getpid() {
|
||||
t.Errorf("wrong PID: expected %d, got %d", os.Getpid(), daemon.PID)
|
||||
}
|
||||
if daemon.UptimeSeconds <= 0 {
|
||||
t.Errorf("invalid uptime: %f", daemon.UptimeSeconds)
|
||||
}
|
||||
if daemon.WorkspacePath != tmpDir {
|
||||
t.Errorf("wrong workspace: expected %s, got %s", tmpDir, daemon.WorkspacePath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindDaemonByWorkspace(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
workspace := filepath.Join(tmpDir, ".beads")
|
||||
os.MkdirAll(workspace, 0755)
|
||||
|
||||
// Start daemon
|
||||
dbPath := filepath.Join(workspace, "test.db")
|
||||
socketPath := filepath.Join(workspace, "bd.sock")
|
||||
store, err := sqlite.New(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create storage: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
server := rpc.NewServer(socketPath, store, tmpDir, dbPath)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
go server.Start(ctx)
|
||||
<-server.WaitReady()
|
||||
defer server.Stop()
|
||||
|
||||
// Find daemon by workspace
|
||||
daemon, err := FindDaemonByWorkspace(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to find daemon: %v", err)
|
||||
}
|
||||
if daemon == nil {
|
||||
t.Fatal("daemon not found")
|
||||
}
|
||||
if !daemon.Alive {
|
||||
t.Errorf("daemon not alive: %s", daemon.Error)
|
||||
}
|
||||
if daemon.WorkspacePath != tmpDir {
|
||||
t.Errorf("wrong workspace: expected %s, got %s", tmpDir, daemon.WorkspacePath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupStaleSockets(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create stale socket file
|
||||
stalePath := filepath.Join(tmpDir, "stale.sock")
|
||||
if err := os.WriteFile(stalePath, []byte{}, 0644); err != nil {
|
||||
t.Fatalf("failed to create stale socket: %v", err)
|
||||
}
|
||||
|
||||
daemons := []DaemonInfo{
|
||||
{
|
||||
SocketPath: stalePath,
|
||||
Alive: false,
|
||||
},
|
||||
}
|
||||
|
||||
cleaned, err := CleanupStaleSockets(daemons)
|
||||
if err != nil {
|
||||
t.Fatalf("cleanup failed: %v", err)
|
||||
}
|
||||
if cleaned != 1 {
|
||||
t.Errorf("expected 1 cleaned, got %d", cleaned)
|
||||
}
|
||||
|
||||
// Verify socket was removed
|
||||
if _, err := os.Stat(stalePath); !os.IsNotExist(err) {
|
||||
t.Error("stale socket still exists")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user