mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-21 02:33:32 +00:00
refactor(ai): remove obsolete tool and chat files
Remove files that were consolidated into other modules: - chat/patrol.go, patrol_test.go → moved to chat/service.go - tools_infrastructure.go → merged into tools_storage.go - tools_intelligence.go → merged into tools_metrics.go - tools_patrol.go → merged into tools_alerts.go - tools_profiles.go, tools_profiles_test.go → removed (unused) Update related test file references.
This commit is contained in:
@@ -1,468 +0,0 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// PatrolFinding represents a finding from AI patrol analysis
|
||||
type PatrolFinding struct {
|
||||
ID string `json:"id"`
|
||||
Key string `json:"key"`
|
||||
Severity string `json:"severity"`
|
||||
Category string `json:"category"`
|
||||
ResourceID string `json:"resource_id"`
|
||||
ResourceName string `json:"resource_name"`
|
||||
ResourceType string `json:"resource_type"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Recommendation string `json:"recommendation"`
|
||||
Evidence string `json:"evidence"`
|
||||
Source string `json:"source"`
|
||||
DetectedAt time.Time `json:"detected_at"`
|
||||
LastSeenAt time.Time `json:"last_seen_at"`
|
||||
}
|
||||
|
||||
// FindingsStore interface for storing patrol findings
|
||||
type FindingsStore interface {
|
||||
Add(finding *PatrolFinding) bool // Returns true if finding is new
|
||||
GetActive() []*PatrolFinding
|
||||
GetDismissed() []*PatrolFinding
|
||||
}
|
||||
|
||||
// PatrolStreamCallback is called for patrol streaming updates
|
||||
type PatrolStreamCallback func(event PatrolStreamEvent)
|
||||
|
||||
// PatrolStreamEvent represents a streaming update from patrol
|
||||
type PatrolStreamEvent struct {
|
||||
Type string `json:"type"` // "start", "content", "thinking", "tool_use", "complete", "error"
|
||||
Content string `json:"content,omitempty"`
|
||||
Phase string `json:"phase,omitempty"`
|
||||
}
|
||||
|
||||
// PatrolResult contains the results of a patrol run
|
||||
type PatrolResult struct {
|
||||
Findings []*PatrolFinding
|
||||
NewFindings int
|
||||
RawResponse string
|
||||
InputTokens int
|
||||
OutputTokens int
|
||||
Duration time.Duration
|
||||
Error error
|
||||
}
|
||||
|
||||
// PatrolService runs AI patrol analysis
|
||||
type PatrolService struct {
|
||||
mu sync.RWMutex
|
||||
|
||||
service *Service // Chat service
|
||||
findingsStore FindingsStore // For storing findings
|
||||
sessionID string // Dedicated patrol session
|
||||
running bool // Whether a patrol is currently running
|
||||
lastRun time.Time // Last patrol run time
|
||||
lastResult *PatrolResult // Last patrol result
|
||||
|
||||
// Streaming support
|
||||
streamMu sync.RWMutex
|
||||
subscribers map[chan PatrolStreamEvent]struct{}
|
||||
currentOutput strings.Builder
|
||||
}
|
||||
|
||||
// NewPatrolService creates a new patrol service
|
||||
func NewPatrolService(service *Service) *PatrolService {
|
||||
return &PatrolService{
|
||||
service: service,
|
||||
subscribers: make(map[chan PatrolStreamEvent]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// SetFindingsStore sets the findings store for persisting patrol results
|
||||
func (p *PatrolService) SetFindingsStore(store FindingsStore) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.findingsStore = store
|
||||
}
|
||||
|
||||
// IsRunning returns whether a patrol is currently executing
|
||||
func (p *PatrolService) IsRunning() bool {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return p.running
|
||||
}
|
||||
|
||||
// GetLastResult returns the most recent patrol result
|
||||
func (p *PatrolService) GetLastResult() *PatrolResult {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return p.lastResult
|
||||
}
|
||||
|
||||
// Subscribe returns a channel for receiving patrol stream events
|
||||
func (p *PatrolService) Subscribe() chan PatrolStreamEvent {
|
||||
ch := make(chan PatrolStreamEvent, 100)
|
||||
p.streamMu.Lock()
|
||||
p.subscribers[ch] = struct{}{}
|
||||
p.streamMu.Unlock()
|
||||
return ch
|
||||
}
|
||||
|
||||
// Unsubscribe removes a subscriber channel
|
||||
func (p *PatrolService) Unsubscribe(ch chan PatrolStreamEvent) {
|
||||
p.streamMu.Lock()
|
||||
delete(p.subscribers, ch)
|
||||
p.streamMu.Unlock()
|
||||
close(ch)
|
||||
}
|
||||
|
||||
// broadcast sends an event to all subscribers
|
||||
func (p *PatrolService) broadcast(event PatrolStreamEvent) {
|
||||
p.streamMu.RLock()
|
||||
defer p.streamMu.RUnlock()
|
||||
|
||||
for ch := range p.subscribers {
|
||||
select {
|
||||
case ch <- event:
|
||||
default:
|
||||
// Channel full, skip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RunPatrol executes a patrol analysis
|
||||
func (p *PatrolService) RunPatrol(ctx context.Context) error {
|
||||
_, err := p.RunPatrolWithResult(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
// RunPatrolWithResult executes a patrol analysis and returns detailed results
|
||||
func (p *PatrolService) RunPatrolWithResult(ctx context.Context) (*PatrolResult, error) {
|
||||
p.mu.Lock()
|
||||
if p.running {
|
||||
p.mu.Unlock()
|
||||
return nil, fmt.Errorf("patrol already running")
|
||||
}
|
||||
p.running = true
|
||||
p.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
p.mu.Lock()
|
||||
p.running = false
|
||||
p.mu.Unlock()
|
||||
}()
|
||||
|
||||
start := time.Now()
|
||||
result := &PatrolResult{}
|
||||
|
||||
// Check service is available
|
||||
if p.service == nil || !p.service.IsRunning() {
|
||||
result.Error = fmt.Errorf("Pulse Assistant service not available")
|
||||
return result, result.Error
|
||||
}
|
||||
|
||||
// Build patrol prompt
|
||||
prompt := p.buildPatrolPrompt()
|
||||
|
||||
log.Info().Msg("AI Patrol: Starting infrastructure analysis")
|
||||
p.broadcast(PatrolStreamEvent{Type: "start", Phase: "Starting patrol analysis"})
|
||||
|
||||
// Clear output buffer
|
||||
p.streamMu.Lock()
|
||||
p.currentOutput.Reset()
|
||||
p.streamMu.Unlock()
|
||||
|
||||
// Execute via chat service streaming
|
||||
var responseBuffer strings.Builder
|
||||
|
||||
err := p.service.ExecuteStream(ctx, ExecuteRequest{
|
||||
Prompt: prompt,
|
||||
SessionID: p.sessionID,
|
||||
}, func(event StreamEvent) {
|
||||
switch event.Type {
|
||||
case "content":
|
||||
var contentEvent ContentData
|
||||
if json.Unmarshal(event.Data, &contentEvent) == nil && contentEvent.Text != "" {
|
||||
responseBuffer.WriteString(contentEvent.Text)
|
||||
p.streamMu.Lock()
|
||||
p.currentOutput.WriteString(contentEvent.Text)
|
||||
p.streamMu.Unlock()
|
||||
p.broadcast(PatrolStreamEvent{Type: "content", Content: contentEvent.Text})
|
||||
}
|
||||
case "thinking":
|
||||
var thinkingEvent ThinkingData
|
||||
if json.Unmarshal(event.Data, &thinkingEvent) == nil && thinkingEvent.Text != "" {
|
||||
p.broadcast(PatrolStreamEvent{Type: "thinking", Content: thinkingEvent.Text})
|
||||
}
|
||||
case "tool_start":
|
||||
p.broadcast(PatrolStreamEvent{Type: "tool_use", Phase: "Using tools to gather context"})
|
||||
}
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
result.Error = err
|
||||
result.Duration = time.Since(start)
|
||||
p.broadcast(PatrolStreamEvent{Type: "error", Content: err.Error()})
|
||||
log.Error().Err(err).Msg("AI Patrol: Analysis failed")
|
||||
return result, err
|
||||
}
|
||||
|
||||
result.RawResponse = responseBuffer.String()
|
||||
result.Duration = time.Since(start)
|
||||
|
||||
// Parse findings from response
|
||||
findings := p.parseFindings(result.RawResponse)
|
||||
result.Findings = findings
|
||||
|
||||
// Store findings
|
||||
p.mu.RLock()
|
||||
store := p.findingsStore
|
||||
p.mu.RUnlock()
|
||||
|
||||
if store != nil {
|
||||
for _, f := range findings {
|
||||
if store.Add(f) {
|
||||
result.NewFindings++
|
||||
}
|
||||
}
|
||||
} else {
|
||||
result.NewFindings = len(findings)
|
||||
}
|
||||
|
||||
// Save result
|
||||
p.mu.Lock()
|
||||
p.lastRun = time.Now()
|
||||
p.lastResult = result
|
||||
p.mu.Unlock()
|
||||
|
||||
p.broadcast(PatrolStreamEvent{Type: "complete", Phase: "Patrol complete"})
|
||||
|
||||
log.Info().
|
||||
Int("findings", len(findings)).
|
||||
Int("new_findings", result.NewFindings).
|
||||
Dur("duration", result.Duration).
|
||||
Msg("AI Patrol: Analysis complete")
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// buildPatrolPrompt creates the prompt for patrol analysis
|
||||
func (p *PatrolService) buildPatrolPrompt() string {
|
||||
return `You are the AI Patrol agent for Pulse, an infrastructure monitoring system. You have comprehensive access to monitor Proxmox, Docker, Kubernetes, PBS, and host systems.
|
||||
|
||||
## Your Mission
|
||||
Perform a thorough infrastructure health check. Find issues that need human attention - not just threshold breaches, but patterns, trends, and potential problems before they become critical.
|
||||
|
||||
## Your Toolkit
|
||||
You have access to powerful monitoring tools:
|
||||
|
||||
**Infrastructure Overview:**
|
||||
- pulse_get_topology - Complete snapshot of all nodes, VMs, containers, storage
|
||||
- pulse_list_infrastructure - List all monitored resources
|
||||
- pulse_search_resources - Find specific resources
|
||||
- pulse_get_resource - Deep dive into a specific resource
|
||||
|
||||
**Intelligent Analysis:**
|
||||
- pulse_get_metrics - Time-series data to analyze trends
|
||||
- pulse_get_baselines - Learned NORMAL behavior for each resource (compare against this!)
|
||||
- pulse_get_patterns - Detected anomaly patterns
|
||||
|
||||
**Storage & Data Safety:**
|
||||
- pulse_list_storage - Storage pools and usage
|
||||
- pulse_get_disk_health - SMART data, errors, failure predictions
|
||||
- pulse_list_physical_disks - Physical disk information
|
||||
- pulse_list_backups - Backup status and history
|
||||
- pulse_list_pbs_jobs - PBS backup job status
|
||||
- pulse_list_snapshots - VM/container snapshots
|
||||
|
||||
**Hardware Health:**
|
||||
- pulse_get_temperatures - CPU and disk temperatures
|
||||
- pulse_get_host_raid_status - RAID array health
|
||||
- pulse_get_host_ceph_details - Ceph details on hosts
|
||||
- pulse_get_ceph_status - Ceph cluster status
|
||||
|
||||
**Cluster & Network:**
|
||||
- pulse_get_cluster_status - PVE cluster health
|
||||
- pulse_get_connection_health - API connectivity
|
||||
- pulse_get_network_stats - Network throughput
|
||||
- pulse_get_diskio_stats - Disk I/O statistics
|
||||
- pulse_get_replication - Replication job status
|
||||
|
||||
**Docker/Swarm:**
|
||||
- pulse_get_swarm_status - Docker Swarm cluster
|
||||
- pulse_list_docker_services - Swarm services
|
||||
- pulse_list_docker_updates - Containers with available updates
|
||||
|
||||
**Kubernetes:**
|
||||
- pulse_get_kubernetes_clusters - K8s cluster status
|
||||
- pulse_get_kubernetes_nodes - Node health
|
||||
- pulse_get_kubernetes_pods - Pod status
|
||||
|
||||
**Current Issues:**
|
||||
- pulse_list_alerts - Active threshold alerts
|
||||
- pulse_list_findings - Previously identified patrol findings
|
||||
- pulse_list_resolved_alerts - Recently resolved alerts
|
||||
|
||||
## Patrol Strategy
|
||||
1. **Start broad**: Get topology to understand what you're monitoring
|
||||
2. **Check known issues**: Review active alerts and existing findings
|
||||
3. **Analyze trends**: Use metrics + baselines to spot resources drifting toward problems
|
||||
4. **Check hardware**: Disk health, temperatures, RAID status - catch failures early
|
||||
5. **Verify backups**: Are critical systems being backed up successfully?
|
||||
6. **Look for patterns**: Correlate data - high CPU + high disk I/O might indicate a specific problem
|
||||
|
||||
## What Makes a Good Finding
|
||||
- **Actionable**: User can do something about it
|
||||
- **Evidenced**: Back it up with data from your investigation
|
||||
- **Contextual**: Compare against baselines, not just arbitrary thresholds
|
||||
- **Predictive**: Trends heading toward problems, not just current breaches
|
||||
|
||||
## Output Format
|
||||
For each issue, output:
|
||||
|
||||
[FINDING]
|
||||
KEY: <stable-issue-key>
|
||||
SEVERITY: critical|warning|watch|info
|
||||
CATEGORY: performance|reliability|security|capacity|backup
|
||||
RESOURCE: <resource-name>
|
||||
RESOURCE_TYPE: node|vm|container|docker_container|storage|host|pbs
|
||||
TITLE: <brief title>
|
||||
DESCRIPTION: <detailed description with context>
|
||||
RECOMMENDATION: <specific actionable steps>
|
||||
EVIDENCE: <data from your investigation>
|
||||
[/FINDING]
|
||||
|
||||
## Severity Guidelines
|
||||
- CRITICAL: Immediate action needed - service down, imminent data loss, disk >95%, failing hardware
|
||||
- WARNING: Action needed soon - disk >85% and growing, backup failures >24h, degraded RAID
|
||||
- WATCH: Monitor closely - trends approaching thresholds, intermittent issues
|
||||
- INFO: Awareness only - minor optimizations, non-urgent improvements
|
||||
|
||||
## DO NOT Report
|
||||
- Stopped VMs/containers (unless autostart is enabled and they crashed)
|
||||
- Resources within their normal baseline range
|
||||
- Transient spikes that have already resolved
|
||||
- Issues already covered by existing findings
|
||||
|
||||
If your investigation finds everything healthy, output NO findings. A clean patrol is the best outcome.
|
||||
|
||||
Begin your patrol investigation now.`
|
||||
}
|
||||
|
||||
// parseFindings extracts findings from the AI response
|
||||
func (p *PatrolService) parseFindings(response string) []*PatrolFinding {
|
||||
var findings []*PatrolFinding
|
||||
|
||||
findingRe := regexp.MustCompile(`(?s)\[FINDING\](.*?)\[/FINDING\]`)
|
||||
matches := findingRe.FindAllStringSubmatch(response, -1)
|
||||
|
||||
for _, match := range matches {
|
||||
if len(match) < 2 {
|
||||
continue
|
||||
}
|
||||
block := match[1]
|
||||
|
||||
finding := p.parseFindingBlock(block)
|
||||
if finding != nil {
|
||||
findings = append(findings, finding)
|
||||
}
|
||||
}
|
||||
|
||||
return findings
|
||||
}
|
||||
|
||||
// parseFindingBlock parses a single finding block
|
||||
func (p *PatrolService) parseFindingBlock(block string) *PatrolFinding {
|
||||
extractField := func(name string) string {
|
||||
re := regexp.MustCompile(`(?i)` + name + `:\s*(.+?)(?:\n|$)`)
|
||||
match := re.FindStringSubmatch(block)
|
||||
if len(match) >= 2 {
|
||||
return strings.TrimSpace(match[1])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
key := extractField("KEY")
|
||||
severity := strings.ToLower(extractField("SEVERITY"))
|
||||
category := strings.ToLower(extractField("CATEGORY"))
|
||||
resource := extractField("RESOURCE")
|
||||
resourceType := strings.ToLower(extractField("RESOURCE_TYPE"))
|
||||
title := extractField("TITLE")
|
||||
description := extractField("DESCRIPTION")
|
||||
recommendation := extractField("RECOMMENDATION")
|
||||
evidence := extractField("EVIDENCE")
|
||||
|
||||
if key == "" || title == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
validSeverities := map[string]bool{"critical": true, "warning": true, "watch": true, "info": true}
|
||||
if !validSeverities[severity] {
|
||||
severity = "info"
|
||||
}
|
||||
|
||||
validCategories := map[string]bool{"performance": true, "reliability": true, "security": true, "capacity": true, "backup": true, "configuration": true}
|
||||
if !validCategories[category] {
|
||||
category = "configuration"
|
||||
}
|
||||
|
||||
id := generateFindingID(resource, category, key)
|
||||
|
||||
now := time.Now()
|
||||
return &PatrolFinding{
|
||||
ID: id,
|
||||
Key: key,
|
||||
Severity: severity,
|
||||
Category: category,
|
||||
ResourceID: resource,
|
||||
ResourceName: resource,
|
||||
ResourceType: resourceType,
|
||||
Title: title,
|
||||
Description: description,
|
||||
Recommendation: recommendation,
|
||||
Evidence: evidence,
|
||||
Source: "ai-patrol",
|
||||
DetectedAt: now,
|
||||
LastSeenAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
// generateFindingID creates a stable ID for a finding based on resource, category, and key.
|
||||
// All three components are included to ensure distinct issues on the same resource remain separate.
|
||||
func generateFindingID(resourceID, category, key string) string {
|
||||
input := fmt.Sprintf("%s:%s:%s", resourceID, category, key)
|
||||
hash := sha256.Sum256([]byte(input))
|
||||
return fmt.Sprintf("%x", hash[:8])
|
||||
}
|
||||
|
||||
// CreatePatrolSession creates a dedicated session for patrol
|
||||
func (p *PatrolService) CreatePatrolSession(ctx context.Context) error {
|
||||
if p.service == nil || !p.service.IsRunning() {
|
||||
return fmt.Errorf("Pulse Assistant service not available")
|
||||
}
|
||||
|
||||
session, err := p.service.CreateSession(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create patrol session: %w", err)
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
p.sessionID = session.ID
|
||||
p.mu.Unlock()
|
||||
|
||||
log.Info().Str("session_id", session.ID).Msg("AI Patrol: Created dedicated session")
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSessionID returns the current patrol session ID
|
||||
func (p *PatrolService) GetSessionID() string {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return p.sessionID
|
||||
}
|
||||
@@ -1,272 +0,0 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/ai/providers"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPatrolService_ParseFindings(t *testing.T) {
|
||||
service := NewPatrolService(nil)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
response string
|
||||
want int
|
||||
check func(t *testing.T, f *PatrolFinding)
|
||||
}{
|
||||
{
|
||||
name: "Single Valid Finding",
|
||||
response: `
|
||||
Some introductory text...
|
||||
|
||||
[FINDING]
|
||||
KEY: high-cpu-load
|
||||
SEVERITY: warning
|
||||
CATEGORY: performance
|
||||
RESOURCE: node-01
|
||||
RESOURCE_TYPE: node
|
||||
TITLE: High CPU Load
|
||||
DESCRIPTION: CPU usage is consistently above 80%
|
||||
RECOMMENDATION: Resize node or investigate process
|
||||
EVIDENCE: metrics show 85% avg
|
||||
[/FINDING]
|
||||
|
||||
Closing remarks...
|
||||
`,
|
||||
want: 1,
|
||||
check: func(t *testing.T, f *PatrolFinding) {
|
||||
assert.Equal(t, "high-cpu-load", f.Key)
|
||||
assert.Equal(t, "warning", f.Severity)
|
||||
assert.Equal(t, "performance", f.Category)
|
||||
assert.Equal(t, "node-01", f.ResourceID)
|
||||
assert.Equal(t, "node", f.ResourceType)
|
||||
assert.Equal(t, "High CPU Load", f.Title)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Multiple Findings",
|
||||
response: `
|
||||
[FINDING]
|
||||
KEY: disk-full
|
||||
SEVERITY: critical
|
||||
CATEGORY: capacity
|
||||
RESOURCE: local-lvm
|
||||
RESOURCE_TYPE: storage
|
||||
TITLE: Disk Full
|
||||
DESCRIPTION: Storage is 95% full
|
||||
RECOMMENDATION: Clean up
|
||||
EVIDENCE: df output
|
||||
[/FINDING]
|
||||
|
||||
[FINDING]
|
||||
KEY: vm-down
|
||||
SEVERITY: critical
|
||||
CATEGORY: reliability
|
||||
RESOURCE: 100
|
||||
RESOURCE_TYPE: vm
|
||||
TITLE: VM Down
|
||||
DESCRIPTION: VM 100 is stopped
|
||||
RECOMMENDATION: Start VM
|
||||
EVIDENCE: status check
|
||||
[/FINDING]
|
||||
`,
|
||||
want: 2,
|
||||
},
|
||||
{
|
||||
name: "Malformed Blocks",
|
||||
response: `
|
||||
[FINDING]
|
||||
Incomplete block...
|
||||
[/FINDING]
|
||||
|
||||
[FINDING]
|
||||
KEY: valid-one
|
||||
SEVERITY: info
|
||||
CATEGORY: configuration
|
||||
RESOURCE: test
|
||||
RESOURCE_TYPE: vm
|
||||
TITLE: Test
|
||||
DESCRIPTION: A test
|
||||
RECOMMENDATION: None
|
||||
EVIDENCE: None
|
||||
[/FINDING]
|
||||
`,
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
name: "No Findings",
|
||||
response: "Everything looks good.",
|
||||
want: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
findings := service.parseFindings(tt.response)
|
||||
assert.Len(t, findings, tt.want)
|
||||
if tt.want > 0 && tt.check != nil {
|
||||
tt.check(t, findings[0])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatrolService_ParseFindings_BackupCategory(t *testing.T) {
|
||||
service := NewPatrolService(nil)
|
||||
|
||||
response := `
|
||||
[FINDING]
|
||||
KEY: backup-stale
|
||||
SEVERITY: warning
|
||||
CATEGORY: backup
|
||||
RESOURCE: vm-101
|
||||
RESOURCE_TYPE: vm
|
||||
TITLE: Backup stale
|
||||
DESCRIPTION: No backup in 48 hours
|
||||
RECOMMENDATION: Check backup jobs
|
||||
EVIDENCE: Last backup: 2 days ago
|
||||
[/FINDING]
|
||||
`
|
||||
|
||||
findings := service.parseFindings(response)
|
||||
require.Len(t, findings, 1)
|
||||
assert.Equal(t, "backup", findings[0].Category)
|
||||
assert.Equal(t, "Backup stale", findings[0].Title)
|
||||
}
|
||||
|
||||
// MockFindingsStore
|
||||
type MockFindingsStore struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockFindingsStore) Add(finding *PatrolFinding) bool {
|
||||
args := m.Called(finding)
|
||||
return args.Bool(0)
|
||||
}
|
||||
|
||||
func (m *MockFindingsStore) GetActive() []*PatrolFinding {
|
||||
args := m.Called()
|
||||
return args.Get(0).([]*PatrolFinding)
|
||||
}
|
||||
|
||||
func (m *MockFindingsStore) GetDismissed() []*PatrolFinding {
|
||||
args := m.Called()
|
||||
return args.Get(0).([]*PatrolFinding)
|
||||
}
|
||||
|
||||
func TestPatrolService_RunPatrol(t *testing.T) {
|
||||
// Setup dependencies
|
||||
mockStore := new(MockFindingsStore)
|
||||
mockProvider := new(MockProvider)
|
||||
|
||||
// Create Service manually
|
||||
cfg := Config{
|
||||
AIConfig: &config.AIConfig{
|
||||
Enabled: true,
|
||||
OpenAIAPIKey: "sk-test",
|
||||
ChatModel: "openai:gpt-4",
|
||||
},
|
||||
StateProvider: &mockStateProvider{},
|
||||
DataDir: t.TempDir(),
|
||||
}
|
||||
|
||||
// Use NewService to initialize internal structures (like executor)
|
||||
chatService := NewService(cfg)
|
||||
|
||||
// Manually inject missing pieces since we are bypassing Start()
|
||||
store, err := NewSessionStore(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
chatService.sessions = store
|
||||
|
||||
// Create AgenticLoop with our mock provider
|
||||
// We need a non-nil executor, which NewService created.
|
||||
chatService.agenticLoop = NewAgenticLoop(mockProvider, chatService.executor, "test-prompt")
|
||||
|
||||
// Mark as started
|
||||
chatService.started = true
|
||||
|
||||
// Create PatrolService
|
||||
patrolService := NewPatrolService(chatService)
|
||||
patrolService.SetFindingsStore(mockStore)
|
||||
|
||||
// Mock Provider ChatStream
|
||||
responseContent := `
|
||||
[FINDING]
|
||||
KEY: test-issue
|
||||
SEVERITY: warning
|
||||
CATEGORY: performance
|
||||
RESOURCE: db-01
|
||||
RESOURCE_TYPE: container
|
||||
TITLE: High Latency
|
||||
DESCRIPTION: DB latency > 100ms
|
||||
RECOMMENDATION: Check indexes
|
||||
EVIDENCE: logs
|
||||
[/FINDING]
|
||||
`
|
||||
mockProvider.On("ChatStream", mock.Anything, mock.MatchedBy(func(req providers.ChatRequest) bool {
|
||||
return true
|
||||
}), mock.Anything).Return(nil).Run(func(args mock.Arguments) {
|
||||
callback := args.Get(2).(providers.StreamCallback)
|
||||
|
||||
// Simulate content stream
|
||||
callback(providers.StreamEvent{
|
||||
Type: "content",
|
||||
Data: providers.ContentEvent{Text: responseContent},
|
||||
})
|
||||
|
||||
// Simulate done
|
||||
callback(providers.StreamEvent{
|
||||
Type: "done",
|
||||
Data: providers.DoneEvent{StopReason: "end_turn"},
|
||||
})
|
||||
})
|
||||
|
||||
// Expect finding to be stored
|
||||
mockStore.On("Add", mock.MatchedBy(func(f *PatrolFinding) bool {
|
||||
return f.Key == "test-issue" && f.ResourceID == "db-01"
|
||||
})).Return(true)
|
||||
|
||||
// Run Patrol
|
||||
result, err := patrolService.RunPatrolWithResult(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify
|
||||
assert.Equal(t, 1, result.NewFindings)
|
||||
assert.Len(t, result.Findings, 1)
|
||||
assert.Equal(t, "test-issue", result.Findings[0].Key)
|
||||
assert.Equal(t, result, patrolService.GetLastResult())
|
||||
|
||||
mockProvider.AssertExpectations(t)
|
||||
mockStore.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestPatrolService_SubscribeUnsubscribe(t *testing.T) {
|
||||
patrolService := NewPatrolService(nil)
|
||||
ch := patrolService.Subscribe()
|
||||
|
||||
patrolService.broadcast(PatrolStreamEvent{Type: "content", Content: "hello"})
|
||||
select {
|
||||
case event := <-ch:
|
||||
assert.Equal(t, "content", event.Type)
|
||||
assert.Equal(t, "hello", event.Content)
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Fatal("expected to receive patrol stream event")
|
||||
}
|
||||
|
||||
patrolService.Unsubscribe(ch)
|
||||
_, ok := <-ch
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestPatrolService_RunPatrol_Error(t *testing.T) {
|
||||
patrolService := NewPatrolService(nil)
|
||||
err := patrolService.RunPatrol(context.Background())
|
||||
require.Error(t, err)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,428 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// IncidentRecorderProvider provides access to incident recording data
|
||||
type IncidentRecorderProvider interface {
|
||||
GetWindowsForResource(resourceID string, limit int) []*IncidentWindow
|
||||
GetWindow(windowID string) *IncidentWindow
|
||||
}
|
||||
|
||||
// IncidentWindow represents a high-frequency recording window during an incident
|
||||
type IncidentWindow struct {
|
||||
ID string `json:"id"`
|
||||
ResourceID string `json:"resource_id"`
|
||||
ResourceName string `json:"resource_name,omitempty"`
|
||||
ResourceType string `json:"resource_type,omitempty"`
|
||||
TriggerType string `json:"trigger_type"`
|
||||
TriggerID string `json:"trigger_id,omitempty"`
|
||||
StartTime time.Time `json:"start_time"`
|
||||
EndTime *time.Time `json:"end_time,omitempty"`
|
||||
Status string `json:"status"`
|
||||
DataPoints []IncidentDataPoint `json:"data_points"`
|
||||
Summary *IncidentSummary `json:"summary,omitempty"`
|
||||
}
|
||||
|
||||
// IncidentDataPoint represents a single data point in an incident window
|
||||
type IncidentDataPoint struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Metrics map[string]float64 `json:"metrics"`
|
||||
}
|
||||
|
||||
// IncidentSummary provides computed statistics about an incident window
|
||||
type IncidentSummary struct {
|
||||
Duration time.Duration `json:"duration_ms"`
|
||||
DataPoints int `json:"data_points"`
|
||||
Peaks map[string]float64 `json:"peaks"`
|
||||
Lows map[string]float64 `json:"lows"`
|
||||
Averages map[string]float64 `json:"averages"`
|
||||
Changes map[string]float64 `json:"changes"`
|
||||
}
|
||||
|
||||
// EventCorrelatorProvider provides access to correlated events
|
||||
type EventCorrelatorProvider interface {
|
||||
GetCorrelationsForResource(resourceID string, window time.Duration) []EventCorrelation
|
||||
}
|
||||
|
||||
// EventCorrelation represents a correlated event
|
||||
type EventCorrelation struct {
|
||||
EventType string `json:"event_type"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
ResourceID string `json:"resource_id"`
|
||||
ResourceName string `json:"resource_name,omitempty"`
|
||||
Description string `json:"description"`
|
||||
Severity string `json:"severity,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// TopologyProvider provides access to resource relationships
|
||||
type TopologyProvider interface {
|
||||
GetRelatedResources(resourceID string, depth int) []RelatedResource
|
||||
}
|
||||
|
||||
// RelatedResource represents a resource related to another resource
|
||||
type RelatedResource struct {
|
||||
ResourceID string `json:"resource_id"`
|
||||
ResourceName string `json:"resource_name"`
|
||||
ResourceType string `json:"resource_type"`
|
||||
Relationship string `json:"relationship"`
|
||||
}
|
||||
|
||||
// KnowledgeStoreProvider provides access to stored knowledge/notes
|
||||
type KnowledgeStoreProvider interface {
|
||||
SaveNote(resourceID, note, category string) error
|
||||
GetKnowledge(resourceID string, category string) []KnowledgeEntry
|
||||
}
|
||||
|
||||
// KnowledgeEntry represents a stored note about a resource
|
||||
type KnowledgeEntry struct {
|
||||
ID string `json:"id"`
|
||||
ResourceID string `json:"resource_id"`
|
||||
Note string `json:"note"`
|
||||
Category string `json:"category,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
// registerIntelligenceTools registers AI intelligence tools for incident analysis and knowledge management
|
||||
func (e *PulseToolExecutor) registerIntelligenceTools() {
|
||||
// Tool 1: pulse_get_incident_window
|
||||
e.registry.Register(RegisteredTool{
|
||||
Definition: Tool{
|
||||
Name: "pulse_get_incident_window",
|
||||
Description: `Get high-resolution incident recording data for a resource.
|
||||
|
||||
Returns: JSON with incident windows containing high-frequency metrics captured during incidents, including timestamps, metric values, and summary statistics.
|
||||
|
||||
Use when: You need detailed metrics data from during an incident to analyze what happened, or to see metrics leading up to an alert.`,
|
||||
InputSchema: InputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertySchema{
|
||||
"resource_id": {
|
||||
Type: "string",
|
||||
Description: "The resource ID to get incident data for",
|
||||
},
|
||||
"window_id": {
|
||||
Type: "string",
|
||||
Description: "Optional: specific incident window ID. If omitted, returns most recent windows for the resource.",
|
||||
},
|
||||
"limit": {
|
||||
Type: "integer",
|
||||
Description: "Maximum number of windows to return (default: 5)",
|
||||
},
|
||||
},
|
||||
Required: []string{"resource_id"},
|
||||
},
|
||||
},
|
||||
Handler: func(ctx context.Context, exec *PulseToolExecutor, args map[string]interface{}) (CallToolResult, error) {
|
||||
return exec.executeGetIncidentWindow(ctx, args)
|
||||
},
|
||||
})
|
||||
|
||||
// Tool 2: pulse_correlate_events
|
||||
e.registry.Register(RegisteredTool{
|
||||
Definition: Tool{
|
||||
Name: "pulse_correlate_events",
|
||||
Description: `Get correlated Proxmox events around a timestamp for a resource.
|
||||
|
||||
Returns: JSON with events that occurred around the same time as an incident, helping identify root causes.
|
||||
|
||||
Use when: You want to understand what other events (config changes, migrations, other alerts) happened around the time of an incident.`,
|
||||
InputSchema: InputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertySchema{
|
||||
"resource_id": {
|
||||
Type: "string",
|
||||
Description: "The resource ID to correlate events for",
|
||||
},
|
||||
"timestamp": {
|
||||
Type: "string",
|
||||
Description: "Optional: ISO timestamp to center the search around. Defaults to now.",
|
||||
},
|
||||
"window_minutes": {
|
||||
Type: "integer",
|
||||
Description: "Time window in minutes to search (default: 15)",
|
||||
},
|
||||
},
|
||||
Required: []string{"resource_id"},
|
||||
},
|
||||
},
|
||||
Handler: func(ctx context.Context, exec *PulseToolExecutor, args map[string]interface{}) (CallToolResult, error) {
|
||||
return exec.executeCorrelateEvents(ctx, args)
|
||||
},
|
||||
})
|
||||
|
||||
// Tool 3: pulse_get_relationship_graph
|
||||
e.registry.Register(RegisteredTool{
|
||||
Definition: Tool{
|
||||
Name: "pulse_get_relationship_graph",
|
||||
Description: `Show resource dependencies (host, storage, network).
|
||||
|
||||
Returns: JSON with related resources including the host node, storage volumes, and other dependent resources.
|
||||
|
||||
Use when: You need to understand what a resource depends on or what might be affected by issues with a resource.`,
|
||||
InputSchema: InputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertySchema{
|
||||
"resource_id": {
|
||||
Type: "string",
|
||||
Description: "The resource ID to get relationships for",
|
||||
},
|
||||
"depth": {
|
||||
Type: "integer",
|
||||
Description: "How many levels of relationships to traverse (default: 1, max: 3)",
|
||||
},
|
||||
},
|
||||
Required: []string{"resource_id"},
|
||||
},
|
||||
},
|
||||
Handler: func(ctx context.Context, exec *PulseToolExecutor, args map[string]interface{}) (CallToolResult, error) {
|
||||
return exec.executeGetRelationshipGraph(ctx, args)
|
||||
},
|
||||
})
|
||||
|
||||
// Tool 4: pulse_remember
|
||||
e.registry.Register(RegisteredTool{
|
||||
Definition: Tool{
|
||||
Name: "pulse_remember",
|
||||
Description: `Save a note to memory about a resource.
|
||||
|
||||
Use when: You learn something important about a resource that should be remembered for future reference, such as its purpose, known issues, maintenance schedules, or owner information.
|
||||
|
||||
Notes are persisted and will be available in future conversations.`,
|
||||
InputSchema: InputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertySchema{
|
||||
"resource_id": {
|
||||
Type: "string",
|
||||
Description: "The resource ID to save a note about",
|
||||
},
|
||||
"note": {
|
||||
Type: "string",
|
||||
Description: "The note to save about this resource",
|
||||
},
|
||||
"category": {
|
||||
Type: "string",
|
||||
Description: "Optional category for the note (e.g., 'purpose', 'owner', 'maintenance', 'issue')",
|
||||
},
|
||||
},
|
||||
Required: []string{"resource_id", "note"},
|
||||
},
|
||||
},
|
||||
Handler: func(ctx context.Context, exec *PulseToolExecutor, args map[string]interface{}) (CallToolResult, error) {
|
||||
return exec.executeRemember(ctx, args)
|
||||
},
|
||||
})
|
||||
|
||||
// Tool 5: pulse_recall
|
||||
e.registry.Register(RegisteredTool{
|
||||
Definition: Tool{
|
||||
Name: "pulse_recall",
|
||||
Description: `Recall saved notes about a resource.
|
||||
|
||||
Returns: JSON with all saved notes about a resource, optionally filtered by category.
|
||||
|
||||
Use when: You need to remember previously stored information about a resource, such as its purpose or known issues.`,
|
||||
InputSchema: InputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertySchema{
|
||||
"resource_id": {
|
||||
Type: "string",
|
||||
Description: "The resource ID to recall notes about",
|
||||
},
|
||||
"category": {
|
||||
Type: "string",
|
||||
Description: "Optional: filter notes by category",
|
||||
},
|
||||
},
|
||||
Required: []string{"resource_id"},
|
||||
},
|
||||
},
|
||||
Handler: func(ctx context.Context, exec *PulseToolExecutor, args map[string]interface{}) (CallToolResult, error) {
|
||||
return exec.executeRecall(ctx, args)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Tool handler implementations
|
||||
|
||||
func (e *PulseToolExecutor) executeGetIncidentWindow(_ context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||
resourceID, _ := args["resource_id"].(string)
|
||||
windowID, _ := args["window_id"].(string)
|
||||
limit := intArg(args, "limit", 5)
|
||||
|
||||
if resourceID == "" {
|
||||
return NewErrorResult(fmt.Errorf("resource_id is required")), nil
|
||||
}
|
||||
|
||||
if e.incidentRecorderProvider == nil {
|
||||
return NewTextResult("Incident recording data not available. The incident recorder may not be enabled."), nil
|
||||
}
|
||||
|
||||
// If a specific window ID is requested
|
||||
if windowID != "" {
|
||||
window := e.incidentRecorderProvider.GetWindow(windowID)
|
||||
if window == nil {
|
||||
return NewTextResult(fmt.Sprintf("Incident window '%s' not found.", windowID)), nil
|
||||
}
|
||||
return NewJSONResult(map[string]interface{}{
|
||||
"window": window,
|
||||
}), nil
|
||||
}
|
||||
|
||||
// Get windows for the resource
|
||||
windows := e.incidentRecorderProvider.GetWindowsForResource(resourceID, limit)
|
||||
if len(windows) == 0 {
|
||||
return NewTextResult(fmt.Sprintf("No incident recording data found for resource '%s'. Incident data is captured when alerts fire.", resourceID)), nil
|
||||
}
|
||||
|
||||
return NewJSONResult(map[string]interface{}{
|
||||
"resource_id": resourceID,
|
||||
"windows": windows,
|
||||
"count": len(windows),
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (e *PulseToolExecutor) executeCorrelateEvents(_ context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||
resourceID, _ := args["resource_id"].(string)
|
||||
timestampStr, _ := args["timestamp"].(string)
|
||||
windowMinutes := intArg(args, "window_minutes", 15)
|
||||
|
||||
if resourceID == "" {
|
||||
return NewErrorResult(fmt.Errorf("resource_id is required")), nil
|
||||
}
|
||||
|
||||
if e.eventCorrelatorProvider == nil {
|
||||
return NewTextResult("Event correlation not available. The event correlator may not be enabled."), nil
|
||||
}
|
||||
|
||||
// Parse timestamp or use now
|
||||
var timestamp time.Time
|
||||
if timestampStr != "" {
|
||||
var err error
|
||||
timestamp, err = time.Parse(time.RFC3339, timestampStr)
|
||||
if err != nil {
|
||||
return NewErrorResult(fmt.Errorf("invalid timestamp format: %w", err)), nil
|
||||
}
|
||||
} else {
|
||||
timestamp = time.Now()
|
||||
}
|
||||
|
||||
window := time.Duration(windowMinutes) * time.Minute
|
||||
correlations := e.eventCorrelatorProvider.GetCorrelationsForResource(resourceID, window)
|
||||
|
||||
if len(correlations) == 0 {
|
||||
return NewTextResult(fmt.Sprintf("No correlated events found for resource '%s' within %d minutes of %s.",
|
||||
resourceID, windowMinutes, timestamp.Format(time.RFC3339))), nil
|
||||
}
|
||||
|
||||
return NewJSONResult(map[string]interface{}{
|
||||
"resource_id": resourceID,
|
||||
"timestamp": timestamp.Format(time.RFC3339),
|
||||
"window_minutes": windowMinutes,
|
||||
"events": correlations,
|
||||
"count": len(correlations),
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (e *PulseToolExecutor) executeGetRelationshipGraph(_ context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||
resourceID, _ := args["resource_id"].(string)
|
||||
depth := intArg(args, "depth", 1)
|
||||
|
||||
if resourceID == "" {
|
||||
return NewErrorResult(fmt.Errorf("resource_id is required")), nil
|
||||
}
|
||||
|
||||
// Cap depth to prevent excessive traversal
|
||||
if depth < 1 {
|
||||
depth = 1
|
||||
}
|
||||
if depth > 3 {
|
||||
depth = 3
|
||||
}
|
||||
|
||||
if e.topologyProvider == nil {
|
||||
return NewTextResult("Topology information not available."), nil
|
||||
}
|
||||
|
||||
related := e.topologyProvider.GetRelatedResources(resourceID, depth)
|
||||
if len(related) == 0 {
|
||||
return NewTextResult(fmt.Sprintf("No relationships found for resource '%s'.", resourceID)), nil
|
||||
}
|
||||
|
||||
return NewJSONResult(map[string]interface{}{
|
||||
"resource_id": resourceID,
|
||||
"depth": depth,
|
||||
"related_resources": related,
|
||||
"count": len(related),
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (e *PulseToolExecutor) executeRemember(_ context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||
resourceID, _ := args["resource_id"].(string)
|
||||
note, _ := args["note"].(string)
|
||||
category, _ := args["category"].(string)
|
||||
|
||||
if resourceID == "" {
|
||||
return NewErrorResult(fmt.Errorf("resource_id is required")), nil
|
||||
}
|
||||
if note == "" {
|
||||
return NewErrorResult(fmt.Errorf("note is required")), nil
|
||||
}
|
||||
|
||||
if e.knowledgeStoreProvider == nil {
|
||||
return NewTextResult("Knowledge storage not available."), nil
|
||||
}
|
||||
|
||||
if err := e.knowledgeStoreProvider.SaveNote(resourceID, note, category); err != nil {
|
||||
return NewErrorResult(fmt.Errorf("failed to save note: %w", err)), nil
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"success": true,
|
||||
"resource_id": resourceID,
|
||||
"note": note,
|
||||
"message": "Note saved successfully",
|
||||
}
|
||||
if category != "" {
|
||||
response["category"] = category
|
||||
}
|
||||
|
||||
return NewJSONResult(response), nil
|
||||
}
|
||||
|
||||
func (e *PulseToolExecutor) executeRecall(_ context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||
resourceID, _ := args["resource_id"].(string)
|
||||
category, _ := args["category"].(string)
|
||||
|
||||
if resourceID == "" {
|
||||
return NewErrorResult(fmt.Errorf("resource_id is required")), nil
|
||||
}
|
||||
|
||||
if e.knowledgeStoreProvider == nil {
|
||||
return NewTextResult("Knowledge storage not available."), nil
|
||||
}
|
||||
|
||||
entries := e.knowledgeStoreProvider.GetKnowledge(resourceID, category)
|
||||
if len(entries) == 0 {
|
||||
if category != "" {
|
||||
return NewTextResult(fmt.Sprintf("No notes found for resource '%s' in category '%s'.", resourceID, category)), nil
|
||||
}
|
||||
return NewTextResult(fmt.Sprintf("No notes found for resource '%s'.", resourceID)), nil
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"resource_id": resourceID,
|
||||
"notes": entries,
|
||||
"count": len(entries),
|
||||
}
|
||||
if category != "" {
|
||||
response["category"] = category
|
||||
}
|
||||
|
||||
return NewJSONResult(response), nil
|
||||
}
|
||||
@@ -1,769 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// registerPatrolTools registers patrol context tools (metrics, baselines, patterns, alerts, findings)
|
||||
func (e *PulseToolExecutor) registerPatrolTools() {
|
||||
e.registry.Register(RegisteredTool{
|
||||
Definition: Tool{
|
||||
Name: "pulse_get_metrics",
|
||||
Description: "Get historical metrics (CPU, memory, disk) for resources over 24 hours or 7 days. Use this to understand trends and detect anomalies.",
|
||||
InputSchema: InputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertySchema{
|
||||
"period": {
|
||||
Type: "string",
|
||||
Description: "Time period: '24h' for last 24 hours, '7d' for last 7 days",
|
||||
Enum: []string{"24h", "7d"},
|
||||
},
|
||||
"resource_id": {
|
||||
Type: "string",
|
||||
Description: "Optional: specific resource ID. If omitted, returns summary for all resources.",
|
||||
},
|
||||
"resource_type": {
|
||||
Type: "string",
|
||||
Description: "Optional: filter by resource type (vm, container, node)",
|
||||
},
|
||||
"limit": {
|
||||
Type: "integer",
|
||||
Description: "Max results when returning summary (default 100)",
|
||||
},
|
||||
"offset": {
|
||||
Type: "integer",
|
||||
Description: "Skip N results when returning summary",
|
||||
},
|
||||
},
|
||||
Required: []string{"period"},
|
||||
},
|
||||
},
|
||||
Handler: func(ctx context.Context, exec *PulseToolExecutor, args map[string]interface{}) (CallToolResult, error) {
|
||||
return exec.executeGetMetrics(ctx, args)
|
||||
},
|
||||
})
|
||||
|
||||
e.registry.Register(RegisteredTool{
|
||||
Definition: Tool{
|
||||
Name: "pulse_get_baselines",
|
||||
Description: "Get learned baselines for resources. Baselines represent 'normal' behavior and help detect anomalies.",
|
||||
InputSchema: InputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertySchema{
|
||||
"resource_id": {
|
||||
Type: "string",
|
||||
Description: "Optional: specific resource ID. If omitted, returns all baselines.",
|
||||
},
|
||||
"resource_type": {
|
||||
Type: "string",
|
||||
Description: "Optional: filter by resource type (vm, container, node)",
|
||||
},
|
||||
"limit": {
|
||||
Type: "integer",
|
||||
Description: "Max results when returning baselines (default 100)",
|
||||
},
|
||||
"offset": {
|
||||
Type: "integer",
|
||||
Description: "Skip N results when returning baselines",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Handler: func(ctx context.Context, exec *PulseToolExecutor, args map[string]interface{}) (CallToolResult, error) {
|
||||
return exec.executeGetBaselines(ctx, args)
|
||||
},
|
||||
})
|
||||
|
||||
e.registry.Register(RegisteredTool{
|
||||
Definition: Tool{
|
||||
Name: "pulse_get_patterns",
|
||||
Description: "Get detected operational patterns and predictions. Includes recurring spikes, growth trends, and predicted issues.",
|
||||
InputSchema: InputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertySchema{},
|
||||
},
|
||||
},
|
||||
Handler: func(ctx context.Context, exec *PulseToolExecutor, args map[string]interface{}) (CallToolResult, error) {
|
||||
return exec.executeGetPatterns(ctx, args)
|
||||
},
|
||||
})
|
||||
|
||||
e.registry.Register(RegisteredTool{
|
||||
Definition: Tool{
|
||||
Name: "pulse_list_alerts",
|
||||
Description: `List active threshold alerts (CPU > 80%, disk full, etc).
|
||||
|
||||
Returns: JSON array of alerts with resource, type, severity, value, threshold.
|
||||
|
||||
Use when: User asks about alerts, warnings, or "what's wrong" with infrastructure.
|
||||
|
||||
Do NOT use for: Checking if something is running (use pulse_get_topology).`,
|
||||
InputSchema: InputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertySchema{
|
||||
"severity": {
|
||||
Type: "string",
|
||||
Description: "Filter: 'critical', 'warning', or 'info'. Omit for all.",
|
||||
Enum: []string{"critical", "warning", "info"},
|
||||
},
|
||||
"limit": {
|
||||
Type: "integer",
|
||||
Description: "Max results (default 100)",
|
||||
},
|
||||
"offset": {
|
||||
Type: "integer",
|
||||
Description: "Skip N results for pagination",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Handler: func(ctx context.Context, exec *PulseToolExecutor, args map[string]interface{}) (CallToolResult, error) {
|
||||
return exec.executeListAlerts(ctx, args)
|
||||
},
|
||||
})
|
||||
|
||||
e.registry.Register(RegisteredTool{
|
||||
Definition: Tool{
|
||||
Name: "pulse_list_findings",
|
||||
Description: `List AI patrol findings - issues detected by automated analysis.
|
||||
|
||||
Returns: JSON with active findings (current issues) and counts.
|
||||
|
||||
Use when: User asks about findings, issues, or "what did the AI find".
|
||||
|
||||
Do NOT use for: Checking if something is running (use pulse_get_topology), or threshold alerts (use pulse_list_alerts).`,
|
||||
InputSchema: InputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertySchema{
|
||||
"include_dismissed": {
|
||||
Type: "boolean",
|
||||
Description: "Include previously dismissed findings",
|
||||
},
|
||||
"severity": {
|
||||
Type: "string",
|
||||
Description: "Filter: critical, warning, or info. Omit for all.",
|
||||
Enum: []string{"critical", "warning", "info"},
|
||||
},
|
||||
"resource_type": {
|
||||
Type: "string",
|
||||
Description: "Optional: filter by resource type (vm, container, node, docker)",
|
||||
},
|
||||
"resource_id": {
|
||||
Type: "string",
|
||||
Description: "Optional: filter by resource ID",
|
||||
},
|
||||
"limit": {
|
||||
Type: "integer",
|
||||
Description: "Max results (default 100)",
|
||||
},
|
||||
"offset": {
|
||||
Type: "integer",
|
||||
Description: "Skip N results for pagination",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Handler: func(ctx context.Context, exec *PulseToolExecutor, args map[string]interface{}) (CallToolResult, error) {
|
||||
return exec.executeListFindings(ctx, args)
|
||||
},
|
||||
})
|
||||
|
||||
e.registry.Register(RegisteredTool{
|
||||
Definition: Tool{
|
||||
Name: "pulse_resolve_finding",
|
||||
Description: "Mark an AI patrol finding as resolved after fixing the issue.",
|
||||
InputSchema: InputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertySchema{
|
||||
"finding_id": {
|
||||
Type: "string",
|
||||
Description: "The finding ID to resolve",
|
||||
},
|
||||
"resolution_note": {
|
||||
Type: "string",
|
||||
Description: "Brief description of how the issue was resolved",
|
||||
},
|
||||
},
|
||||
Required: []string{"finding_id", "resolution_note"},
|
||||
},
|
||||
},
|
||||
Handler: func(ctx context.Context, exec *PulseToolExecutor, args map[string]interface{}) (CallToolResult, error) {
|
||||
return exec.executeResolveFinding(ctx, args)
|
||||
},
|
||||
})
|
||||
|
||||
e.registry.Register(RegisteredTool{
|
||||
Definition: Tool{
|
||||
Name: "pulse_dismiss_finding",
|
||||
Description: "Dismiss an AI patrol finding as not an issue or expected behavior.",
|
||||
InputSchema: InputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertySchema{
|
||||
"finding_id": {
|
||||
Type: "string",
|
||||
Description: "The finding ID to dismiss",
|
||||
},
|
||||
"reason": {
|
||||
Type: "string",
|
||||
Description: "Why the finding is being dismissed",
|
||||
Enum: []string{"not_an_issue", "expected_behavior", "will_fix_later"},
|
||||
},
|
||||
"note": {
|
||||
Type: "string",
|
||||
Description: "Explanation of why this is being dismissed",
|
||||
},
|
||||
},
|
||||
Required: []string{"finding_id", "reason", "note"},
|
||||
},
|
||||
},
|
||||
Handler: func(ctx context.Context, exec *PulseToolExecutor, args map[string]interface{}) (CallToolResult, error) {
|
||||
return exec.executeDismissFinding(ctx, args)
|
||||
},
|
||||
})
|
||||
|
||||
// ========== Resolved Alerts Tool ==========
|
||||
|
||||
e.registry.Register(RegisteredTool{
|
||||
Definition: Tool{
|
||||
Name: "pulse_list_resolved_alerts",
|
||||
Description: `List recently resolved alerts (alerts that were active but have since cleared).
|
||||
|
||||
Returns: JSON with alerts array containing alert details including type, level, resource info, message, start time, and when it was resolved.
|
||||
|
||||
Use when: User asks about alerts that cleared, what issues resolved themselves, or wants to see recent alert history.`,
|
||||
InputSchema: InputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertySchema{
|
||||
"type": {
|
||||
Type: "string",
|
||||
Description: "Optional: filter by alert type",
|
||||
},
|
||||
"level": {
|
||||
Type: "string",
|
||||
Description: "Optional: filter by level (critical, warning)",
|
||||
},
|
||||
"limit": {
|
||||
Type: "integer",
|
||||
Description: "Maximum number of results (default: 50)",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Handler: func(ctx context.Context, exec *PulseToolExecutor, args map[string]interface{}) (CallToolResult, error) {
|
||||
return exec.executeListResolvedAlerts(ctx, args)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (e *PulseToolExecutor) executeGetMetrics(_ context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||
period, _ := args["period"].(string)
|
||||
resourceID, _ := args["resource_id"].(string)
|
||||
resourceType, _ := args["resource_type"].(string)
|
||||
limit := intArg(args, "limit", 100)
|
||||
offset := intArg(args, "offset", 0)
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
resourceType = strings.ToLower(strings.TrimSpace(resourceType))
|
||||
if resourceType != "" {
|
||||
validTypes := map[string]bool{"vm": true, "container": true, "node": true}
|
||||
if !validTypes[resourceType] {
|
||||
return NewErrorResult(fmt.Errorf("invalid resource_type: %s. Use vm, container, or node", resourceType)), nil
|
||||
}
|
||||
}
|
||||
|
||||
if e.metricsHistory == nil {
|
||||
return NewTextResult("Metrics history not available. The system may still be collecting data."), nil
|
||||
}
|
||||
|
||||
var duration time.Duration
|
||||
switch period {
|
||||
case "24h":
|
||||
duration = 24 * time.Hour
|
||||
case "7d":
|
||||
duration = 7 * 24 * time.Hour
|
||||
default:
|
||||
duration = 24 * time.Hour
|
||||
period = "24h"
|
||||
}
|
||||
|
||||
response := MetricsResponse{
|
||||
Period: period,
|
||||
}
|
||||
|
||||
if resourceID != "" {
|
||||
response.ResourceID = resourceID
|
||||
metrics, err := e.metricsHistory.GetResourceMetrics(resourceID, duration)
|
||||
if err != nil {
|
||||
return NewErrorResult(err), nil
|
||||
}
|
||||
response.Points = metrics
|
||||
return NewJSONResult(response), nil
|
||||
}
|
||||
|
||||
summary, err := e.metricsHistory.GetAllMetricsSummary(duration)
|
||||
if err != nil {
|
||||
return NewErrorResult(err), nil
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(summary))
|
||||
for id, metric := range summary {
|
||||
if resourceType != "" && strings.ToLower(metric.ResourceType) != resourceType {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, id)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
filtered := make(map[string]ResourceMetricsSummary)
|
||||
total := 0
|
||||
for _, id := range keys {
|
||||
if total < offset {
|
||||
total++
|
||||
continue
|
||||
}
|
||||
if len(filtered) >= limit {
|
||||
total++
|
||||
continue
|
||||
}
|
||||
filtered[id] = summary[id]
|
||||
total++
|
||||
}
|
||||
|
||||
if filtered == nil {
|
||||
filtered = map[string]ResourceMetricsSummary{}
|
||||
}
|
||||
|
||||
response.Summary = filtered
|
||||
if offset > 0 || total > limit {
|
||||
response.Pagination = &PaginationInfo{
|
||||
Total: total,
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
}
|
||||
}
|
||||
|
||||
return NewJSONResult(response), nil
|
||||
}
|
||||
|
||||
func (e *PulseToolExecutor) executeGetBaselines(_ context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||
resourceID, _ := args["resource_id"].(string)
|
||||
resourceType, _ := args["resource_type"].(string)
|
||||
limit := intArg(args, "limit", 100)
|
||||
offset := intArg(args, "offset", 0)
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
resourceType = strings.ToLower(strings.TrimSpace(resourceType))
|
||||
if resourceType != "" {
|
||||
validTypes := map[string]bool{"vm": true, "container": true, "node": true}
|
||||
if !validTypes[resourceType] {
|
||||
return NewErrorResult(fmt.Errorf("invalid resource_type: %s. Use vm, container, or node", resourceType)), nil
|
||||
}
|
||||
}
|
||||
|
||||
if e.baselineProvider == nil {
|
||||
return NewTextResult("Baseline data not available. The system needs time to learn normal behavior patterns."), nil
|
||||
}
|
||||
|
||||
response := BaselinesResponse{
|
||||
Baselines: make(map[string]map[string]*MetricBaseline),
|
||||
}
|
||||
|
||||
if resourceID != "" {
|
||||
response.ResourceID = resourceID
|
||||
cpuBaseline := e.baselineProvider.GetBaseline(resourceID, "cpu")
|
||||
memBaseline := e.baselineProvider.GetBaseline(resourceID, "memory")
|
||||
|
||||
if cpuBaseline != nil || memBaseline != nil {
|
||||
response.Baselines[resourceID] = make(map[string]*MetricBaseline)
|
||||
if cpuBaseline != nil {
|
||||
response.Baselines[resourceID]["cpu"] = cpuBaseline
|
||||
}
|
||||
if memBaseline != nil {
|
||||
response.Baselines[resourceID]["memory"] = memBaseline
|
||||
}
|
||||
}
|
||||
return NewJSONResult(response), nil
|
||||
}
|
||||
|
||||
baselines := e.baselineProvider.GetAllBaselines()
|
||||
keys := make([]string, 0, len(baselines))
|
||||
var typeIndex map[string]string
|
||||
if resourceType != "" {
|
||||
if e.stateProvider == nil {
|
||||
return NewErrorResult(fmt.Errorf("state provider not available")), nil
|
||||
}
|
||||
state := e.stateProvider.GetState()
|
||||
typeIndex = make(map[string]string)
|
||||
for _, vm := range state.VMs {
|
||||
typeIndex[fmt.Sprintf("%d", vm.VMID)] = "vm"
|
||||
}
|
||||
for _, ct := range state.Containers {
|
||||
typeIndex[fmt.Sprintf("%d", ct.VMID)] = "container"
|
||||
}
|
||||
for _, node := range state.Nodes {
|
||||
typeIndex[node.ID] = "node"
|
||||
}
|
||||
}
|
||||
|
||||
for id := range baselines {
|
||||
if resourceType != "" {
|
||||
if t, ok := typeIndex[id]; !ok || t != resourceType {
|
||||
continue
|
||||
}
|
||||
}
|
||||
keys = append(keys, id)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
filtered := make(map[string]map[string]*MetricBaseline)
|
||||
total := 0
|
||||
for _, id := range keys {
|
||||
if total < offset {
|
||||
total++
|
||||
continue
|
||||
}
|
||||
if len(filtered) >= limit {
|
||||
total++
|
||||
continue
|
||||
}
|
||||
filtered[id] = baselines[id]
|
||||
total++
|
||||
}
|
||||
|
||||
if filtered == nil {
|
||||
filtered = map[string]map[string]*MetricBaseline{}
|
||||
}
|
||||
|
||||
response.Baselines = filtered
|
||||
if offset > 0 || total > limit {
|
||||
response.Pagination = &PaginationInfo{
|
||||
Total: total,
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
}
|
||||
}
|
||||
|
||||
return NewJSONResult(response), nil
|
||||
}
|
||||
|
||||
func (e *PulseToolExecutor) executeGetPatterns(_ context.Context, _ map[string]interface{}) (CallToolResult, error) {
|
||||
if e.patternProvider == nil {
|
||||
return NewTextResult("Pattern detection not available. The system needs more historical data."), nil
|
||||
}
|
||||
|
||||
response := PatternsResponse{
|
||||
Patterns: e.patternProvider.GetPatterns(),
|
||||
Predictions: e.patternProvider.GetPredictions(),
|
||||
}
|
||||
|
||||
// Ensure non-nil slices for clean JSON
|
||||
if response.Patterns == nil {
|
||||
response.Patterns = []Pattern{}
|
||||
}
|
||||
if response.Predictions == nil {
|
||||
response.Predictions = []Prediction{}
|
||||
}
|
||||
|
||||
return NewJSONResult(response), nil
|
||||
}
|
||||
|
||||
func (e *PulseToolExecutor) executeListAlerts(_ context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||
if e.alertProvider == nil {
|
||||
return NewTextResult("Alert data not available."), nil
|
||||
}
|
||||
|
||||
severityFilter, _ := args["severity"].(string)
|
||||
limit := intArg(args, "limit", 100)
|
||||
offset := intArg(args, "offset", 0)
|
||||
|
||||
allAlerts := e.alertProvider.GetActiveAlerts()
|
||||
|
||||
var filtered []ActiveAlert
|
||||
for i, a := range allAlerts {
|
||||
if i < offset {
|
||||
continue
|
||||
}
|
||||
if len(filtered) >= limit {
|
||||
break
|
||||
}
|
||||
if severityFilter != "" && a.Severity != severityFilter {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, a)
|
||||
}
|
||||
|
||||
if filtered == nil {
|
||||
filtered = []ActiveAlert{}
|
||||
}
|
||||
|
||||
response := AlertsResponse{
|
||||
Alerts: filtered,
|
||||
Count: len(filtered),
|
||||
}
|
||||
|
||||
if offset > 0 || len(allAlerts) > limit {
|
||||
response.Pagination = &PaginationInfo{
|
||||
Total: len(allAlerts),
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
}
|
||||
}
|
||||
|
||||
return NewJSONResult(response), nil
|
||||
}
|
||||
|
||||
func (e *PulseToolExecutor) executeListFindings(_ context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||
includeDismissed, _ := args["include_dismissed"].(bool)
|
||||
severityFilter, _ := args["severity"].(string)
|
||||
resourceType, _ := args["resource_type"].(string)
|
||||
resourceID, _ := args["resource_id"].(string)
|
||||
limit := intArg(args, "limit", 100)
|
||||
offset := intArg(args, "offset", 0)
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
resourceType = strings.ToLower(strings.TrimSpace(resourceType))
|
||||
resourceID = strings.TrimSpace(resourceID)
|
||||
if resourceType != "" {
|
||||
validTypes := map[string]bool{"vm": true, "container": true, "node": true, "docker": true}
|
||||
if !validTypes[resourceType] {
|
||||
return NewErrorResult(fmt.Errorf("invalid resource_type: %s. Use vm, container, node, or docker", resourceType)), nil
|
||||
}
|
||||
}
|
||||
|
||||
if e.findingsProvider == nil {
|
||||
return NewTextResult("Patrol findings not available. Pulse Patrol may not be running."), nil
|
||||
}
|
||||
|
||||
allActive := e.findingsProvider.GetActiveFindings()
|
||||
var allDismissed []Finding
|
||||
if includeDismissed {
|
||||
allDismissed = e.findingsProvider.GetDismissedFindings()
|
||||
}
|
||||
|
||||
normalizeType := func(value string) string {
|
||||
normalized := strings.ToLower(strings.TrimSpace(value))
|
||||
switch normalized {
|
||||
case "docker container", "docker-container", "docker_container":
|
||||
return "docker"
|
||||
case "lxc", "lxc container", "lxc-container", "lxc_container":
|
||||
return "container"
|
||||
default:
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
|
||||
matches := func(f Finding) bool {
|
||||
if severityFilter != "" && f.Severity != severityFilter {
|
||||
return false
|
||||
}
|
||||
if resourceID != "" && f.ResourceID != resourceID {
|
||||
return false
|
||||
}
|
||||
if resourceType != "" && normalizeType(f.ResourceType) != resourceType {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Filter active
|
||||
var active []Finding
|
||||
totalActive := 0
|
||||
for _, f := range allActive {
|
||||
if !matches(f) {
|
||||
continue
|
||||
}
|
||||
if totalActive < offset {
|
||||
totalActive++
|
||||
continue
|
||||
}
|
||||
if len(active) >= limit {
|
||||
totalActive++
|
||||
continue
|
||||
}
|
||||
active = append(active, f)
|
||||
totalActive++
|
||||
}
|
||||
|
||||
// Filter dismissed
|
||||
var dismissed []Finding
|
||||
totalDismissed := 0
|
||||
if includeDismissed {
|
||||
for _, f := range allDismissed {
|
||||
if !matches(f) {
|
||||
continue
|
||||
}
|
||||
if totalDismissed < offset {
|
||||
totalDismissed++
|
||||
continue
|
||||
}
|
||||
if len(dismissed) >= limit {
|
||||
totalDismissed++
|
||||
continue
|
||||
}
|
||||
dismissed = append(dismissed, f)
|
||||
totalDismissed++
|
||||
}
|
||||
}
|
||||
|
||||
if active == nil {
|
||||
active = []Finding{}
|
||||
}
|
||||
if dismissed == nil {
|
||||
dismissed = []Finding{}
|
||||
}
|
||||
|
||||
response := FindingsResponse{
|
||||
Active: active,
|
||||
Dismissed: dismissed,
|
||||
Counts: FindingCounts{
|
||||
Active: totalActive,
|
||||
Dismissed: totalDismissed,
|
||||
},
|
||||
}
|
||||
|
||||
total := totalActive + totalDismissed
|
||||
if offset > 0 || total > limit {
|
||||
response.Pagination = &PaginationInfo{
|
||||
Total: total,
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
}
|
||||
}
|
||||
|
||||
return NewJSONResult(response), nil
|
||||
}
|
||||
|
||||
func (e *PulseToolExecutor) executeResolveFinding(_ context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||
findingID, _ := args["finding_id"].(string)
|
||||
resolutionNote, _ := args["resolution_note"].(string)
|
||||
|
||||
if findingID == "" {
|
||||
return NewErrorResult(fmt.Errorf("finding_id is required")), nil
|
||||
}
|
||||
if resolutionNote == "" {
|
||||
return NewErrorResult(fmt.Errorf("resolution_note is required")), nil
|
||||
}
|
||||
|
||||
if e.findingsManager == nil {
|
||||
return NewTextResult("Findings manager not available."), nil
|
||||
}
|
||||
|
||||
if err := e.findingsManager.ResolveFinding(findingID, resolutionNote); err != nil {
|
||||
return NewErrorResult(err), nil
|
||||
}
|
||||
|
||||
return NewJSONResult(map[string]interface{}{
|
||||
"success": true,
|
||||
"finding_id": findingID,
|
||||
"action": "resolved",
|
||||
"resolution_note": resolutionNote,
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (e *PulseToolExecutor) executeDismissFinding(_ context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||
findingID, _ := args["finding_id"].(string)
|
||||
reason, _ := args["reason"].(string)
|
||||
note, _ := args["note"].(string)
|
||||
|
||||
if findingID == "" {
|
||||
return NewErrorResult(fmt.Errorf("finding_id is required")), nil
|
||||
}
|
||||
if reason == "" {
|
||||
return NewErrorResult(fmt.Errorf("reason is required")), nil
|
||||
}
|
||||
if note == "" {
|
||||
return NewErrorResult(fmt.Errorf("note is required")), nil
|
||||
}
|
||||
|
||||
if e.findingsManager == nil {
|
||||
return NewTextResult("Findings manager not available."), nil
|
||||
}
|
||||
|
||||
if err := e.findingsManager.DismissFinding(findingID, reason, note); err != nil {
|
||||
return NewErrorResult(err), nil
|
||||
}
|
||||
|
||||
return NewJSONResult(map[string]interface{}{
|
||||
"success": true,
|
||||
"finding_id": findingID,
|
||||
"action": "dismissed",
|
||||
"reason": reason,
|
||||
"note": note,
|
||||
}), nil
|
||||
}
|
||||
|
||||
// ========== Resolved Alerts Tool Implementation ==========
|
||||
|
||||
func (e *PulseToolExecutor) executeListResolvedAlerts(_ context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||
if e.stateProvider == nil {
|
||||
return NewTextResult("State provider not available."), nil
|
||||
}
|
||||
|
||||
typeFilter, _ := args["type"].(string)
|
||||
levelFilter, _ := args["level"].(string)
|
||||
limit := intArg(args, "limit", 50)
|
||||
|
||||
state := e.stateProvider.GetState()
|
||||
|
||||
if len(state.RecentlyResolved) == 0 {
|
||||
return NewTextResult("No recently resolved alerts."), nil
|
||||
}
|
||||
|
||||
var alerts []ResolvedAlertSummary
|
||||
|
||||
for _, alert := range state.RecentlyResolved {
|
||||
// Apply filters
|
||||
if typeFilter != "" && !strings.EqualFold(alert.Type, typeFilter) {
|
||||
continue
|
||||
}
|
||||
if levelFilter != "" && !strings.EqualFold(alert.Level, levelFilter) {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(alerts) >= limit {
|
||||
break
|
||||
}
|
||||
|
||||
alerts = append(alerts, ResolvedAlertSummary{
|
||||
ID: alert.ID,
|
||||
Type: alert.Type,
|
||||
Level: alert.Level,
|
||||
ResourceID: alert.ResourceID,
|
||||
ResourceName: alert.ResourceName,
|
||||
Node: alert.Node,
|
||||
Instance: alert.Instance,
|
||||
Message: alert.Message,
|
||||
Value: alert.Value,
|
||||
Threshold: alert.Threshold,
|
||||
StartTime: alert.StartTime,
|
||||
ResolvedTime: alert.ResolvedTime,
|
||||
})
|
||||
}
|
||||
|
||||
if alerts == nil {
|
||||
alerts = []ResolvedAlertSummary{}
|
||||
}
|
||||
|
||||
response := ResolvedAlertsResponse{
|
||||
Alerts: alerts,
|
||||
Total: len(state.RecentlyResolved),
|
||||
}
|
||||
|
||||
return NewJSONResult(response), nil
|
||||
}
|
||||
@@ -1,344 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||
)
|
||||
|
||||
// registerProfileTools registers agent profile tools
|
||||
func (e *PulseToolExecutor) registerProfileTools() {
|
||||
// Read-only profile tool (always available)
|
||||
e.registry.Register(RegisteredTool{
|
||||
Definition: Tool{
|
||||
Name: "pulse_get_agent_scope",
|
||||
Description: "Get the current unified agent scope (profile assignment and settings).",
|
||||
InputSchema: InputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertySchema{
|
||||
"agent_id": {
|
||||
Type: "string",
|
||||
Description: "Unified agent ID (preferred if known)",
|
||||
},
|
||||
"hostname": {
|
||||
Type: "string",
|
||||
Description: "Hostname or display name to resolve the agent ID",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Handler: func(ctx context.Context, exec *PulseToolExecutor, args map[string]interface{}) (CallToolResult, error) {
|
||||
return exec.executeGetAgentScope(ctx, args)
|
||||
},
|
||||
RequireControl: false,
|
||||
})
|
||||
|
||||
// Unified write tool for profile management
|
||||
e.registry.Register(RegisteredTool{
|
||||
Definition: Tool{
|
||||
Name: "pulse_set_agent_scope",
|
||||
Description: "Update a unified agent's scope via profile settings or assign an existing profile. Use this to enable/disable modules like Docker, Kubernetes, or Proxmox.",
|
||||
InputSchema: InputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertySchema{
|
||||
"agent_id": {
|
||||
Type: "string",
|
||||
Description: "Unified agent ID (preferred if known)",
|
||||
},
|
||||
"hostname": {
|
||||
Type: "string",
|
||||
Description: "Hostname or display name to resolve the agent ID",
|
||||
},
|
||||
"profile_id": {
|
||||
Type: "string",
|
||||
Description: "Assign an existing profile ID (optional; omit to use settings)",
|
||||
},
|
||||
"settings": {
|
||||
Type: "object",
|
||||
Description: "Profile settings (e.g., enable_host, enable_docker, enable_kubernetes, enable_proxmox, proxmox_type, docker_runtime, disable_auto_update, disable_docker_update_checks, kube_include_all_pods, kube_include_all_deployments, log_level, interval, report_ip, disable_ceph)",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Handler: func(ctx context.Context, exec *PulseToolExecutor, args map[string]interface{}) (CallToolResult, error) {
|
||||
return exec.executeSetAgentScope(ctx, args)
|
||||
},
|
||||
RequireControl: true,
|
||||
})
|
||||
}
|
||||
|
||||
func (e *PulseToolExecutor) executeGetAgentScope(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||
agentID, _ := args["agent_id"].(string)
|
||||
hostname, _ := args["hostname"].(string)
|
||||
agentID = strings.TrimSpace(agentID)
|
||||
hostname = strings.TrimSpace(hostname)
|
||||
|
||||
if agentID == "" && hostname == "" {
|
||||
return NewErrorResult(fmt.Errorf("agent_id or hostname is required")), nil
|
||||
}
|
||||
|
||||
agentLabel := agentID
|
||||
if agentID == "" {
|
||||
if e.stateProvider == nil {
|
||||
return NewErrorResult(fmt.Errorf("state provider not available to resolve hostname")), nil
|
||||
}
|
||||
resolvedID, resolvedLabel := resolveAgentFromHostname(e.stateProvider.GetState(), hostname)
|
||||
if resolvedID == "" {
|
||||
return NewJSONResult(map[string]interface{}{
|
||||
"error": "not_found",
|
||||
"hostname": hostname,
|
||||
"message": fmt.Sprintf("No agent found for hostname '%s'", hostname),
|
||||
}), nil
|
||||
}
|
||||
agentID = resolvedID
|
||||
agentLabel = resolvedLabel
|
||||
} else if e.stateProvider != nil {
|
||||
if resolvedLabel := resolveAgentLabel(e.stateProvider.GetState(), agentID); resolvedLabel != "" {
|
||||
agentLabel = resolvedLabel
|
||||
}
|
||||
}
|
||||
|
||||
response := AgentScopeResponse{
|
||||
AgentID: agentID,
|
||||
AgentLabel: agentLabel,
|
||||
}
|
||||
|
||||
// Get profile info
|
||||
if e.agentProfileManager != nil {
|
||||
scope, err := e.agentProfileManager.GetAgentScope(ctx, agentID)
|
||||
if err != nil {
|
||||
return NewJSONResult(map[string]interface{}{
|
||||
"error": "failed_to_load",
|
||||
"agent_id": agentID,
|
||||
"agent_label": agentLabel,
|
||||
"message": fmt.Sprintf("Failed to load agent scope: %v", err),
|
||||
}), nil
|
||||
}
|
||||
if scope != nil {
|
||||
response.ProfileID = scope.ProfileID
|
||||
response.ProfileName = scope.ProfileName
|
||||
response.ProfileVersion = scope.ProfileVersion
|
||||
response.Settings = scope.Settings
|
||||
}
|
||||
}
|
||||
|
||||
// Get observed modules
|
||||
if e.stateProvider != nil {
|
||||
observed, commandsEnabled := detectAgentModules(e.stateProvider.GetState(), agentID)
|
||||
response.ObservedModules = observed
|
||||
response.CommandsEnabled = commandsEnabled
|
||||
}
|
||||
|
||||
return NewJSONResult(response), nil
|
||||
}
|
||||
|
||||
func (e *PulseToolExecutor) executeSetAgentScope(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||
if e.agentProfileManager == nil {
|
||||
return NewTextResult("Agent scope management is not available."), nil
|
||||
}
|
||||
|
||||
// Note: Control level check is now centralized in registry.Execute()
|
||||
|
||||
agentID, _ := args["agent_id"].(string)
|
||||
hostname, _ := args["hostname"].(string)
|
||||
profileID, _ := args["profile_id"].(string)
|
||||
|
||||
agentID = strings.TrimSpace(agentID)
|
||||
hostname = strings.TrimSpace(hostname)
|
||||
profileID = strings.TrimSpace(profileID)
|
||||
|
||||
settings := map[string]interface{}{}
|
||||
if rawSettings, ok := args["settings"].(map[string]interface{}); ok {
|
||||
for key, value := range rawSettings {
|
||||
if value != nil {
|
||||
settings[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if agentID == "" && hostname == "" {
|
||||
return NewErrorResult(fmt.Errorf("agent_id or hostname is required")), nil
|
||||
}
|
||||
|
||||
agentLabel := agentID
|
||||
if agentID == "" {
|
||||
if e.stateProvider == nil {
|
||||
return NewErrorResult(fmt.Errorf("state provider not available to resolve hostname")), nil
|
||||
}
|
||||
resolvedID, resolvedLabel := resolveAgentFromHostname(e.stateProvider.GetState(), hostname)
|
||||
if resolvedID == "" {
|
||||
return NewJSONResult(map[string]interface{}{
|
||||
"error": "not_found",
|
||||
"hostname": hostname,
|
||||
"message": fmt.Sprintf("No agent found for hostname '%s'", hostname),
|
||||
}), nil
|
||||
}
|
||||
agentID = resolvedID
|
||||
agentLabel = resolvedLabel
|
||||
} else if e.stateProvider != nil {
|
||||
if resolvedLabel := resolveAgentLabel(e.stateProvider.GetState(), agentID); resolvedLabel != "" {
|
||||
agentLabel = resolvedLabel
|
||||
}
|
||||
}
|
||||
|
||||
if profileID != "" && len(settings) > 0 {
|
||||
return NewErrorResult(fmt.Errorf("use either profile_id or settings, not both")), nil
|
||||
}
|
||||
|
||||
if profileID != "" {
|
||||
profileName, err := e.agentProfileManager.AssignProfile(ctx, agentID, profileID)
|
||||
if err != nil {
|
||||
return NewErrorResult(err), nil
|
||||
}
|
||||
return NewJSONResult(map[string]interface{}{
|
||||
"success": true,
|
||||
"action": "assigned",
|
||||
"agent_id": agentID,
|
||||
"agent_label": agentLabel,
|
||||
"profile_id": profileID,
|
||||
"profile_name": profileName,
|
||||
"message": fmt.Sprintf("Assigned profile '%s' to agent %s. Restart the agent to apply changes.", profileName, agentLabel),
|
||||
}), nil
|
||||
}
|
||||
|
||||
if len(settings) == 0 {
|
||||
return NewErrorResult(fmt.Errorf("settings are required when profile_id is not provided")), nil
|
||||
}
|
||||
|
||||
newProfileID, profileName, created, err := e.agentProfileManager.ApplyAgentScope(ctx, agentID, agentLabel, settings)
|
||||
if err != nil {
|
||||
return NewErrorResult(err), nil
|
||||
}
|
||||
|
||||
action := "updated"
|
||||
if created {
|
||||
action = "created"
|
||||
}
|
||||
|
||||
return NewJSONResult(map[string]interface{}{
|
||||
"success": true,
|
||||
"action": action,
|
||||
"agent_id": agentID,
|
||||
"agent_label": agentLabel,
|
||||
"profile_id": newProfileID,
|
||||
"profile_name": profileName,
|
||||
"settings": settings,
|
||||
"message": fmt.Sprintf("%s profile '%s' and assigned to agent %s. Restart the agent to apply changes.", strings.Title(action), profileName, agentLabel),
|
||||
}), nil
|
||||
}
|
||||
|
||||
// Helper functions for profile tools
|
||||
|
||||
func resolveAgentFromHostname(state models.StateSnapshot, hostname string) (string, string) {
|
||||
needle := strings.TrimSpace(hostname)
|
||||
if needle == "" {
|
||||
return "", ""
|
||||
}
|
||||
for _, host := range state.Hosts {
|
||||
if strings.EqualFold(host.Hostname, needle) || strings.EqualFold(host.DisplayName, needle) || strings.EqualFold(host.ID, needle) {
|
||||
label := firstNonEmpty(host.DisplayName, host.Hostname, host.ID)
|
||||
return host.ID, label
|
||||
}
|
||||
}
|
||||
for _, host := range state.DockerHosts {
|
||||
if strings.EqualFold(host.Hostname, needle) || strings.EqualFold(host.DisplayName, needle) || strings.EqualFold(host.CustomDisplayName, needle) || strings.EqualFold(host.ID, needle) {
|
||||
label := firstNonEmpty(host.CustomDisplayName, host.DisplayName, host.Hostname, host.ID)
|
||||
agentID := strings.TrimSpace(host.AgentID)
|
||||
if agentID == "" {
|
||||
agentID = host.ID
|
||||
}
|
||||
return agentID, label
|
||||
}
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func resolveAgentLabel(state models.StateSnapshot, agentID string) string {
|
||||
needle := strings.TrimSpace(agentID)
|
||||
if needle == "" {
|
||||
return ""
|
||||
}
|
||||
for _, host := range state.Hosts {
|
||||
if strings.EqualFold(host.ID, needle) {
|
||||
return firstNonEmpty(host.DisplayName, host.Hostname, host.ID)
|
||||
}
|
||||
}
|
||||
for _, host := range state.DockerHosts {
|
||||
if strings.EqualFold(host.AgentID, needle) || strings.EqualFold(host.ID, needle) {
|
||||
return firstNonEmpty(host.CustomDisplayName, host.DisplayName, host.Hostname, host.ID)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func formatSettingsSummary(settings map[string]interface{}) string {
|
||||
if len(settings) == 0 {
|
||||
return "none"
|
||||
}
|
||||
keys := make([]string, 0, len(settings))
|
||||
for key := range settings {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
parts = append(parts, fmt.Sprintf("%s=%v", key, settings[key]))
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
func detectAgentModules(state models.StateSnapshot, agentID string) ([]string, *bool) {
|
||||
agentID = strings.TrimSpace(agentID)
|
||||
if agentID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var modules []string
|
||||
var commandsEnabled *bool
|
||||
|
||||
for _, host := range state.Hosts {
|
||||
if strings.EqualFold(host.ID, agentID) {
|
||||
modules = append(modules, "host")
|
||||
val := host.CommandsEnabled
|
||||
commandsEnabled = &val
|
||||
if host.LinkedNodeID != "" {
|
||||
modules = append(modules, "proxmox")
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
for _, dockerHost := range state.DockerHosts {
|
||||
if strings.EqualFold(dockerHost.AgentID, agentID) || strings.EqualFold(dockerHost.ID, agentID) {
|
||||
modules = append(modules, "docker")
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
for _, cluster := range state.KubernetesClusters {
|
||||
if strings.EqualFold(cluster.AgentID, agentID) {
|
||||
modules = append(modules, "kubernetes")
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if len(modules) == 0 {
|
||||
return nil, commandsEnabled
|
||||
}
|
||||
|
||||
sort.Strings(modules)
|
||||
return modules, commandsEnabled
|
||||
}
|
||||
@@ -1,424 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||
)
|
||||
|
||||
type mockProfileManager struct {
|
||||
scope *AgentScope
|
||||
|
||||
getErr error
|
||||
assignErr error
|
||||
applyErr error
|
||||
|
||||
assignName string
|
||||
applyID string
|
||||
applyName string
|
||||
applyNew bool
|
||||
|
||||
lastGetAgent string
|
||||
lastAssignAgent string
|
||||
lastAssignProfile string
|
||||
lastApplyAgent string
|
||||
lastApplyLabel string
|
||||
lastApplySettings map[string]interface{}
|
||||
}
|
||||
|
||||
func (m *mockProfileManager) GetAgentScope(ctx context.Context, agentID string) (*AgentScope, error) {
|
||||
m.lastGetAgent = agentID
|
||||
if m.getErr != nil {
|
||||
return nil, m.getErr
|
||||
}
|
||||
return m.scope, nil
|
||||
}
|
||||
|
||||
func (m *mockProfileManager) AssignProfile(ctx context.Context, agentID, profileID string) (string, error) {
|
||||
m.lastAssignAgent = agentID
|
||||
m.lastAssignProfile = profileID
|
||||
if m.assignErr != nil {
|
||||
return "", m.assignErr
|
||||
}
|
||||
return m.assignName, nil
|
||||
}
|
||||
|
||||
func (m *mockProfileManager) ApplyAgentScope(ctx context.Context, agentID, agentLabel string, settings map[string]interface{}) (string, string, bool, error) {
|
||||
m.lastApplyAgent = agentID
|
||||
m.lastApplyLabel = agentLabel
|
||||
m.lastApplySettings = settings
|
||||
if m.applyErr != nil {
|
||||
return "", "", false, m.applyErr
|
||||
}
|
||||
return m.applyID, m.applyName, m.applyNew, nil
|
||||
}
|
||||
|
||||
func TestResolveAgentFromHostname(t *testing.T) {
|
||||
state := models.StateSnapshot{
|
||||
Hosts: []models.Host{
|
||||
{ID: "host-1", Hostname: "alpha", DisplayName: "Alpha"},
|
||||
},
|
||||
DockerHosts: []models.DockerHost{
|
||||
{ID: "dock-1", Hostname: "beta", DisplayName: "Beta", CustomDisplayName: "Beta Custom"},
|
||||
},
|
||||
}
|
||||
|
||||
id, label := resolveAgentFromHostname(state, "ALPHA")
|
||||
if id != "host-1" || label != "Alpha" {
|
||||
t.Fatalf("expected host match, got id=%q label=%q", id, label)
|
||||
}
|
||||
|
||||
id, label = resolveAgentFromHostname(state, "beta")
|
||||
if id != "dock-1" || label != "Beta Custom" {
|
||||
t.Fatalf("expected docker host match, got id=%q label=%q", id, label)
|
||||
}
|
||||
|
||||
id, label = resolveAgentFromHostname(state, "missing")
|
||||
if id != "" || label != "" {
|
||||
t.Fatalf("expected no match, got id=%q label=%q", id, label)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAgentLabel(t *testing.T) {
|
||||
state := models.StateSnapshot{
|
||||
Hosts: []models.Host{
|
||||
{ID: "host-1", Hostname: "alpha", DisplayName: "Alpha"},
|
||||
},
|
||||
DockerHosts: []models.DockerHost{
|
||||
{ID: "dock-2", AgentID: "agent-2", Hostname: "dock", DisplayName: "Dock", CustomDisplayName: "Docker Host"},
|
||||
},
|
||||
}
|
||||
|
||||
if label := resolveAgentLabel(state, "host-1"); label != "Alpha" {
|
||||
t.Fatalf("expected host label, got %q", label)
|
||||
}
|
||||
|
||||
if label := resolveAgentLabel(state, "agent-2"); label != "Docker Host" {
|
||||
t.Fatalf("expected docker label by agent ID, got %q", label)
|
||||
}
|
||||
|
||||
if label := resolveAgentLabel(state, "dock-2"); label != "Docker Host" {
|
||||
t.Fatalf("expected docker label by host ID, got %q", label)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatSettingsSummary(t *testing.T) {
|
||||
if summary := formatSettingsSummary(nil); summary != "none" {
|
||||
t.Fatalf("expected none for empty settings, got %q", summary)
|
||||
}
|
||||
|
||||
settings := map[string]interface{}{
|
||||
"beta": true,
|
||||
"alpha": 1,
|
||||
}
|
||||
summary := formatSettingsSummary(settings)
|
||||
if summary != "alpha=1, beta=true" {
|
||||
t.Fatalf("unexpected summary: %q", summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectAgentModules(t *testing.T) {
|
||||
state := models.StateSnapshot{
|
||||
Hosts: []models.Host{
|
||||
{ID: "agent-1", Hostname: "alpha", CommandsEnabled: true, LinkedNodeID: "node-1"},
|
||||
},
|
||||
DockerHosts: []models.DockerHost{
|
||||
{ID: "dock-1", AgentID: "agent-1", Hostname: "dock"},
|
||||
},
|
||||
KubernetesClusters: []models.KubernetesCluster{
|
||||
{ID: "k8s-1", AgentID: "agent-1", Name: "cluster"},
|
||||
},
|
||||
}
|
||||
|
||||
modules, commandsEnabled := detectAgentModules(state, "agent-1")
|
||||
expected := []string{"docker", "host", "kubernetes", "proxmox"}
|
||||
if !reflect.DeepEqual(modules, expected) {
|
||||
t.Fatalf("expected modules %v, got %v", expected, modules)
|
||||
}
|
||||
if commandsEnabled == nil || !*commandsEnabled {
|
||||
t.Fatalf("expected commandsEnabled true, got %v", commandsEnabled)
|
||||
}
|
||||
|
||||
modules, commandsEnabled = detectAgentModules(state, "missing")
|
||||
if modules != nil || commandsEnabled != nil {
|
||||
t.Fatalf("expected no matches, got modules=%v commandsEnabled=%v", modules, commandsEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteGetAgentScopeErrors(t *testing.T) {
|
||||
executor := NewPulseToolExecutor(ExecutorConfig{})
|
||||
|
||||
result, err := executor.executeGetAgentScope(context.Background(), map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !result.IsError {
|
||||
t.Fatal("expected error for missing agent args")
|
||||
}
|
||||
|
||||
result, err = executor.executeGetAgentScope(context.Background(), map[string]interface{}{
|
||||
"hostname": "alpha",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !result.IsError {
|
||||
t.Fatal("expected error when state provider missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteGetAgentScopeNotFound(t *testing.T) {
|
||||
stateProv := &mockStateProvider{}
|
||||
stateProv.On("GetState").Return(models.StateSnapshot{})
|
||||
executor := NewPulseToolExecutor(ExecutorConfig{
|
||||
StateProvider: stateProv,
|
||||
})
|
||||
|
||||
result, err := executor.executeGetAgentScope(context.Background(), map[string]interface{}{
|
||||
"hostname": "ghost",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result.IsError {
|
||||
t.Fatal("expected not_found response, got error result")
|
||||
}
|
||||
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(result.Content[0].Text), &payload); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if payload["error"] != "not_found" {
|
||||
t.Fatalf("expected not_found error, got %v", payload["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteGetAgentScopeProfileManagerError(t *testing.T) {
|
||||
manager := &mockProfileManager{
|
||||
getErr: errors.New("boom"),
|
||||
}
|
||||
stateProv := &mockStateProvider{}
|
||||
stateProv.On("GetState").Return(models.StateSnapshot{})
|
||||
executor := NewPulseToolExecutor(ExecutorConfig{
|
||||
StateProvider: stateProv,
|
||||
AgentProfileManager: manager,
|
||||
})
|
||||
|
||||
result, err := executor.executeGetAgentScope(context.Background(), map[string]interface{}{
|
||||
"agent_id": "agent-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result.IsError {
|
||||
t.Fatal("expected JSON error payload, got error result")
|
||||
}
|
||||
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(result.Content[0].Text), &payload); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if payload["error"] != "failed_to_load" {
|
||||
t.Fatalf("expected failed_to_load error, got %v", payload["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteGetAgentScopeWithProfile(t *testing.T) {
|
||||
state := models.StateSnapshot{
|
||||
Hosts: []models.Host{
|
||||
{ID: "agent-1", Hostname: "alpha", DisplayName: "Alpha", CommandsEnabled: true, LinkedNodeID: "node-1"},
|
||||
},
|
||||
DockerHosts: []models.DockerHost{
|
||||
{ID: "dock-1", AgentID: "agent-1", Hostname: "dock"},
|
||||
},
|
||||
KubernetesClusters: []models.KubernetesCluster{
|
||||
{ID: "k8s-1", AgentID: "agent-1", Name: "cluster"},
|
||||
},
|
||||
}
|
||||
manager := &mockProfileManager{
|
||||
scope: &AgentScope{
|
||||
AgentID: "agent-1",
|
||||
ProfileID: "profile-1",
|
||||
ProfileName: "Default",
|
||||
ProfileVersion: 2,
|
||||
Settings: map[string]interface{}{
|
||||
"enable_docker": true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
stateProv := &mockStateProvider{}
|
||||
stateProv.On("GetState").Return(state)
|
||||
executor := NewPulseToolExecutor(ExecutorConfig{
|
||||
StateProvider: stateProv,
|
||||
AgentProfileManager: manager,
|
||||
})
|
||||
|
||||
result, err := executor.executeGetAgentScope(context.Background(), map[string]interface{}{
|
||||
"agent_id": "agent-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result.IsError {
|
||||
t.Fatal("expected successful JSON response")
|
||||
}
|
||||
|
||||
var response AgentScopeResponse
|
||||
if err := json.Unmarshal([]byte(result.Content[0].Text), &response); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if response.ProfileID != "profile-1" || response.ProfileName != "Default" || response.ProfileVersion != 2 {
|
||||
t.Fatalf("unexpected profile info: %+v", response)
|
||||
}
|
||||
if response.AgentLabel != "Alpha" {
|
||||
t.Fatalf("expected agent label Alpha, got %q", response.AgentLabel)
|
||||
}
|
||||
if response.CommandsEnabled == nil || !*response.CommandsEnabled {
|
||||
t.Fatalf("expected commands enabled true, got %v", response.CommandsEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteSetAgentScopeErrors(t *testing.T) {
|
||||
executor := NewPulseToolExecutor(ExecutorConfig{})
|
||||
result, err := executor.executeSetAgentScope(context.Background(), map[string]interface{}{
|
||||
"agent_id": "agent-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result.IsError || !strings.Contains(result.Content[0].Text, "not available") {
|
||||
t.Fatalf("expected not available message, got %+v", result)
|
||||
}
|
||||
|
||||
executor = NewPulseToolExecutor(ExecutorConfig{
|
||||
AgentProfileManager: &mockProfileManager{},
|
||||
})
|
||||
result, err = executor.executeSetAgentScope(context.Background(), map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !result.IsError {
|
||||
t.Fatal("expected error for missing agent args")
|
||||
}
|
||||
|
||||
result, err = executor.executeSetAgentScope(context.Background(), map[string]interface{}{
|
||||
"agent_id": "agent-1",
|
||||
"profile_id": "profile-1",
|
||||
"settings": map[string]interface{}{
|
||||
"enable_host": true,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !result.IsError {
|
||||
t.Fatal("expected error for profile_id + settings")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteSetAgentScopeAssignProfile(t *testing.T) {
|
||||
manager := &mockProfileManager{
|
||||
assignName: "Gold",
|
||||
}
|
||||
executor := NewPulseToolExecutor(ExecutorConfig{
|
||||
ControlLevel: ControlLevelAutonomous,
|
||||
AgentProfileManager: manager,
|
||||
})
|
||||
|
||||
result, err := executor.executeSetAgentScope(context.Background(), map[string]interface{}{
|
||||
"agent_id": "agent-1",
|
||||
"profile_id": "profile-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result.IsError {
|
||||
t.Fatal("expected success response")
|
||||
}
|
||||
if manager.lastAssignAgent != "agent-1" || manager.lastAssignProfile != "profile-1" {
|
||||
t.Fatalf("assign not called with expected values: %+v", manager)
|
||||
}
|
||||
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(result.Content[0].Text), &payload); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if payload["action"] != "assigned" || payload["profile_name"] != "Gold" {
|
||||
t.Fatalf("unexpected payload: %v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteSetAgentScopeApplySettings(t *testing.T) {
|
||||
manager := &mockProfileManager{
|
||||
applyID: "profile-2",
|
||||
applyName: "Custom",
|
||||
applyNew: true,
|
||||
}
|
||||
executor := NewPulseToolExecutor(ExecutorConfig{
|
||||
ControlLevel: ControlLevelAutonomous,
|
||||
AgentProfileManager: manager,
|
||||
})
|
||||
|
||||
result, err := executor.executeSetAgentScope(context.Background(), map[string]interface{}{
|
||||
"agent_id": "agent-1",
|
||||
"settings": map[string]interface{}{
|
||||
"enable_host": true,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result.IsError {
|
||||
t.Fatal("expected success response")
|
||||
}
|
||||
if manager.lastApplyAgent != "agent-1" || manager.lastApplySettings == nil {
|
||||
t.Fatalf("apply not called with expected values: %+v", manager)
|
||||
}
|
||||
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(result.Content[0].Text), &payload); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if payload["action"] != "created" || payload["profile_name"] != "Custom" {
|
||||
t.Fatalf("unexpected payload: %v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteSetAgentScopeApplySettingsUpdated(t *testing.T) {
|
||||
manager := &mockProfileManager{
|
||||
applyID: "profile-3",
|
||||
applyName: "Existing",
|
||||
applyNew: false,
|
||||
}
|
||||
executor := NewPulseToolExecutor(ExecutorConfig{
|
||||
ControlLevel: ControlLevelAutonomous,
|
||||
AgentProfileManager: manager,
|
||||
})
|
||||
|
||||
result, err := executor.executeSetAgentScope(context.Background(), map[string]interface{}{
|
||||
"agent_id": "agent-1",
|
||||
"settings": map[string]interface{}{
|
||||
"enable_docker": true,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result.IsError {
|
||||
t.Fatal("expected success response")
|
||||
}
|
||||
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(result.Content[0].Text), &payload); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if payload["action"] != "updated" || payload["profile_name"] != "Existing" {
|
||||
t.Fatalf("unexpected payload: %v", payload)
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,10 @@ func (s *stubChatService) GetMessages(ctx context.Context, sessionID string) ([]
|
||||
return s.messages, nil
|
||||
}
|
||||
|
||||
func (s *stubChatService) ExecutePatrolStream(ctx context.Context, req ai.PatrolExecuteRequest, callback ai.ChatStreamCallback) (*ai.PatrolStreamResponse, error) {
|
||||
return &ai.PatrolStreamResponse{}, nil
|
||||
}
|
||||
|
||||
func (s *stubChatService) DeleteSession(ctx context.Context, sessionID string) error {
|
||||
return nil
|
||||
}
|
||||
@@ -547,17 +551,21 @@ func TestExecuteInvestigationFix_MCPTool(t *testing.T) {
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if resp["success"] != true {
|
||||
t.Fatalf("expected success response, got %v", resp["success"])
|
||||
|
||||
// The tool pulse_get_capabilities doesn't exist in the registry, so execution
|
||||
// fails gracefully. The handler still returns 200 OK with success=false and
|
||||
// records the outcome as fix_failed.
|
||||
if resp["success"] != false {
|
||||
t.Fatalf("expected success=false for unknown tool, got %v", resp["success"])
|
||||
}
|
||||
|
||||
updatedFinding := findings.Get(findingID)
|
||||
if updatedFinding == nil || updatedFinding.InvestigationOutcome != string(investigation.OutcomeFixExecuted) {
|
||||
if updatedFinding == nil || updatedFinding.InvestigationOutcome != string(investigation.OutcomeFixFailed) {
|
||||
t.Fatalf("unexpected finding outcome: %+v", updatedFinding)
|
||||
}
|
||||
|
||||
updatedSession := store.Get(session.ID)
|
||||
if updatedSession == nil || updatedSession.Outcome != investigation.OutcomeFixExecuted {
|
||||
if updatedSession == nil || updatedSession.Outcome != investigation.OutcomeFixFailed {
|
||||
t.Fatalf("unexpected investigation outcome: %+v", updatedSession)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user