Implement daemon registry system (bd-07b8c8)
- Created global daemon registry at ~/.beads/registry.json - Daemons auto-register on start, unregister on graceful shutdown - DiscoverDaemons() now uses registry instead of filesystem scan - Instant daemon discovery (35ms vs indefinite hang) - Auto-cleanup of stale registry entries - Full test coverage Closes bd-07b8c8, bd-acb971c7
This commit is contained in:
177
internal/daemon/registry.go
Normal file
177
internal/daemon/registry.go
Normal file
@@ -0,0 +1,177 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RegistryEntry represents a daemon entry in the registry
|
||||
type RegistryEntry struct {
|
||||
WorkspacePath string `json:"workspace_path"`
|
||||
SocketPath string `json:"socket_path"`
|
||||
DatabasePath string `json:"database_path"`
|
||||
PID int `json:"pid"`
|
||||
Version string `json:"version"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
}
|
||||
|
||||
// Registry manages the global daemon registry file
|
||||
type Registry struct {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewRegistry creates a new registry instance
|
||||
// The registry is stored in ~/.beads/registry.json
|
||||
func NewRegistry() (*Registry, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get home directory: %w", err)
|
||||
}
|
||||
|
||||
beadsDir := filepath.Join(home, ".beads")
|
||||
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create .beads directory: %w", err)
|
||||
}
|
||||
|
||||
registryPath := filepath.Join(beadsDir, "registry.json")
|
||||
return &Registry{path: registryPath}, nil
|
||||
}
|
||||
|
||||
// readEntries reads all entries from the registry file
|
||||
func (r *Registry) readEntries() ([]RegistryEntry, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
data, err := os.ReadFile(r.path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []RegistryEntry{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to read registry: %w", err)
|
||||
}
|
||||
|
||||
var entries []RegistryEntry
|
||||
if err := json.Unmarshal(data, &entries); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse registry: %w", err)
|
||||
}
|
||||
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// writeEntries writes all entries to the registry file
|
||||
func (r *Registry) writeEntries(entries []RegistryEntry) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
// Ensure we always write an array, never null
|
||||
if entries == nil {
|
||||
entries = []RegistryEntry{}
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(entries, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal registry: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(r.path, data, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write registry: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Register adds a daemon to the registry
|
||||
func (r *Registry) Register(entry RegistryEntry) error {
|
||||
entries, err := r.readEntries()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Remove any existing entry for this workspace or PID
|
||||
filtered := []RegistryEntry{}
|
||||
for _, e := range entries {
|
||||
if e.WorkspacePath != entry.WorkspacePath && e.PID != entry.PID {
|
||||
filtered = append(filtered, e)
|
||||
}
|
||||
}
|
||||
|
||||
// Add new entry
|
||||
filtered = append(filtered, entry)
|
||||
|
||||
return r.writeEntries(filtered)
|
||||
}
|
||||
|
||||
// Unregister removes a daemon from the registry
|
||||
func (r *Registry) Unregister(workspacePath string, pid int) error {
|
||||
entries, err := r.readEntries()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Filter out entries matching workspace or PID
|
||||
filtered := []RegistryEntry{}
|
||||
for _, e := range entries {
|
||||
if e.WorkspacePath != workspacePath && e.PID != pid {
|
||||
filtered = append(filtered, e)
|
||||
}
|
||||
}
|
||||
|
||||
return r.writeEntries(filtered)
|
||||
}
|
||||
|
||||
// List returns all daemons from the registry, automatically cleaning up stale entries
|
||||
func (r *Registry) List() ([]DaemonInfo, error) {
|
||||
entries, err := r.readEntries()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var daemons []DaemonInfo
|
||||
var aliveEntries []RegistryEntry
|
||||
|
||||
for _, entry := range entries {
|
||||
// Check if process is still alive
|
||||
if !isProcessAlive(entry.PID) {
|
||||
// Stale entry - skip and don't add to alive list
|
||||
continue
|
||||
}
|
||||
|
||||
// Process is alive, add to both lists
|
||||
aliveEntries = append(aliveEntries, entry)
|
||||
|
||||
// Try to connect and get current status
|
||||
daemon := discoverDaemon(entry.SocketPath)
|
||||
|
||||
// If connection failed but process is alive, still include basic info
|
||||
if !daemon.Alive {
|
||||
daemon.Alive = true // Process exists, socket just might not be ready
|
||||
daemon.WorkspacePath = entry.WorkspacePath
|
||||
daemon.DatabasePath = entry.DatabasePath
|
||||
daemon.SocketPath = entry.SocketPath
|
||||
daemon.PID = entry.PID
|
||||
daemon.Version = entry.Version
|
||||
}
|
||||
|
||||
daemons = append(daemons, daemon)
|
||||
}
|
||||
|
||||
// Clean up stale entries from registry
|
||||
if len(aliveEntries) != len(entries) {
|
||||
if err := r.writeEntries(aliveEntries); err != nil {
|
||||
// Log warning but don't fail - we still have the daemon list
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to cleanup stale registry entries: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
return daemons, nil
|
||||
}
|
||||
|
||||
// Clear removes all entries from the registry (for testing)
|
||||
func (r *Registry) Clear() error {
|
||||
return r.writeEntries([]RegistryEntry{})
|
||||
}
|
||||
Reference in New Issue
Block a user