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:
rcourtman
2026-01-28 21:30:24 +00:00
parent e227314d76
commit 9c2f8a3284
8 changed files with 12 additions and 5354 deletions
-468
View File
@@ -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
}
-272
View File
@@ -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
-428
View File
@@ -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
}
-769
View File
@@ -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
}
-344
View File
@@ -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
}
-424
View File
@@ -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)
}
}