mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-29 12:00:13 +00:00
Adds detailed architecture documentation for Pulse Patrol and Pulse Assistant. Updates AI.md and PULSE_PRO.md. Also includes additional tests.
520 lines
14 KiB
Go
520 lines
14 KiB
Go
package ai
|
|
|
|
import (
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
|
)
|
|
|
|
// mockPatrolHistoryPersistence implements PatrolHistoryPersistence for testing
|
|
type mockPatrolHistoryPersistence struct {
|
|
mu sync.Mutex
|
|
runs []PatrolRunRecord
|
|
saveErr error
|
|
loadErr error
|
|
saveCalls atomic.Int32
|
|
loadCalls atomic.Int32
|
|
}
|
|
|
|
type errorPatrolHistoryPersistence struct {
|
|
err error
|
|
}
|
|
|
|
func (e *errorPatrolHistoryPersistence) SavePatrolRunHistory(runs []PatrolRunRecord) error {
|
|
return e.err
|
|
}
|
|
|
|
func (e *errorPatrolHistoryPersistence) LoadPatrolRunHistory() ([]PatrolRunRecord, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *mockPatrolHistoryPersistence) SavePatrolRunHistory(runs []PatrolRunRecord) error {
|
|
m.saveCalls.Add(1)
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.saveErr != nil {
|
|
return m.saveErr
|
|
}
|
|
m.runs = runs
|
|
return nil
|
|
}
|
|
|
|
func (m *mockPatrolHistoryPersistence) LoadPatrolRunHistory() ([]PatrolRunRecord, error) {
|
|
m.loadCalls.Add(1)
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.loadErr != nil {
|
|
return nil, m.loadErr
|
|
}
|
|
return m.runs, nil
|
|
}
|
|
|
|
func TestNewPatrolRunHistoryStore(t *testing.T) {
|
|
// Test with positive maxRuns
|
|
store := NewPatrolRunHistoryStore(50)
|
|
if store == nil {
|
|
t.Fatal("Expected non-nil store")
|
|
}
|
|
if store.maxRuns != 50 {
|
|
t.Errorf("Expected maxRuns=50, got %d", store.maxRuns)
|
|
}
|
|
|
|
// Test with zero maxRuns (should use default)
|
|
storeDefault := NewPatrolRunHistoryStore(0)
|
|
if storeDefault.maxRuns != MaxPatrolRunHistory {
|
|
t.Errorf("Expected maxRuns=%d (default), got %d", MaxPatrolRunHistory, storeDefault.maxRuns)
|
|
}
|
|
|
|
// Test with negative maxRuns (should use default)
|
|
storeNegative := NewPatrolRunHistoryStore(-10)
|
|
if storeNegative.maxRuns != MaxPatrolRunHistory {
|
|
t.Errorf("Expected maxRuns=%d (default), got %d", MaxPatrolRunHistory, storeNegative.maxRuns)
|
|
}
|
|
}
|
|
|
|
func TestPatrolRunHistoryStore_Add(t *testing.T) {
|
|
store := NewPatrolRunHistoryStore(3)
|
|
|
|
run1 := PatrolRunRecord{ID: "run-1", StartedAt: time.Now()}
|
|
run2 := PatrolRunRecord{ID: "run-2", StartedAt: time.Now()}
|
|
run3 := PatrolRunRecord{ID: "run-3", StartedAt: time.Now()}
|
|
run4 := PatrolRunRecord{ID: "run-4", StartedAt: time.Now()}
|
|
|
|
store.Add(run1)
|
|
if store.Count() != 1 {
|
|
t.Errorf("Expected count=1, got %d", store.Count())
|
|
}
|
|
|
|
store.Add(run2)
|
|
store.Add(run3)
|
|
if store.Count() != 3 {
|
|
t.Errorf("Expected count=3, got %d", store.Count())
|
|
}
|
|
|
|
// Adding 4th run should trim to maxRuns
|
|
store.Add(run4)
|
|
if store.Count() != 3 {
|
|
t.Errorf("Expected count=3 (trimmed), got %d", store.Count())
|
|
}
|
|
|
|
// Newest should be first
|
|
runs := store.GetAll()
|
|
if runs[0].ID != "run-4" {
|
|
t.Errorf("Expected newest run first, got %s", runs[0].ID)
|
|
}
|
|
}
|
|
|
|
func TestPatrolRunHistoryStore_GetAll(t *testing.T) {
|
|
store := NewPatrolRunHistoryStore(10)
|
|
|
|
// Empty store
|
|
runs := store.GetAll()
|
|
if len(runs) != 0 {
|
|
t.Errorf("Expected empty slice, got %d runs", len(runs))
|
|
}
|
|
|
|
// Add runs
|
|
store.Add(PatrolRunRecord{ID: "run-1"})
|
|
store.Add(PatrolRunRecord{ID: "run-2"})
|
|
|
|
runs = store.GetAll()
|
|
if len(runs) != 2 {
|
|
t.Errorf("Expected 2 runs, got %d", len(runs))
|
|
}
|
|
|
|
// Verify it returns a copy (modification shouldn't affect store)
|
|
runs[0].ID = "modified"
|
|
storedRuns := store.GetAll()
|
|
if storedRuns[0].ID == "modified" {
|
|
t.Error("GetAll should return a copy, not the original slice")
|
|
}
|
|
}
|
|
|
|
func TestPatrolRunHistoryStore_GetRecent(t *testing.T) {
|
|
store := NewPatrolRunHistoryStore(10)
|
|
|
|
store.Add(PatrolRunRecord{ID: "run-1"})
|
|
store.Add(PatrolRunRecord{ID: "run-2"})
|
|
store.Add(PatrolRunRecord{ID: "run-3"})
|
|
|
|
// Get 2 recent
|
|
runs := store.GetRecent(2)
|
|
if len(runs) != 2 {
|
|
t.Errorf("Expected 2 runs, got %d", len(runs))
|
|
}
|
|
if runs[0].ID != "run-3" {
|
|
t.Errorf("Expected run-3 first, got %s", runs[0].ID)
|
|
}
|
|
|
|
// Get more than available
|
|
runsAll := store.GetRecent(10)
|
|
if len(runsAll) != 3 {
|
|
t.Errorf("Expected 3 runs (all available), got %d", len(runsAll))
|
|
}
|
|
|
|
// Get 0 or negative
|
|
runsZero := store.GetRecent(0)
|
|
if len(runsZero) != 3 {
|
|
t.Errorf("Expected 3 runs for n=0, got %d", len(runsZero))
|
|
}
|
|
|
|
runsNeg := store.GetRecent(-5)
|
|
if len(runsNeg) != 3 {
|
|
t.Errorf("Expected 3 runs for n=-5, got %d", len(runsNeg))
|
|
}
|
|
}
|
|
|
|
func TestPatrolRunHistoryStore_Count(t *testing.T) {
|
|
store := NewPatrolRunHistoryStore(10)
|
|
|
|
if store.Count() != 0 {
|
|
t.Errorf("Expected count=0, got %d", store.Count())
|
|
}
|
|
|
|
store.Add(PatrolRunRecord{ID: "run-1"})
|
|
if store.Count() != 1 {
|
|
t.Errorf("Expected count=1, got %d", store.Count())
|
|
}
|
|
|
|
store.Add(PatrolRunRecord{ID: "run-2"})
|
|
store.Add(PatrolRunRecord{ID: "run-3"})
|
|
if store.Count() != 3 {
|
|
t.Errorf("Expected count=3, got %d", store.Count())
|
|
}
|
|
}
|
|
|
|
func TestPatrolRunHistoryStore_SetPersistence(t *testing.T) {
|
|
store := NewPatrolRunHistoryStore(10)
|
|
|
|
// Create mock with existing runs
|
|
mockPersistence := &mockPatrolHistoryPersistence{
|
|
runs: []PatrolRunRecord{
|
|
{ID: "persisted-1"},
|
|
{ID: "persisted-2"},
|
|
},
|
|
}
|
|
|
|
err := store.SetPersistence(mockPersistence)
|
|
if err != nil {
|
|
t.Fatalf("SetPersistence failed: %v", err)
|
|
}
|
|
|
|
// Should have loaded runs
|
|
if store.Count() != 2 {
|
|
t.Errorf("Expected 2 runs loaded, got %d", store.Count())
|
|
}
|
|
|
|
runs := store.GetAll()
|
|
if runs[0].ID != "persisted-1" {
|
|
t.Errorf("Expected persisted-1, got %s", runs[0].ID)
|
|
}
|
|
}
|
|
|
|
func TestPatrolRunHistoryStore_PersistenceStatusAndErrors(t *testing.T) {
|
|
store := NewPatrolRunHistoryStore(5)
|
|
store.saveDebounce = 0
|
|
|
|
saveErr := errors.New("save failed")
|
|
errPersistence := &errorPatrolHistoryPersistence{err: saveErr}
|
|
|
|
if err := store.SetPersistence(errPersistence); err != nil {
|
|
t.Fatalf("SetPersistence failed: %v", err)
|
|
}
|
|
|
|
errCh := make(chan error, 1)
|
|
store.SetOnSaveError(func(err error) {
|
|
errCh <- err
|
|
})
|
|
|
|
store.Add(PatrolRunRecord{ID: "run-1"})
|
|
|
|
select {
|
|
case err := <-errCh:
|
|
if err == nil || err.Error() != "save failed" {
|
|
t.Fatalf("unexpected error callback: %v", err)
|
|
}
|
|
case <-time.After(1 * time.Second):
|
|
t.Fatal("timed out waiting for save error callback")
|
|
}
|
|
|
|
lastErr, lastSaveTime, hasPersistence := store.GetPersistenceStatus()
|
|
if lastErr == nil {
|
|
t.Fatal("expected last save error to be recorded")
|
|
}
|
|
if !lastSaveTime.IsZero() {
|
|
t.Fatalf("expected last save time to be zero on failure, got %v", lastSaveTime)
|
|
}
|
|
if !hasPersistence {
|
|
t.Fatal("expected persistence to be configured")
|
|
}
|
|
}
|
|
|
|
func TestConvertPatrolToolCalls(t *testing.T) {
|
|
aiCalls := []ToolCallRecord{
|
|
{
|
|
ID: "call-1",
|
|
ToolName: "pulse_query",
|
|
Input: `{"action":"get"}`,
|
|
Output: `{"status":"ok"}`,
|
|
Success: true,
|
|
StartTime: 123,
|
|
EndTime: 456,
|
|
Duration: 333,
|
|
},
|
|
}
|
|
|
|
cfgCalls := convertAIToolCallsToConfig(aiCalls)
|
|
if len(cfgCalls) != 1 {
|
|
t.Fatalf("expected 1 config tool call, got %d", len(cfgCalls))
|
|
}
|
|
if cfgCalls[0].ToolName != "pulse_query" || cfgCalls[0].Duration != 333 {
|
|
t.Fatalf("unexpected config tool call: %+v", cfgCalls[0])
|
|
}
|
|
|
|
roundTrip := convertConfigToolCallsToAI(cfgCalls)
|
|
if len(roundTrip) != 1 {
|
|
t.Fatalf("expected 1 AI tool call, got %d", len(roundTrip))
|
|
}
|
|
if roundTrip[0].ID != "call-1" || roundTrip[0].Output != `{"status":"ok"}` {
|
|
t.Fatalf("unexpected AI tool call: %+v", roundTrip[0])
|
|
}
|
|
}
|
|
|
|
func TestPatrolRunHistoryStore_SetPersistence_Nil(t *testing.T) {
|
|
store := NewPatrolRunHistoryStore(10)
|
|
|
|
// Setting nil persistence should not error
|
|
err := store.SetPersistence(nil)
|
|
if err != nil {
|
|
t.Fatalf("SetPersistence(nil) should not error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPatrolRunHistoryStore_SetPersistence_TrimToMax(t *testing.T) {
|
|
store := NewPatrolRunHistoryStore(2) // Only allow 2 runs
|
|
|
|
// Create mock with more runs than maxRuns
|
|
mockPersistence := &mockPatrolHistoryPersistence{
|
|
runs: []PatrolRunRecord{
|
|
{ID: "run-1"},
|
|
{ID: "run-2"},
|
|
{ID: "run-3"},
|
|
{ID: "run-4"},
|
|
},
|
|
}
|
|
|
|
err := store.SetPersistence(mockPersistence)
|
|
if err != nil {
|
|
t.Fatalf("SetPersistence failed: %v", err)
|
|
}
|
|
|
|
// Should have trimmed to max
|
|
if store.Count() != 2 {
|
|
t.Errorf("Expected 2 runs (trimmed), got %d", store.Count())
|
|
}
|
|
}
|
|
|
|
func TestPatrolRunHistoryStore_SetPersistence_LoadError(t *testing.T) {
|
|
store := NewPatrolRunHistoryStore(10)
|
|
mockPersistence := &mockPatrolHistoryPersistence{
|
|
loadErr: errors.New("load failed"),
|
|
}
|
|
|
|
err := store.SetPersistence(mockPersistence)
|
|
if err == nil {
|
|
t.Fatal("expected error from SetPersistence when load fails")
|
|
}
|
|
}
|
|
|
|
func TestPatrolRunHistoryStore_FlushPersistence(t *testing.T) {
|
|
store := NewPatrolRunHistoryStore(10)
|
|
|
|
mockPersistence := &mockPatrolHistoryPersistence{}
|
|
store.SetPersistence(mockPersistence)
|
|
|
|
store.Add(PatrolRunRecord{ID: "run-1"})
|
|
store.Add(PatrolRunRecord{ID: "run-2"})
|
|
|
|
err := store.FlushPersistence()
|
|
if err != nil {
|
|
t.Fatalf("FlushPersistence failed: %v", err)
|
|
}
|
|
|
|
// Should have saved
|
|
if len(mockPersistence.runs) != 2 {
|
|
t.Errorf("Expected 2 runs saved, got %d", len(mockPersistence.runs))
|
|
}
|
|
}
|
|
|
|
func TestPatrolRunHistoryStore_FlushPersistence_NoPersistence(t *testing.T) {
|
|
store := NewPatrolRunHistoryStore(10)
|
|
|
|
store.Add(PatrolRunRecord{ID: "run-1"})
|
|
|
|
// Should not error when no persistence is set
|
|
err := store.FlushPersistence()
|
|
if err != nil {
|
|
t.Fatalf("FlushPersistence without persistence should not error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPatrolRunHistoryStore_ScheduleSave(t *testing.T) {
|
|
store := NewPatrolRunHistoryStore(10)
|
|
store.saveDebounce = 50 * time.Millisecond // Short debounce for testing
|
|
|
|
mockPersistence := &mockPatrolHistoryPersistence{}
|
|
store.SetPersistence(mockPersistence)
|
|
mockPersistence.saveCalls.Store(0) // Reset after SetPersistence load
|
|
|
|
// Add a run (triggers scheduleSave)
|
|
store.Add(PatrolRunRecord{ID: "run-1"})
|
|
|
|
// Wait for debounce
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
if mockPersistence.saveCalls.Load() < 1 {
|
|
t.Errorf("Expected at least 1 save call after debounce, got %d", mockPersistence.saveCalls.Load())
|
|
}
|
|
}
|
|
|
|
func TestPatrolRunHistoryStore_ScheduleSave_StopsExistingTimer(t *testing.T) {
|
|
store := NewPatrolRunHistoryStore(10)
|
|
store.saveDebounce = 50 * time.Millisecond
|
|
|
|
mockPersistence := &mockPatrolHistoryPersistence{}
|
|
store.SetPersistence(mockPersistence)
|
|
mockPersistence.saveCalls.Store(0)
|
|
|
|
store.Add(PatrolRunRecord{ID: "run-1"})
|
|
store.Add(PatrolRunRecord{ID: "run-2"})
|
|
|
|
time.Sleep(120 * time.Millisecond)
|
|
|
|
if mockPersistence.saveCalls.Load() != 1 {
|
|
t.Errorf("expected 1 save call after reschedule, got %d", mockPersistence.saveCalls.Load())
|
|
}
|
|
}
|
|
|
|
func TestPatrolRunHistoryStore_ScheduleSave_Cancelled(t *testing.T) {
|
|
store := NewPatrolRunHistoryStore(10)
|
|
store.saveDebounce = 50 * time.Millisecond
|
|
|
|
mockPersistence := &mockPatrolHistoryPersistence{}
|
|
store.SetPersistence(mockPersistence)
|
|
mockPersistence.saveCalls.Store(0)
|
|
|
|
store.Add(PatrolRunRecord{ID: "run-1"})
|
|
|
|
store.mu.Lock()
|
|
store.savePending = false
|
|
store.mu.Unlock()
|
|
|
|
time.Sleep(120 * time.Millisecond)
|
|
|
|
if mockPersistence.saveCalls.Load() != 0 {
|
|
t.Errorf("expected no save calls after cancellation, got %d", mockPersistence.saveCalls.Load())
|
|
}
|
|
}
|
|
|
|
func TestPatrolRunHistoryStore_ScheduleSave_Error(t *testing.T) {
|
|
store := NewPatrolRunHistoryStore(10)
|
|
store.saveDebounce = 50 * time.Millisecond
|
|
|
|
mockPersistence := &mockPatrolHistoryPersistence{saveErr: errors.New("save failed")}
|
|
store.SetPersistence(mockPersistence)
|
|
mockPersistence.saveCalls.Store(0)
|
|
|
|
store.Add(PatrolRunRecord{ID: "run-1"})
|
|
|
|
time.Sleep(120 * time.Millisecond)
|
|
|
|
if mockPersistence.saveCalls.Load() < 1 {
|
|
t.Errorf("expected save to be attempted, got %d calls", mockPersistence.saveCalls.Load())
|
|
}
|
|
}
|
|
|
|
func TestPatrolRunHistoryStore_ScheduleSave_NoPersistence(t *testing.T) {
|
|
store := NewPatrolRunHistoryStore(10)
|
|
|
|
// Add without persistence - should not panic
|
|
store.Add(PatrolRunRecord{ID: "run-1"})
|
|
|
|
// Give time for any potential async operation
|
|
time.Sleep(10 * time.Millisecond)
|
|
|
|
// No error or panic is success
|
|
}
|
|
|
|
func TestPatrolHistoryPersistenceAdapter_SaveAndLoad(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
persistence := config.NewConfigPersistence(tmp)
|
|
|
|
adapter := NewPatrolHistoryPersistenceAdapter(persistence)
|
|
if adapter == nil {
|
|
t.Fatal("expected adapter to be created")
|
|
}
|
|
|
|
runs := []PatrolRunRecord{
|
|
{
|
|
ID: "run-1",
|
|
StartedAt: time.Now().Add(-2 * time.Minute),
|
|
CompletedAt: time.Now().Add(-1 * time.Minute),
|
|
Duration: time.Minute,
|
|
Type: "manual",
|
|
ResourcesChecked: 10,
|
|
NodesChecked: 2,
|
|
GuestsChecked: 5,
|
|
DockerChecked: 1,
|
|
StorageChecked: 1,
|
|
HostsChecked: 0,
|
|
PBSChecked: 0,
|
|
NewFindings: 1,
|
|
ExistingFindings: 2,
|
|
ResolvedFindings: 1,
|
|
AutoFixCount: 0,
|
|
FindingsSummary: "summary",
|
|
FindingIDs: []string{"f1", "f2"},
|
|
ErrorCount: 0,
|
|
Status: "ok",
|
|
AIAnalysis: "analysis",
|
|
InputTokens: 100,
|
|
OutputTokens: 200,
|
|
},
|
|
}
|
|
|
|
if err := adapter.SavePatrolRunHistory(runs); err != nil {
|
|
t.Fatalf("save failed: %v", err)
|
|
}
|
|
|
|
loaded, err := adapter.LoadPatrolRunHistory()
|
|
if err != nil {
|
|
t.Fatalf("load failed: %v", err)
|
|
}
|
|
if len(loaded) != 1 || loaded[0].ID != "run-1" {
|
|
t.Fatalf("unexpected loaded runs: %+v", loaded)
|
|
}
|
|
if loaded[0].Duration != runs[0].Duration {
|
|
t.Fatalf("expected duration %v, got %v", runs[0].Duration, loaded[0].Duration)
|
|
}
|
|
}
|
|
|
|
func TestPatrolHistoryPersistenceAdapter_LoadError(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
persistence := config.NewConfigPersistence(tmp)
|
|
adapter := NewPatrolHistoryPersistenceAdapter(persistence)
|
|
|
|
badPath := filepath.Join(tmp, "ai_patrol_runs.json")
|
|
if err := os.Mkdir(badPath, 0700); err != nil {
|
|
t.Fatalf("failed to create directory at %s: %v", badPath, err)
|
|
}
|
|
|
|
if _, err := adapter.LoadPatrolRunHistory(); err == nil {
|
|
t.Fatal("expected error when patrol runs path is a directory")
|
|
}
|
|
}
|