Add SQLiteRegistry and RedisRegistry implementations of the AgentRegistry interface, mirroring the AllowanceStore persistence pattern. SQLiteRegistry uses modernc.org/sqlite directly with a proper agents table schema, WAL mode, and UPSERT semantics. RedisRegistry uses go-redis/v9 with TTL-based natural reaping and SCAN for listing. Add RegistryConfig struct and NewAgentRegistryFromConfig factory function to config.go. Full test parity with existing MemoryRegistry tests, including concurrent access under -race, persistence round-trip, and skip-if-no-Redis pattern. Co-Authored-By: Virgil <virgil@lethean.io>
386 lines
10 KiB
Go
386 lines
10 KiB
Go
package agentic
|
|
|
|
import (
|
|
"path/filepath"
|
|
"sort"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// newTestSQLiteRegistry creates a SQLiteRegistry backed by :memory: for testing.
|
|
func newTestSQLiteRegistry(t *testing.T) *SQLiteRegistry {
|
|
t.Helper()
|
|
reg, err := NewSQLiteRegistry(":memory:")
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { _ = reg.Close() })
|
|
return reg
|
|
}
|
|
|
|
// --- Register tests ---
|
|
|
|
func TestSQLiteRegistry_Register_Good(t *testing.T) {
|
|
reg := newTestSQLiteRegistry(t)
|
|
err := reg.Register(AgentInfo{
|
|
ID: "agent-1",
|
|
Name: "Test Agent",
|
|
Capabilities: []string{"go", "testing"},
|
|
Status: AgentAvailable,
|
|
MaxLoad: 5,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
got, err := reg.Get("agent-1")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "agent-1", got.ID)
|
|
assert.Equal(t, "Test Agent", got.Name)
|
|
assert.Equal(t, []string{"go", "testing"}, got.Capabilities)
|
|
assert.Equal(t, AgentAvailable, got.Status)
|
|
assert.Equal(t, 5, got.MaxLoad)
|
|
}
|
|
|
|
func TestSQLiteRegistry_Register_Good_Overwrite(t *testing.T) {
|
|
reg := newTestSQLiteRegistry(t)
|
|
_ = reg.Register(AgentInfo{ID: "agent-1", Name: "Original", MaxLoad: 3})
|
|
err := reg.Register(AgentInfo{ID: "agent-1", Name: "Updated", MaxLoad: 10})
|
|
require.NoError(t, err)
|
|
|
|
got, err := reg.Get("agent-1")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "Updated", got.Name)
|
|
assert.Equal(t, 10, got.MaxLoad)
|
|
}
|
|
|
|
func TestSQLiteRegistry_Register_Bad_EmptyID(t *testing.T) {
|
|
reg := newTestSQLiteRegistry(t)
|
|
err := reg.Register(AgentInfo{ID: "", Name: "No ID"})
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "agent ID is required")
|
|
}
|
|
|
|
func TestSQLiteRegistry_Register_Good_NilCapabilities(t *testing.T) {
|
|
reg := newTestSQLiteRegistry(t)
|
|
err := reg.Register(AgentInfo{
|
|
ID: "agent-1",
|
|
Name: "No Caps",
|
|
Capabilities: nil,
|
|
Status: AgentAvailable,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
got, err := reg.Get("agent-1")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "No Caps", got.Name)
|
|
// nil capabilities serialised as JSON null, deserialised back to nil.
|
|
}
|
|
|
|
// --- Deregister tests ---
|
|
|
|
func TestSQLiteRegistry_Deregister_Good(t *testing.T) {
|
|
reg := newTestSQLiteRegistry(t)
|
|
_ = reg.Register(AgentInfo{ID: "agent-1", Name: "To Remove"})
|
|
|
|
err := reg.Deregister("agent-1")
|
|
require.NoError(t, err)
|
|
|
|
_, err = reg.Get("agent-1")
|
|
require.Error(t, err)
|
|
}
|
|
|
|
func TestSQLiteRegistry_Deregister_Bad_NotFound(t *testing.T) {
|
|
reg := newTestSQLiteRegistry(t)
|
|
err := reg.Deregister("nonexistent")
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "agent not found")
|
|
}
|
|
|
|
// --- Get tests ---
|
|
|
|
func TestSQLiteRegistry_Get_Good(t *testing.T) {
|
|
reg := newTestSQLiteRegistry(t)
|
|
now := time.Now().UTC().Truncate(time.Microsecond)
|
|
_ = reg.Register(AgentInfo{
|
|
ID: "agent-1",
|
|
Name: "Getter",
|
|
Status: AgentBusy,
|
|
CurrentLoad: 2,
|
|
MaxLoad: 5,
|
|
LastHeartbeat: now,
|
|
})
|
|
|
|
got, err := reg.Get("agent-1")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, AgentBusy, got.Status)
|
|
assert.Equal(t, 2, got.CurrentLoad)
|
|
// Heartbeat stored via RFC3339Nano — allow small time difference from serialisation.
|
|
assert.WithinDuration(t, now, got.LastHeartbeat, time.Millisecond)
|
|
}
|
|
|
|
func TestSQLiteRegistry_Get_Bad_NotFound(t *testing.T) {
|
|
reg := newTestSQLiteRegistry(t)
|
|
_, err := reg.Get("nonexistent")
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "agent not found")
|
|
}
|
|
|
|
func TestSQLiteRegistry_Get_Good_ReturnsCopy(t *testing.T) {
|
|
reg := newTestSQLiteRegistry(t)
|
|
_ = reg.Register(AgentInfo{ID: "agent-1", Name: "Original", CurrentLoad: 1})
|
|
|
|
got, _ := reg.Get("agent-1")
|
|
got.CurrentLoad = 99
|
|
got.Name = "Tampered"
|
|
|
|
// Re-read — should be unchanged.
|
|
again, _ := reg.Get("agent-1")
|
|
assert.Equal(t, "Original", again.Name)
|
|
assert.Equal(t, 1, again.CurrentLoad)
|
|
}
|
|
|
|
// --- List tests ---
|
|
|
|
func TestSQLiteRegistry_List_Good_Empty(t *testing.T) {
|
|
reg := newTestSQLiteRegistry(t)
|
|
agents := reg.List()
|
|
assert.Empty(t, agents)
|
|
}
|
|
|
|
func TestSQLiteRegistry_List_Good_Multiple(t *testing.T) {
|
|
reg := newTestSQLiteRegistry(t)
|
|
_ = reg.Register(AgentInfo{ID: "a", Name: "Alpha"})
|
|
_ = reg.Register(AgentInfo{ID: "b", Name: "Beta"})
|
|
_ = reg.Register(AgentInfo{ID: "c", Name: "Charlie"})
|
|
|
|
agents := reg.List()
|
|
assert.Len(t, agents, 3)
|
|
|
|
// Sort by ID for deterministic assertion.
|
|
sort.Slice(agents, func(i, j int) bool { return agents[i].ID < agents[j].ID })
|
|
assert.Equal(t, "a", agents[0].ID)
|
|
assert.Equal(t, "b", agents[1].ID)
|
|
assert.Equal(t, "c", agents[2].ID)
|
|
}
|
|
|
|
// --- Heartbeat tests ---
|
|
|
|
func TestSQLiteRegistry_Heartbeat_Good(t *testing.T) {
|
|
reg := newTestSQLiteRegistry(t)
|
|
past := time.Now().UTC().Add(-5 * time.Minute)
|
|
_ = reg.Register(AgentInfo{
|
|
ID: "agent-1",
|
|
Status: AgentAvailable,
|
|
LastHeartbeat: past,
|
|
})
|
|
|
|
err := reg.Heartbeat("agent-1")
|
|
require.NoError(t, err)
|
|
|
|
got, _ := reg.Get("agent-1")
|
|
assert.True(t, got.LastHeartbeat.After(past))
|
|
assert.Equal(t, AgentAvailable, got.Status)
|
|
}
|
|
|
|
func TestSQLiteRegistry_Heartbeat_Good_RecoverFromOffline(t *testing.T) {
|
|
reg := newTestSQLiteRegistry(t)
|
|
_ = reg.Register(AgentInfo{
|
|
ID: "agent-1",
|
|
Status: AgentOffline,
|
|
})
|
|
|
|
err := reg.Heartbeat("agent-1")
|
|
require.NoError(t, err)
|
|
|
|
got, _ := reg.Get("agent-1")
|
|
assert.Equal(t, AgentAvailable, got.Status)
|
|
}
|
|
|
|
func TestSQLiteRegistry_Heartbeat_Good_BusyStaysBusy(t *testing.T) {
|
|
reg := newTestSQLiteRegistry(t)
|
|
_ = reg.Register(AgentInfo{
|
|
ID: "agent-1",
|
|
Status: AgentBusy,
|
|
})
|
|
|
|
err := reg.Heartbeat("agent-1")
|
|
require.NoError(t, err)
|
|
|
|
got, _ := reg.Get("agent-1")
|
|
assert.Equal(t, AgentBusy, got.Status)
|
|
}
|
|
|
|
func TestSQLiteRegistry_Heartbeat_Bad_NotFound(t *testing.T) {
|
|
reg := newTestSQLiteRegistry(t)
|
|
err := reg.Heartbeat("nonexistent")
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "agent not found")
|
|
}
|
|
|
|
// --- Reap tests ---
|
|
|
|
func TestSQLiteRegistry_Reap_Good_StaleAgent(t *testing.T) {
|
|
reg := newTestSQLiteRegistry(t)
|
|
stale := time.Now().UTC().Add(-10 * time.Minute)
|
|
fresh := time.Now().UTC()
|
|
|
|
_ = reg.Register(AgentInfo{ID: "stale-1", Status: AgentAvailable, LastHeartbeat: stale})
|
|
_ = reg.Register(AgentInfo{ID: "fresh-1", Status: AgentAvailable, LastHeartbeat: fresh})
|
|
|
|
reaped := reg.Reap(5 * time.Minute)
|
|
assert.Len(t, reaped, 1)
|
|
assert.Contains(t, reaped, "stale-1")
|
|
|
|
got, _ := reg.Get("stale-1")
|
|
assert.Equal(t, AgentOffline, got.Status)
|
|
|
|
got, _ = reg.Get("fresh-1")
|
|
assert.Equal(t, AgentAvailable, got.Status)
|
|
}
|
|
|
|
func TestSQLiteRegistry_Reap_Good_AlreadyOfflineSkipped(t *testing.T) {
|
|
reg := newTestSQLiteRegistry(t)
|
|
stale := time.Now().UTC().Add(-10 * time.Minute)
|
|
|
|
_ = reg.Register(AgentInfo{ID: "already-off", Status: AgentOffline, LastHeartbeat: stale})
|
|
|
|
reaped := reg.Reap(5 * time.Minute)
|
|
assert.Empty(t, reaped)
|
|
}
|
|
|
|
func TestSQLiteRegistry_Reap_Good_NoStaleAgents(t *testing.T) {
|
|
reg := newTestSQLiteRegistry(t)
|
|
now := time.Now().UTC()
|
|
|
|
_ = reg.Register(AgentInfo{ID: "a", Status: AgentAvailable, LastHeartbeat: now})
|
|
_ = reg.Register(AgentInfo{ID: "b", Status: AgentBusy, LastHeartbeat: now})
|
|
|
|
reaped := reg.Reap(5 * time.Minute)
|
|
assert.Empty(t, reaped)
|
|
}
|
|
|
|
func TestSQLiteRegistry_Reap_Good_BusyAgentReaped(t *testing.T) {
|
|
reg := newTestSQLiteRegistry(t)
|
|
stale := time.Now().UTC().Add(-10 * time.Minute)
|
|
|
|
_ = reg.Register(AgentInfo{ID: "busy-stale", Status: AgentBusy, LastHeartbeat: stale})
|
|
|
|
reaped := reg.Reap(5 * time.Minute)
|
|
assert.Len(t, reaped, 1)
|
|
assert.Contains(t, reaped, "busy-stale")
|
|
|
|
got, _ := reg.Get("busy-stale")
|
|
assert.Equal(t, AgentOffline, got.Status)
|
|
}
|
|
|
|
// --- Concurrent access ---
|
|
|
|
func TestSQLiteRegistry_Concurrent_Good(t *testing.T) {
|
|
reg := newTestSQLiteRegistry(t)
|
|
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < 20; i++ {
|
|
wg.Add(1)
|
|
go func(n int) {
|
|
defer wg.Done()
|
|
id := "agent-" + string(rune('a'+n%5))
|
|
_ = reg.Register(AgentInfo{
|
|
ID: id,
|
|
Name: "Concurrent",
|
|
Status: AgentAvailable,
|
|
LastHeartbeat: time.Now().UTC(),
|
|
})
|
|
_, _ = reg.Get(id)
|
|
_ = reg.Heartbeat(id)
|
|
_ = reg.List()
|
|
_ = reg.Reap(1 * time.Minute)
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
|
|
// No race conditions — test passes under -race.
|
|
agents := reg.List()
|
|
assert.True(t, len(agents) > 0)
|
|
}
|
|
|
|
// --- Persistence: close and reopen ---
|
|
|
|
func TestSQLiteRegistry_Persistence_Good(t *testing.T) {
|
|
dbPath := filepath.Join(t.TempDir(), "registry.db")
|
|
|
|
// Phase 1: write data
|
|
r1, err := NewSQLiteRegistry(dbPath)
|
|
require.NoError(t, err)
|
|
|
|
now := time.Now().UTC().Truncate(time.Microsecond)
|
|
_ = r1.Register(AgentInfo{
|
|
ID: "agent-1",
|
|
Name: "Persistent",
|
|
Capabilities: []string{"go", "rust"},
|
|
Status: AgentBusy,
|
|
LastHeartbeat: now,
|
|
CurrentLoad: 3,
|
|
MaxLoad: 10,
|
|
})
|
|
require.NoError(t, r1.Close())
|
|
|
|
// Phase 2: reopen and verify
|
|
r2, err := NewSQLiteRegistry(dbPath)
|
|
require.NoError(t, err)
|
|
defer func() { _ = r2.Close() }()
|
|
|
|
got, err := r2.Get("agent-1")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "Persistent", got.Name)
|
|
assert.Equal(t, []string{"go", "rust"}, got.Capabilities)
|
|
assert.Equal(t, AgentBusy, got.Status)
|
|
assert.Equal(t, 3, got.CurrentLoad)
|
|
assert.Equal(t, 10, got.MaxLoad)
|
|
assert.WithinDuration(t, now, got.LastHeartbeat, time.Millisecond)
|
|
}
|
|
|
|
// --- Constructor error case ---
|
|
|
|
func TestNewSQLiteRegistry_Bad_InvalidPath(t *testing.T) {
|
|
_, err := NewSQLiteRegistry("/nonexistent/deeply/nested/dir/registry.db")
|
|
require.Error(t, err)
|
|
}
|
|
|
|
// --- Config-based factory ---
|
|
|
|
func TestNewAgentRegistryFromConfig_Good_Memory(t *testing.T) {
|
|
cfg := RegistryConfig{RegistryBackend: "memory"}
|
|
reg, err := NewAgentRegistryFromConfig(cfg)
|
|
require.NoError(t, err)
|
|
_, ok := reg.(*MemoryRegistry)
|
|
assert.True(t, ok, "expected MemoryRegistry")
|
|
}
|
|
|
|
func TestNewAgentRegistryFromConfig_Good_Default(t *testing.T) {
|
|
cfg := RegistryConfig{} // empty defaults to memory
|
|
reg, err := NewAgentRegistryFromConfig(cfg)
|
|
require.NoError(t, err)
|
|
_, ok := reg.(*MemoryRegistry)
|
|
assert.True(t, ok, "expected MemoryRegistry for empty config")
|
|
}
|
|
|
|
func TestNewAgentRegistryFromConfig_Good_SQLite(t *testing.T) {
|
|
dbPath := filepath.Join(t.TempDir(), "factory-registry.db")
|
|
cfg := RegistryConfig{
|
|
RegistryBackend: "sqlite",
|
|
RegistryPath: dbPath,
|
|
}
|
|
reg, err := NewAgentRegistryFromConfig(cfg)
|
|
require.NoError(t, err)
|
|
sr, ok := reg.(*SQLiteRegistry)
|
|
assert.True(t, ok, "expected SQLiteRegistry")
|
|
_ = sr.Close()
|
|
}
|
|
|
|
func TestNewAgentRegistryFromConfig_Bad_UnknownBackend(t *testing.T) {
|
|
cfg := RegistryConfig{RegistryBackend: "cassandra"}
|
|
_, err := NewAgentRegistryFromConfig(cfg)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "unsupported registry backend")
|
|
}
|