From 2a26948d44318dc41c744a4c875f3f23c81a407a Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 9 Mar 2026 14:42:25 +0000 Subject: [PATCH] feat: add daemon Registry for tracking running daemons Co-Authored-By: Claude Opus 4.6 --- registry.go | 138 +++++++++++++++++++++++++++++++++++++++++++++++ registry_test.go | 127 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 265 insertions(+) create mode 100644 registry.go create mode 100644 registry_test.go diff --git a/registry.go b/registry.go new file mode 100644 index 0000000..0f87ddf --- /dev/null +++ b/registry.go @@ -0,0 +1,138 @@ +package process + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "syscall" + "time" +) + +// DaemonEntry records a running daemon in the registry. +type DaemonEntry struct { + Code string `json:"code"` + Daemon string `json:"daemon"` + PID int `json:"pid"` + Health string `json:"health,omitempty"` + Project string `json:"project,omitempty"` + Binary string `json:"binary,omitempty"` + Started time.Time `json:"started"` +} + +// Registry tracks running daemons via JSON files in a directory. +type Registry struct { + dir string +} + +// NewRegistry creates a registry backed by the given directory. +func NewRegistry(dir string) *Registry { + return &Registry{dir: dir} +} + +// DefaultRegistry returns a registry using ~/.core/daemons/. +func DefaultRegistry() *Registry { + home, err := os.UserHomeDir() + if err != nil { + home = os.TempDir() + } + return NewRegistry(filepath.Join(home, ".core", "daemons")) +} + +// Register writes a daemon entry to the registry directory. +// If Started is zero, it is set to the current time. +// The directory is created if it does not exist. +func (r *Registry) Register(entry DaemonEntry) error { + if entry.Started.IsZero() { + entry.Started = time.Now() + } + + if err := os.MkdirAll(r.dir, 0755); err != nil { + return err + } + + data, err := json.MarshalIndent(entry, "", " ") + if err != nil { + return err + } + + return os.WriteFile(r.entryPath(entry.Code, entry.Daemon), data, 0644) +} + +// Unregister removes a daemon entry from the registry. +func (r *Registry) Unregister(code, daemon string) error { + return os.Remove(r.entryPath(code, daemon)) +} + +// Get reads a single daemon entry and checks whether its process is alive. +// If the process is dead, the stale file is removed and (nil, false) is returned. +func (r *Registry) Get(code, daemon string) (*DaemonEntry, bool) { + path := r.entryPath(code, daemon) + + data, err := os.ReadFile(path) + if err != nil { + return nil, false + } + + var entry DaemonEntry + if err := json.Unmarshal(data, &entry); err != nil { + _ = os.Remove(path) + return nil, false + } + + if !isAlive(entry.PID) { + _ = os.Remove(path) + return nil, false + } + + return &entry, true +} + +// List returns all alive daemon entries, pruning any with dead PIDs. +func (r *Registry) List() ([]DaemonEntry, error) { + matches, err := filepath.Glob(filepath.Join(r.dir, "*.json")) + if err != nil { + return nil, err + } + + var alive []DaemonEntry + for _, path := range matches { + data, err := os.ReadFile(path) + if err != nil { + continue + } + + var entry DaemonEntry + if err := json.Unmarshal(data, &entry); err != nil { + _ = os.Remove(path) + continue + } + + if !isAlive(entry.PID) { + _ = os.Remove(path) + continue + } + + alive = append(alive, entry) + } + + return alive, nil +} + +// entryPath returns the filesystem path for a daemon entry. +func (r *Registry) entryPath(code, daemon string) string { + name := strings.ReplaceAll(code, "/", "-") + "-" + strings.ReplaceAll(daemon, "/", "-") + ".json" + return filepath.Join(r.dir, name) +} + +// isAlive checks whether a process with the given PID is running. +func isAlive(pid int) bool { + if pid <= 0 { + return false + } + proc, err := os.FindProcess(pid) + if err != nil { + return false + } + return proc.Signal(syscall.Signal(0)) == nil +} diff --git a/registry_test.go b/registry_test.go new file mode 100644 index 0000000..108ae28 --- /dev/null +++ b/registry_test.go @@ -0,0 +1,127 @@ +package process + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRegistry_RegisterAndGet(t *testing.T) { + dir := t.TempDir() + reg := NewRegistry(dir) + + started := time.Now().UTC().Truncate(time.Second) + entry := DaemonEntry{ + Code: "myapp", + Daemon: "worker", + PID: os.Getpid(), + Health: "healthy", + Project: "test-project", + Binary: "/usr/bin/worker", + Started: started, + } + + err := reg.Register(entry) + require.NoError(t, err) + + got, ok := reg.Get("myapp", "worker") + require.True(t, ok) + assert.Equal(t, "myapp", got.Code) + assert.Equal(t, "worker", got.Daemon) + assert.Equal(t, os.Getpid(), got.PID) + assert.Equal(t, "healthy", got.Health) + assert.Equal(t, "test-project", got.Project) + assert.Equal(t, "/usr/bin/worker", got.Binary) + assert.Equal(t, started, got.Started) +} + +func TestRegistry_Unregister(t *testing.T) { + dir := t.TempDir() + reg := NewRegistry(dir) + + entry := DaemonEntry{ + Code: "myapp", + Daemon: "server", + PID: os.Getpid(), + } + + err := reg.Register(entry) + require.NoError(t, err) + + // File should exist + path := filepath.Join(dir, "myapp-server.json") + _, err = os.Stat(path) + require.NoError(t, err) + + err = reg.Unregister("myapp", "server") + require.NoError(t, err) + + // File should be gone + _, err = os.Stat(path) + assert.True(t, os.IsNotExist(err)) +} + +func TestRegistry_List(t *testing.T) { + dir := t.TempDir() + reg := NewRegistry(dir) + + err := reg.Register(DaemonEntry{Code: "app1", Daemon: "web", PID: os.Getpid()}) + require.NoError(t, err) + err = reg.Register(DaemonEntry{Code: "app2", Daemon: "api", PID: os.Getpid()}) + require.NoError(t, err) + + entries, err := reg.List() + require.NoError(t, err) + assert.Len(t, entries, 2) +} + +func TestRegistry_List_PrunesStale(t *testing.T) { + dir := t.TempDir() + reg := NewRegistry(dir) + + err := reg.Register(DaemonEntry{Code: "dead", Daemon: "proc", PID: 999999999}) + require.NoError(t, err) + + // File should exist before listing + path := filepath.Join(dir, "dead-proc.json") + _, err = os.Stat(path) + require.NoError(t, err) + + entries, err := reg.List() + require.NoError(t, err) + assert.Empty(t, entries) + + // Stale file should be removed + _, err = os.Stat(path) + assert.True(t, os.IsNotExist(err)) +} + +func TestRegistry_Get_NotFound(t *testing.T) { + dir := t.TempDir() + reg := NewRegistry(dir) + + got, ok := reg.Get("nope", "missing") + assert.Nil(t, got) + assert.False(t, ok) +} + +func TestRegistry_CreatesDirectory(t *testing.T) { + dir := filepath.Join(t.TempDir(), "nested", "deep", "daemons") + reg := NewRegistry(dir) + + err := reg.Register(DaemonEntry{Code: "app", Daemon: "srv", PID: os.Getpid()}) + require.NoError(t, err) + + info, err := os.Stat(dir) + require.NoError(t, err) + assert.True(t, info.IsDir()) +} + +func TestDefaultRegistry(t *testing.T) { + reg := DefaultRegistry() + assert.NotNil(t, reg) +}