mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
When a Patrol finding has a generated remediation plan, the plan's per-step Rollback strings are now aggregated into the durable InvestigationRecord.Rollback field at record-build time. Previously rollback metadata existed only nested inside RemediationStep.Rollback, forcing operator-facing surfaces and Assistant prompt context to walk into per-step payload to answer "what's the undo for the proposed fix?" Adds AggregatePlanRollbackSteps in internal/ai/investigation_records.go which deduplicates non-empty rollback strings from a plan's steps. Wires the aggregation into the patrol_findings.go investigation-record build site so rollback flows automatically when the PatrolService has a RemediationEngine attached and the finding has an active plan. Adds three sub-tests covering nil plan, empty-rollback-step skip, and duplicate-rollback dedup. Updates the ai-runtime contract to pin the RemediationPlan to InvestigationRecord rollback aggregation rule.
186 lines
6.5 KiB
Go
186 lines
6.5 KiB
Go
package ai
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/pkg/aicontracts"
|
|
)
|
|
|
|
func TestBuildFindingInvestigationRecord_FromSession(t *testing.T) {
|
|
detectedAt := time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC)
|
|
completedAt := detectedAt.Add(3 * time.Minute)
|
|
finding := &Finding{
|
|
ID: "finding-1",
|
|
Key: "cpu-high",
|
|
Severity: FindingSeverityCritical,
|
|
Category: FindingCategoryPerformance,
|
|
ResourceID: "vm-100",
|
|
ResourceName: "web-server",
|
|
ResourceType: "vm",
|
|
Node: "pve-1",
|
|
Title: "High CPU",
|
|
Description: "CPU above threshold",
|
|
Impact: "Workload stalls until CPU pressure clears.",
|
|
Recommendation: "Reduce CPU pressure",
|
|
Evidence: "cpu=96%",
|
|
Source: "ai-analysis",
|
|
FailureCause: string(PatrolFailureCauseModelUnsupportedTools),
|
|
DetectedAt: detectedAt,
|
|
InvestigationSessionID: "chat-1",
|
|
InvestigationStatus: string(InvestigationStatusCompleted),
|
|
InvestigationOutcome: string(InvestigationOutcomeFixQueued),
|
|
LastInvestigatedAt: &completedAt,
|
|
}
|
|
session := &InvestigationSession{
|
|
ID: "investigation-1",
|
|
FindingID: "finding-1",
|
|
SessionID: "chat-1",
|
|
Status: aicontracts.InvestigationStatusCompleted,
|
|
StartedAt: detectedAt.Add(time.Minute),
|
|
CompletedAt: &completedAt,
|
|
Outcome: aicontracts.OutcomeFixQueued,
|
|
ToolsUsed: []string{"ssh.exec", "ssh.exec", " "},
|
|
EvidenceIDs: []string{"evidence-1"},
|
|
Summary: "Postgres was consuming CPU.",
|
|
ApprovalID: "approval-1",
|
|
ProposedFix: &aicontracts.Fix{
|
|
ID: "fix-1",
|
|
Description: "Restart postgres",
|
|
Commands: []string{"systemctl restart postgres"},
|
|
RiskLevel: "medium",
|
|
TargetHost: "pve-1",
|
|
Rationale: "Service is stuck in a busy loop",
|
|
},
|
|
}
|
|
|
|
record := BuildFindingInvestigationRecord(finding, session)
|
|
if record == nil {
|
|
t.Fatal("expected investigation record")
|
|
}
|
|
if record.ID != "investigation-1" {
|
|
t.Fatalf("record ID = %q, want investigation-1", record.ID)
|
|
}
|
|
if record.Subject.ResourceID != "vm-100" || record.Subject.Node != "pve-1" {
|
|
t.Fatalf("unexpected subject: %#v", record.Subject)
|
|
}
|
|
if record.Trigger.FindingKey != "cpu-high" || record.Trigger.Title != "High CPU" {
|
|
t.Fatalf("unexpected trigger: %#v", record.Trigger)
|
|
}
|
|
if record.Trigger.Cause != string(PatrolFailureCauseModelUnsupportedTools) {
|
|
t.Fatalf("trigger cause = %q", record.Trigger.Cause)
|
|
}
|
|
if record.Conclusion != "Postgres was consuming CPU." {
|
|
t.Fatalf("conclusion = %q", record.Conclusion)
|
|
}
|
|
if record.RecommendedAction != "Reduce CPU pressure" {
|
|
t.Fatalf("recommended action = %q", record.RecommendedAction)
|
|
}
|
|
if record.Confidence != aicontracts.InvestigationRecordConfidenceMedium {
|
|
t.Fatalf("confidence = %q", record.Confidence)
|
|
}
|
|
if len(record.Evidence) != 2 {
|
|
t.Fatalf("expected finding and investigation evidence, got %#v", record.Evidence)
|
|
}
|
|
if len(record.ToolsUsed) != 1 || record.ToolsUsed[0] != "ssh.exec" {
|
|
t.Fatalf("tools used not normalized: %#v", record.ToolsUsed)
|
|
}
|
|
if record.ProposedFix == nil || record.ProposedFix.RiskLevel != "medium" {
|
|
t.Fatalf("expected proposed fix, got %#v", record.ProposedFix)
|
|
}
|
|
if record.ApprovalID != "approval-1" {
|
|
t.Fatalf("approval ID = %q", record.ApprovalID)
|
|
}
|
|
if record.Impact != "Workload stalls until CPU pressure clears." {
|
|
t.Fatalf("expected finding impact to propagate to record, got %q", record.Impact)
|
|
}
|
|
if record.Rollback == nil || len(record.Rollback) != 0 {
|
|
t.Fatalf("expected normalized empty rollback slice, got %#v", record.Rollback)
|
|
}
|
|
}
|
|
|
|
func TestEmptyInvestigationRecord_NormalizesRollback(t *testing.T) {
|
|
record := aicontracts.EmptyInvestigationRecord()
|
|
if record.Rollback == nil || len(record.Rollback) != 0 {
|
|
t.Fatalf("expected empty rollback slice on empty record, got %#v", record.Rollback)
|
|
}
|
|
}
|
|
|
|
func TestAggregatePlanRollbackSteps(t *testing.T) {
|
|
t.Run("nil plan returns empty slice", func(t *testing.T) {
|
|
got := AggregatePlanRollbackSteps(nil)
|
|
if got == nil || len(got) != 0 {
|
|
t.Fatalf("expected empty slice for nil plan, got %#v", got)
|
|
}
|
|
})
|
|
|
|
t.Run("aggregates rollback strings from steps and drops empties", func(t *testing.T) {
|
|
plan := &aicontracts.RemediationPlan{
|
|
Steps: []aicontracts.RemediationStep{
|
|
{Order: 1, Description: "Restart service", Rollback: "systemctl stop unit && restore prior config"},
|
|
{Order: 2, Description: "Reseat datastore", Rollback: ""}, // empty rollback skipped
|
|
{Order: 3, Description: "Re-enable cron", Rollback: "Disable cron and revert maintenance window"},
|
|
},
|
|
}
|
|
got := AggregatePlanRollbackSteps(plan)
|
|
want := []string{
|
|
"systemctl stop unit && restore prior config",
|
|
"Disable cron and revert maintenance window",
|
|
}
|
|
if len(got) != len(want) {
|
|
t.Fatalf("expected %d rollback steps, got %d (%#v)", len(want), len(got), got)
|
|
}
|
|
for i, w := range want {
|
|
if got[i] != w {
|
|
t.Fatalf("step %d: expected %q, got %q", i, w, got[i])
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("deduplicates identical rollback strings", func(t *testing.T) {
|
|
plan := &aicontracts.RemediationPlan{
|
|
Steps: []aicontracts.RemediationStep{
|
|
{Order: 1, Rollback: "rollback-a"},
|
|
{Order: 2, Rollback: "rollback-a"}, // duplicate
|
|
{Order: 3, Rollback: "rollback-b"},
|
|
},
|
|
}
|
|
got := AggregatePlanRollbackSteps(plan)
|
|
if len(got) != 2 {
|
|
t.Fatalf("expected 2 unique rollback steps, got %#v", got)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestFindingsStore_UpdateInvestigationRecord(t *testing.T) {
|
|
store := NewFindingsStore()
|
|
store.Add(&Finding{
|
|
ID: "finding-1",
|
|
Severity: FindingSeverityWarning,
|
|
Category: FindingCategoryPerformance,
|
|
ResourceID: "vm-100",
|
|
Title: "High CPU",
|
|
DetectedAt: time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC),
|
|
})
|
|
|
|
record := &aicontracts.InvestigationRecord{
|
|
ID: "investigation-1",
|
|
FindingID: "finding-1",
|
|
Status: aicontracts.InvestigationStatusCompleted,
|
|
Outcome: aicontracts.OutcomeFixVerified,
|
|
Evidence: []aicontracts.InvestigationRecordEvidence{},
|
|
}
|
|
if !store.UpdateInvestigationRecord("finding-1", record) {
|
|
t.Fatal("UpdateInvestigationRecord failed")
|
|
}
|
|
updated := store.Get("finding-1")
|
|
if updated == nil || updated.InvestigationRecord == nil {
|
|
t.Fatalf("expected persisted investigation record, got %#v", updated)
|
|
}
|
|
if updated.InvestigationRecord.ID != "investigation-1" {
|
|
t.Fatalf("record ID = %q", updated.InvestigationRecord.ID)
|
|
}
|
|
if store.UpdateInvestigationRecord("missing", record) {
|
|
t.Fatal("expected false for missing finding")
|
|
}
|
|
}
|