Add structured Patrol investigation records

This commit is contained in:
rcourtman 2026-05-06 16:31:51 +01:00
parent cdd977a1c1
commit 75e3cb76fd
28 changed files with 932 additions and 121 deletions

View file

@ -349,6 +349,11 @@ profile and assignment columns, but embedded table framing must route through
5. Keep release-grade updater trust fail-closed across `internal/agentupdate/`, `internal/dockeragent/`, and the shared `internal/api/unified_agent.go` download helpers. When release builds embed trusted update signing keys, published agent binaries and installer assets must carry detached `.sig` plus `.sshsig` sidecars; updater/runtime paths must require `X-Signature-Ed25519` in addition to `X-Checksum-Sha256`, and installer-owned download flows must require the matching base64-encoded `X-Signature-SSHSIG`, instead of silently downgrading to checksum-only trust.
6. Keep shared `internal/api/` helper edits isolated from agent lifecycle semantics: Patrol-specific status transport or alert-trigger wiring changes in shared handlers must not bleed into auto-register, installer, or fleet-control behavior unless this contract moves in the same slice.
The same isolation rule applies to AI settings payload work in `internal/api/ai_handlers.go`: provider auth fields, masked-secret echoes, and provider-test model selection remain AI/runtime plus API-contract ownership and must not be reinterpreted as lifecycle setup or registration semantics just because they share backend helper layers.
The same isolation rule applies to Patrol investigation-record propagation
through shared AI intelligence handlers and `internal/api/router.go`:
lifecycle surfaces may observe the resulting resource context, but they must
not reinterpret `investigation_record` as agent enrollment, installer,
command policy, or fleet-control authority.
The same isolation rule applies to CSRF token-store behavior in
`internal/api/csrf_store.go`: lifecycle-adjacent browser flows may rely on
the shared API/security layer to keep parallel replacement-token retries

View file

@ -41,6 +41,7 @@ runtime cost control, and shared AI transport surfaces.
19. `frontend-modern/src/stores/aiRuntimeState.ts`
20. `frontend-modern/src/stores/aiChat.ts`
21. `docs/AI.md`
22. `pkg/aicontracts/investigation.go`
## Shared Boundaries
@ -167,6 +168,12 @@ runtime cost control, and shared AI transport surfaces.
accept providers that omit `[DONE]` only after a terminal `finish_reason`,
but it must not emit `done` or executable tool calls from partial tool-call
builders when the stream closes before that terminal provider state.
16. Keep Patrol investigations product-facing through the shared
`aicontracts.InvestigationRecord` contract. Patrol may keep
`InvestigationSession` as execution detail, but Assistant handoff,
unified findings, persistence, and approval/remediation context must use
the durable investigation record when they need operator-facing
investigation context.
## Current State

View file

@ -97,6 +97,10 @@ product API routes free of maintainer commercial analytics.
63. `internal/api/availability_handlers.go`
64. `frontend-modern/src/api/availabilityTargets.ts`
65. `frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/AvailabilityTargetSlot.tsx`
66. `pkg/aicontracts/investigation.go`
67. `internal/api/ai_intelligence_handlers.go`
68. `frontend-modern/src/api/ai.ts`
69. `frontend-modern/src/api/patrol.ts`
## Shared Boundaries
@ -268,7 +272,6 @@ product API routes free of maintainer commercial analytics.
50. `internal/api/system_settings.go` shared with `security-privacy`: the system settings telemetry and auth controls are both a security/privacy control surface and a canonical API payload contract boundary.
51. `internal/api/unified_agent.go` shared with `agent-lifecycle`: unified agent download and installer handlers are both an agent lifecycle control surface and a canonical API payload contract boundary.
52. `internal/api/updates.go` shared with `deployment-installability`: update handlers are both a deployment-installability control surface and a canonical API payload contract boundary.
The platform-connections API contract also owns inactive monitored-system
candidate semantics end to end. `enabled=false` on TrueNAS or VMware preview,
test, add, and update payloads must serialize through the shared ledger client
@ -737,6 +740,10 @@ the canonical monitored-system blocked payload.
generic hosted AI quota, anonymous Community entitlement, trial CTA,
account-backed activation support, or full-chat entitlement in normal
self-hosted v6 GA UI
and the structured investigation-record contract, so unified findings may
expose `investigation_record` only through the shared
`aicontracts.InvestigationRecord` payload shape, with frontend API types
and backend contract tests updated in the same slice as any field change.
7. Keep Patrol summary payload consumers aligned on one assessment hierarchy: transport-driven Patrol summary surfaces may show supporting counts and outcomes, but the canonical assessment and verification states must remain singular and not be repeated as a second compact verdict strip
8. Keep Patrol verification and activity facts unified on one transport-backed secondary status area: when frontend consumers combine Patrol status payloads (`runtime_state`, `last_patrol_at`, `last_activity_at`, `trigger_status`) with run-history transport, the latest run result, activity mix, scoped-trigger state, and circuit-breaker context must read as one supporting explanation beneath the primary assessment instead of being re-expanded into a separate full-width status strip plus duplicate summary layers
and the main Patrol page composition boundary, so once that governed

View file

@ -94,6 +94,11 @@ Patrol-specific presentation helpers.
Pro-locked helper text, investigation outcome labels, and run-history badges
must present the paid capability as safe remediation or remediation actions,
not as a broad automation promise.
8. Keep the Patrol store aligned with the shared structured investigation
record when transport carries one. `frontend-modern/src/stores/aiIntelligence.ts`
may retain `investigationRecord` as data for Assistant handoff and future
Patrol presentation, but visible Patrol copy must still flow through the
governed Patrol presentation helpers before the record becomes UI.
## Current State

View file

@ -170,6 +170,10 @@ regression protection.
already-authenticated request context or caller token and must not add
monitor scans, persistence reads, or other broad hot-path work before the
route-local handler owns the mutation.
Patrol investigation-record fan-out through shared router callbacks follows
the same bounded-work rule: the callback may copy the already-materialized
durable record into unified findings, but it must not add broad resource
scans, model calls, or persistence walks to protected request setup paths.
Retiring self-hosted trial acquisition follows that same rule: removing
`/auth/trial-activate` and `POST /api/license/trial/start` from public-path
and CSRF inventories must stay as constant-time route-table absence rather

View file

@ -404,6 +404,11 @@ bypass the API fail-closed execution gate.
shared settings helpers, and they must not treat vendor model IDs or
quickstart upstream-model defaults as part of storage/recovery transport
ownership or route behavior.
Structured Patrol investigation records follow that same adjacent-boundary
rule. Storage and recovery surfaces may consume the resource context in a
shared `investigation_record`, but they must not reinterpret that record as
recovery freshness, restore support, backup cadence, or storage-local
action authority.
That same adjacent `internal/api/` boundary still carries Patrol-run
execution identity. Storage and recovery may observe shared Patrol
transport through `internal/api/chat_service_adapter.go`, but they must not

View file

@ -69,6 +69,17 @@ describe('AIAPI', () => {
description: 'high',
detected_at: '2026-03-01T00:00:00Z',
alert_identifier: 'canonical-alert-1',
investigation_record: {
id: 'investigation-1',
finding_id: 'f1',
subject: { resource_id: 'r1' },
trigger: { detected_at: '2026-03-01T00:00:00Z' },
status: 'completed',
evidence: [],
verification: [],
tools_used: [],
started_at: '2026-03-01T00:00:00Z',
},
},
],
count: 1,
@ -78,6 +89,10 @@ describe('AIAPI', () => {
expect(result.findings[0]).toMatchObject({
alertIdentifier: 'canonical-alert-1',
investigation_record: {
id: 'investigation-1',
finding_id: 'f1',
},
});
});

View file

@ -353,6 +353,60 @@ export class AIAPI {
// Phase 7 Type Definitions
// ============================================
export interface InvestigationRecordSubject {
resource_id: string;
resource_name?: string;
resource_type?: string;
node?: string;
}
export interface InvestigationRecordTrigger {
finding_key?: string;
source?: string;
severity?: string;
category?: string;
title?: string;
detected_at: string;
description?: string;
}
export interface InvestigationRecordEvidence {
id?: string;
kind: string;
summary?: string;
}
export interface InvestigationRecordFix {
id: string;
description: string;
commands?: string[];
risk_level?: string;
destructive: boolean;
target_host?: string;
rationale?: string;
}
export interface InvestigationRecord {
id: string;
finding_id: string;
session_id?: string;
subject: InvestigationRecordSubject;
trigger: InvestigationRecordTrigger;
status: string;
outcome?: string;
confidence?: 'low' | 'medium' | 'high';
evidence: InvestigationRecordEvidence[];
conclusion?: string;
recommended_action?: string;
proposed_fix?: InvestigationRecordFix;
verification: string[];
tools_used: string[];
started_at: string;
completed_at?: string;
approval_id?: string;
error?: string;
}
export interface UnifiedFindingRecord {
id: string;
source: string;
@ -384,6 +438,7 @@ export interface UnifiedFindingRecord {
investigation_outcome?: string;
last_investigated_at?: string;
investigation_attempts?: number;
investigation_record?: InvestigationRecord;
loop_state?: string;
lifecycle?: Array<{
at: string;

View file

@ -5,6 +5,7 @@
import { apiFetchJSON } from '@/utils/apiClient';
import { arrayOrEmpty, promoteLegacyAlertIdentifier } from './responseUtils';
import type { InvestigationRecord } from './ai';
export type FindingSeverity = 'info' | 'watch' | 'warning' | 'critical';
export type FindingCategory =
@ -46,6 +47,7 @@ export interface Finding {
investigation_outcome?: InvestigationOutcome;
last_investigated_at?: string;
investigation_attempts: number;
investigation_record?: InvestigationRecord;
}
export type InvestigationStatus =

View file

@ -61,6 +61,26 @@ describe('aiIntelligenceStore', () => {
detected_at: '2026-03-01T00:00:00Z',
last_seen_at: '2026-03-05T00:00:00Z',
alertIdentifier: 'instance:node:100::metric/cpu',
investigation_record: {
id: 'investigation-1',
finding_id: 'finding-1',
subject: {
resource_id: 'instance:node:100',
resource_name: 'vm-100',
resource_type: 'vm',
},
trigger: {
detected_at: '2026-03-01T00:00:00Z',
title: 'CPU high',
},
status: 'completed',
outcome: 'fix_queued',
confidence: 'medium',
evidence: [],
verification: [],
tools_used: [],
started_at: '2026-03-05T00:00:00Z',
},
},
],
count: 1,
@ -72,6 +92,10 @@ describe('aiIntelligenceStore', () => {
expect(aiIntelligenceStore.findings[0]).toMatchObject({
alertIdentifier: 'instance:node:100::metric/cpu',
lastSeenAt: '2026-03-05T00:00:00Z',
investigationRecord: {
id: 'investigation-1',
finding_id: 'finding-1',
},
});
});

View file

@ -15,6 +15,7 @@ import type {
RemediationPlan,
CircuitBreakerStatus,
UnifiedFindingRecord,
InvestigationRecord,
ApprovalRequest,
ApprovalExecutionResult,
ApprovalDecisionResult,
@ -137,6 +138,7 @@ export interface UnifiedFinding {
| 'fix_verification_unknown';
lastInvestigatedAt?: string;
investigationAttempts?: number;
investigationRecord?: InvestigationRecord;
loopState?: string;
lifecycle?: Array<{
at: string;
@ -313,6 +315,7 @@ export const aiIntelligenceStore = {
investigationOutcome: validateInvestigationOutcome(item.investigation_outcome),
lastInvestigatedAt: item.last_investigated_at || undefined,
investigationAttempts: item.investigation_attempts || 0,
investigationRecord: item.investigation_record,
loopState: item.loop_state || undefined,
lifecycle: item.lifecycle || [],
regressionCount: item.regression_count || 0,

View file

@ -7,6 +7,8 @@ import (
"strings"
"sync"
"time"
"github.com/rcourtman/pulse-go-rewrite/pkg/aicontracts"
)
// Investigation limits for automatic finding investigation.
@ -133,52 +135,54 @@ type Finding struct {
Suppressed bool `json:"suppressed"` // Permanently suppress similar findings for this resource
// Investigation fields - tracks autonomous AI investigation of findings
InvestigationSessionID string `json:"investigation_session_id,omitempty"` // Chat session ID if being investigated
InvestigationStatus string `json:"investigation_status,omitempty"` // pending, running, completed, failed, needs_attention
InvestigationOutcome string `json:"investigation_outcome,omitempty"` // resolved, fix_queued, fix_executed, fix_failed, fix_verified, fix_verification_failed, needs_attention, cannot_fix
LastInvestigatedAt *time.Time `json:"last_investigated_at,omitempty"` // When last investigation completed
InvestigationAttempts int `json:"investigation_attempts"` // Number of investigation attempts
LoopState string `json:"loop_state,omitempty"` // detected, investigating, remediating, resolved, etc.
Lifecycle []FindingLifecycleEvent `json:"lifecycle,omitempty"` // Bounded, append-only lifecycle log
RegressionCount int `json:"regression_count,omitempty"` // Times the issue reappeared after resolution
LastRegressionAt *time.Time `json:"last_regression_at,omitempty"` // Timestamp of most recent regression
InvestigationSessionID string `json:"investigation_session_id,omitempty"` // Chat session ID if being investigated
InvestigationStatus string `json:"investigation_status,omitempty"` // pending, running, completed, failed, needs_attention
InvestigationOutcome string `json:"investigation_outcome,omitempty"` // resolved, fix_queued, fix_executed, fix_failed, fix_verified, fix_verification_failed, needs_attention, cannot_fix
LastInvestigatedAt *time.Time `json:"last_investigated_at,omitempty"` // When last investigation completed
InvestigationAttempts int `json:"investigation_attempts"` // Number of investigation attempts
InvestigationRecord *aicontracts.InvestigationRecord `json:"investigation_record,omitempty"` // Durable contextual summary for Assistant/unified intelligence
LoopState string `json:"loop_state,omitempty"` // detected, investigating, remediating, resolved, etc.
Lifecycle []FindingLifecycleEvent `json:"lifecycle,omitempty"` // Bounded, append-only lifecycle log
RegressionCount int `json:"regression_count,omitempty"` // Times the issue reappeared after resolution
LastRegressionAt *time.Time `json:"last_regression_at,omitempty"` // Timestamp of most recent regression
}
type findingJSON struct {
ID string `json:"id"`
Key string `json:"key,omitempty"`
Severity FindingSeverity `json:"severity"`
Category FindingCategory `json:"category"`
ResourceID string `json:"resource_id"`
ResourceName string `json:"resource_name"`
ResourceType string `json:"resource_type"`
Node string `json:"node,omitempty"`
Title string `json:"title"`
Description string `json:"description"`
Recommendation string `json:"recommendation,omitempty"`
Evidence string `json:"evidence,omitempty"`
Source string `json:"source,omitempty"`
DetectedAt time.Time `json:"detected_at"`
LastSeenAt time.Time `json:"last_seen_at"`
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
AutoResolved bool `json:"auto_resolved"`
ResolveReason string `json:"resolve_reason,omitempty"`
AcknowledgedAt *time.Time `json:"acknowledged_at,omitempty"`
SnoozedUntil *time.Time `json:"snoozed_until,omitempty"`
AlertIdentifier string `json:"alert_identifier,omitempty"`
DismissedReason string `json:"dismissed_reason,omitempty"`
UserNote string `json:"user_note,omitempty"`
TimesRaised int `json:"times_raised"`
Suppressed bool `json:"suppressed"`
InvestigationSessionID string `json:"investigation_session_id,omitempty"`
InvestigationStatus string `json:"investigation_status,omitempty"`
InvestigationOutcome string `json:"investigation_outcome,omitempty"`
LastInvestigatedAt *time.Time `json:"last_investigated_at,omitempty"`
InvestigationAttempts int `json:"investigation_attempts"`
LoopState string `json:"loop_state,omitempty"`
Lifecycle []FindingLifecycleEvent `json:"lifecycle,omitempty"`
RegressionCount int `json:"regression_count,omitempty"`
LastRegressionAt *time.Time `json:"last_regression_at,omitempty"`
ID string `json:"id"`
Key string `json:"key,omitempty"`
Severity FindingSeverity `json:"severity"`
Category FindingCategory `json:"category"`
ResourceID string `json:"resource_id"`
ResourceName string `json:"resource_name"`
ResourceType string `json:"resource_type"`
Node string `json:"node,omitempty"`
Title string `json:"title"`
Description string `json:"description"`
Recommendation string `json:"recommendation,omitempty"`
Evidence string `json:"evidence,omitempty"`
Source string `json:"source,omitempty"`
DetectedAt time.Time `json:"detected_at"`
LastSeenAt time.Time `json:"last_seen_at"`
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
AutoResolved bool `json:"auto_resolved"`
ResolveReason string `json:"resolve_reason,omitempty"`
AcknowledgedAt *time.Time `json:"acknowledged_at,omitempty"`
SnoozedUntil *time.Time `json:"snoozed_until,omitempty"`
AlertIdentifier string `json:"alert_identifier,omitempty"`
DismissedReason string `json:"dismissed_reason,omitempty"`
UserNote string `json:"user_note,omitempty"`
TimesRaised int `json:"times_raised"`
Suppressed bool `json:"suppressed"`
InvestigationSessionID string `json:"investigation_session_id,omitempty"`
InvestigationStatus string `json:"investigation_status,omitempty"`
InvestigationOutcome string `json:"investigation_outcome,omitempty"`
LastInvestigatedAt *time.Time `json:"last_investigated_at,omitempty"`
InvestigationAttempts int `json:"investigation_attempts"`
InvestigationRecord *aicontracts.InvestigationRecord `json:"investigation_record,omitempty"`
LoopState string `json:"loop_state,omitempty"`
Lifecycle []FindingLifecycleEvent `json:"lifecycle,omitempty"`
RegressionCount int `json:"regression_count,omitempty"`
LastRegressionAt *time.Time `json:"last_regression_at,omitempty"`
}
func (f Finding) MarshalJSON() ([]byte, error) {
@ -214,6 +218,7 @@ func (f Finding) MarshalJSON() ([]byte, error) {
InvestigationOutcome: f.InvestigationOutcome,
LastInvestigatedAt: f.LastInvestigatedAt,
InvestigationAttempts: f.InvestigationAttempts,
InvestigationRecord: f.InvestigationRecord,
LoopState: f.LoopState,
Lifecycle: f.Lifecycle,
RegressionCount: f.RegressionCount,
@ -258,6 +263,7 @@ func (f *Finding) UnmarshalJSON(data []byte) error {
InvestigationOutcome: payload.InvestigationOutcome,
LastInvestigatedAt: payload.LastInvestigatedAt,
InvestigationAttempts: payload.InvestigationAttempts,
InvestigationRecord: payload.InvestigationRecord,
LoopState: payload.LoopState,
Lifecycle: payload.Lifecycle,
RegressionCount: payload.RegressionCount,
@ -991,6 +997,7 @@ func (s *FindingsStore) Add(f *Finding) bool {
existing.InvestigationOutcome = ""
existing.LastInvestigatedAt = nil
existing.InvestigationAttempts = 0
existing.InvestigationRecord = nil
existing.RegressionCount++
now := time.Now()
existing.LastRegressionAt = &now
@ -1260,6 +1267,29 @@ func (s *FindingsStore) Undismiss(id string) bool {
return true
}
// UpdateInvestigationRecord stores the durable investigation record for a finding.
func (s *FindingsStore) UpdateInvestigationRecord(id string, record *aicontracts.InvestigationRecord) bool {
s.mu.Lock()
f, exists := s.findings[id]
if !exists {
s.mu.Unlock()
return false
}
f.InvestigationRecord = record
meta := map[string]string{}
if record != nil {
meta["record_id"] = record.ID
meta["status"] = string(record.Status)
meta["outcome"] = string(record.Outcome)
}
s.appendLifecycleLocked(f, "investigation_record_updated", "Investigation record updated", f.LoopState, f.LoopState, meta)
s.mu.Unlock()
s.scheduleSave()
return true
}
// SetUserNote updates the user note on a finding
func (s *FindingsStore) SetUserNote(id, note string) bool {
s.mu.Lock()

View file

@ -68,6 +68,7 @@ func findingsToRecords(findings map[string]*Finding) map[string]*config.AIFindin
InvestigationOutcome: f.InvestigationOutcome,
LastInvestigatedAt: f.LastInvestigatedAt,
InvestigationAttempts: f.InvestigationAttempts,
InvestigationRecord: f.InvestigationRecord,
LoopState: f.LoopState,
Lifecycle: lifecycle,
RegressionCount: f.RegressionCount,
@ -124,6 +125,7 @@ func recordsToFindings(records map[string]*config.AIFindingRecord) map[string]*F
InvestigationOutcome: r.InvestigationOutcome,
LastInvestigatedAt: r.LastInvestigatedAt,
InvestigationAttempts: r.InvestigationAttempts,
InvestigationRecord: r.InvestigationRecord,
LoopState: r.LoopState,
Lifecycle: lifecycle,
RegressionCount: r.RegressionCount,

View file

@ -0,0 +1,228 @@
package ai
import (
"fmt"
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/pkg/aicontracts"
)
// BuildFindingInvestigationRecord converts the latest finding state and the
// latest investigation session into the durable record used by product-facing
// intelligence surfaces.
func BuildFindingInvestigationRecord(f *Finding, session *InvestigationSession) *aicontracts.InvestigationRecord {
if f == nil {
return nil
}
record := &aicontracts.InvestigationRecord{
ID: fallbackInvestigationRecordID(f.ID, ""),
FindingID: strings.TrimSpace(f.ID),
SessionID: strings.TrimSpace(f.InvestigationSessionID),
Subject: aicontracts.InvestigationRecordSubject{
ResourceID: strings.TrimSpace(f.ResourceID),
ResourceName: strings.TrimSpace(f.ResourceName),
ResourceType: strings.TrimSpace(f.ResourceType),
Node: strings.TrimSpace(f.Node),
},
Trigger: aicontracts.InvestigationRecordTrigger{
FindingKey: strings.TrimSpace(f.Key),
Source: strings.TrimSpace(f.Source),
Severity: strings.TrimSpace(string(f.Severity)),
Category: strings.TrimSpace(string(f.Category)),
Title: strings.TrimSpace(f.Title),
DetectedAt: f.DetectedAt,
Description: strings.TrimSpace(f.Description),
},
Status: aicontracts.InvestigationStatus(strings.TrimSpace(f.InvestigationStatus)),
Outcome: aicontracts.InvestigationOutcome(strings.TrimSpace(f.InvestigationOutcome)),
Evidence: investigationRecordEvidenceForFinding(f),
Conclusion: strings.TrimSpace(f.Description),
RecommendedAction: strings.TrimSpace(f.Recommendation),
Verification: investigationRecordVerificationNotes(aicontracts.InvestigationOutcome(strings.TrimSpace(f.InvestigationOutcome))),
ToolsUsed: []string{},
}
if session != nil {
normalized := session.NormalizeCollections()
record.ID = fallbackInvestigationRecordID(f.ID, normalized.ID)
record.SessionID = firstNonEmpty(normalized.SessionID, f.InvestigationSessionID, normalized.ID)
if normalized.FindingID != "" {
record.FindingID = strings.TrimSpace(normalized.FindingID)
}
if normalized.Status != "" {
record.Status = normalized.Status
}
if normalized.Outcome != "" {
record.Outcome = normalized.Outcome
}
record.StartedAt = normalized.StartedAt
record.CompletedAt = normalized.CompletedAt
record.ToolsUsed = uniqueNonEmptyStrings(normalized.ToolsUsed)
record.ApprovalID = strings.TrimSpace(normalized.ApprovalID)
record.Error = strings.TrimSpace(normalized.Error)
if summary := strings.TrimSpace(normalized.Summary); summary != "" {
record.Conclusion = summary
}
record.Evidence = append(record.Evidence, investigationRecordEvidenceIDs(normalized.EvidenceIDs)...)
if normalized.ProposedFix != nil {
record.ProposedFix = investigationRecordFixFromSession(normalized.ProposedFix)
if record.RecommendedAction == "" {
record.RecommendedAction = strings.TrimSpace(normalized.ProposedFix.Description)
}
}
record.Verification = investigationRecordVerificationNotes(record.Outcome)
}
if record.StartedAt.IsZero() {
record.StartedAt = firstNonZeroTimePtr(f.LastInvestigatedAt, f.DetectedAt)
}
if record.CompletedAt == nil && f.LastInvestigatedAt != nil && investigationRecordStatusIsTerminal(record.Status) {
completedAt := *f.LastInvestigatedAt
record.CompletedAt = &completedAt
}
if record.RecommendedAction == "" && record.ProposedFix != nil {
record.RecommendedAction = strings.TrimSpace(record.ProposedFix.Description)
}
record.Confidence = deriveInvestigationRecordConfidence(record)
normalized := record.NormalizeCollections()
return &normalized
}
func fallbackInvestigationRecordID(findingID, sessionID string) string {
findingID = strings.TrimSpace(findingID)
sessionID = strings.TrimSpace(sessionID)
if sessionID != "" {
return sessionID
}
if findingID != "" {
return fmt.Sprintf("%s:investigation", findingID)
}
return fmt.Sprintf("investigation:%d", time.Now().UnixNano())
}
func investigationRecordEvidenceForFinding(f *Finding) []aicontracts.InvestigationRecordEvidence {
if f == nil {
return []aicontracts.InvestigationRecordEvidence{}
}
evidence := strings.TrimSpace(f.Evidence)
if evidence == "" {
return []aicontracts.InvestigationRecordEvidence{}
}
return []aicontracts.InvestigationRecordEvidence{
{
Kind: "finding_evidence",
Summary: evidence,
},
}
}
func investigationRecordEvidenceIDs(ids []string) []aicontracts.InvestigationRecordEvidence {
result := make([]aicontracts.InvestigationRecordEvidence, 0, len(ids))
for _, id := range uniqueNonEmptyStrings(ids) {
result = append(result, aicontracts.InvestigationRecordEvidence{
ID: id,
Kind: "investigation_evidence",
})
}
return result
}
func investigationRecordFixFromSession(fix *aicontracts.Fix) *aicontracts.InvestigationRecordFix {
if fix == nil {
return nil
}
normalized := fix.NormalizeCollections()
recordFix := aicontracts.InvestigationRecordFix{
ID: strings.TrimSpace(normalized.ID),
Description: strings.TrimSpace(normalized.Description),
Commands: uniqueNonEmptyStrings(normalized.Commands),
RiskLevel: strings.TrimSpace(normalized.RiskLevel),
Destructive: normalized.Destructive,
TargetHost: strings.TrimSpace(normalized.TargetHost),
Rationale: strings.TrimSpace(normalized.Rationale),
}.NormalizeCollections()
return &recordFix
}
func investigationRecordVerificationNotes(outcome aicontracts.InvestigationOutcome) []string {
switch outcome {
case aicontracts.OutcomeResolved, aicontracts.OutcomeFixVerified:
return []string{"Patrol verified the finding is resolved."}
case aicontracts.OutcomeFixVerificationFailed:
return []string{"Patrol verification found the issue is still present."}
case aicontracts.OutcomeFixVerificationUnknown:
return []string{"Patrol could not conclusively verify remediation."}
case aicontracts.OutcomeFixFailed:
return []string{"Patrol attempted remediation, but the fix did not complete successfully."}
default:
return []string{}
}
}
func investigationRecordStatusIsTerminal(status aicontracts.InvestigationStatus) bool {
switch status {
case aicontracts.InvestigationStatusCompleted,
aicontracts.InvestigationStatusFailed,
aicontracts.InvestigationStatusNeedsAttention:
return true
default:
return false
}
}
func deriveInvestigationRecordConfidence(record *aicontracts.InvestigationRecord) aicontracts.InvestigationRecordConfidence {
if record == nil {
return aicontracts.InvestigationRecordConfidenceLow
}
switch record.Outcome {
case aicontracts.OutcomeResolved, aicontracts.OutcomeFixVerified:
return aicontracts.InvestigationRecordConfidenceHigh
case aicontracts.OutcomeFixQueued, aicontracts.OutcomeFixExecuted, aicontracts.OutcomeCannotFix, aicontracts.OutcomeNeedsAttention:
return aicontracts.InvestigationRecordConfidenceMedium
case aicontracts.OutcomeFixFailed, aicontracts.OutcomeFixVerificationFailed, aicontracts.OutcomeFixVerificationUnknown, aicontracts.OutcomeTimedOut:
return aicontracts.InvestigationRecordConfidenceLow
}
if strings.TrimSpace(record.Conclusion) != "" || len(record.Evidence) > 0 {
return aicontracts.InvestigationRecordConfidenceMedium
}
return aicontracts.InvestigationRecordConfidenceLow
}
func uniqueNonEmptyStrings(values []string) []string {
if len(values) == 0 {
return []string{}
}
seen := make(map[string]struct{}, len(values))
result := make([]string, 0, len(values))
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
continue
}
if _, ok := seen[trimmed]; ok {
continue
}
seen[trimmed] = struct{}{}
result = append(result, trimmed)
}
return result
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}
func firstNonZeroTimePtr(value *time.Time, fallback time.Time) time.Time {
if value != nil && !value.IsZero() {
return *value
}
return fallback
}

View file

@ -0,0 +1,122 @@
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",
Recommendation: "Reduce CPU pressure",
Evidence: "cpu=96%",
Source: "ai-analysis",
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.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)
}
}
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")
}
}

View file

@ -1234,6 +1234,19 @@ func (p *PatrolService) MaybeInvestigateFinding(f *Finding) {
pushCb = p.pushNotifyCallback
p.mu.RUnlock()
if latest := p.findings.Get(f.ID); latest != nil {
var latestInvestigation *InvestigationSession
if orchestrator != nil {
latestInvestigation = orchestrator.GetInvestigationByFinding(latest.ID)
}
if record := BuildFindingInvestigationRecord(latest, latestInvestigation); record != nil {
if p.findings.UpdateInvestigationRecord(latest.ID, record) {
if refreshed := p.findings.Get(latest.ID); refreshed != nil {
latest = refreshed
} else {
latest.InvestigationRecord = record
}
}
}
if pushUnified != nil {
pushUnified(latest)
}
@ -1247,12 +1260,10 @@ func (p *PatrolService) MaybeInvestigateFinding(f *Finding) {
case string(InvestigationOutcomeFixQueued):
approvalID := ""
riskLevel := ""
if orchestrator != nil {
if inv := orchestrator.GetInvestigationByFinding(latest.ID); inv != nil {
approvalID = inv.ApprovalID
if inv.ProposedFix != nil {
riskLevel = inv.ProposedFix.RiskLevel
}
if latestInvestigation != nil {
approvalID = latestInvestigation.ApprovalID
if latestInvestigation.ProposedFix != nil {
riskLevel = latestInvestigation.ProposedFix.RiskLevel
}
}
if approvalID == "" {

View file

@ -10,6 +10,8 @@ import (
"time"
"github.com/rs/zerolog/log"
"github.com/rcourtman/pulse-go-rewrite/pkg/aicontracts"
)
// FindingSource identifies where a finding originated
@ -96,15 +98,16 @@ type UnifiedFinding struct {
AIEnhancedAt *time.Time `json:"ai_enhanced_at,omitempty"` // When AI analyzed
// Investigation fields (autonomous patrol investigation)
InvestigationSessionID string `json:"investigation_session_id,omitempty"`
InvestigationStatus string `json:"investigation_status,omitempty"`
InvestigationOutcome string `json:"investigation_outcome,omitempty"`
LastInvestigatedAt *time.Time `json:"last_investigated_at,omitempty"`
InvestigationAttempts int `json:"investigation_attempts,omitempty"`
LoopState string `json:"loop_state,omitempty"`
Lifecycle []UnifiedFindingLifecycleEvent `json:"lifecycle,omitempty"`
RegressionCount int `json:"regression_count,omitempty"`
LastRegressionAt *time.Time `json:"last_regression_at,omitempty"`
InvestigationSessionID string `json:"investigation_session_id,omitempty"`
InvestigationStatus string `json:"investigation_status,omitempty"`
InvestigationOutcome string `json:"investigation_outcome,omitempty"`
LastInvestigatedAt *time.Time `json:"last_investigated_at,omitempty"`
InvestigationAttempts int `json:"investigation_attempts,omitempty"`
InvestigationRecord *aicontracts.InvestigationRecord `json:"investigation_record,omitempty"`
LoopState string `json:"loop_state,omitempty"`
Lifecycle []UnifiedFindingLifecycleEvent `json:"lifecycle,omitempty"`
RegressionCount int `json:"regression_count,omitempty"`
LastRegressionAt *time.Time `json:"last_regression_at,omitempty"`
// Timestamps
DetectedAt time.Time `json:"detected_at"`
@ -121,48 +124,49 @@ type UnifiedFinding struct {
}
type unifiedFindingJSON struct {
ID string `json:"id"`
Source FindingSource `json:"source"`
Severity UnifiedSeverity `json:"severity"`
Category UnifiedCategory `json:"category"`
ResourceID string `json:"resource_id"`
ResourceName string `json:"resource_name"`
ResourceType string `json:"resource_type"`
Node string `json:"node,omitempty"`
Title string `json:"title"`
Description string `json:"description"`
Recommendation string `json:"recommendation,omitempty"`
Evidence string `json:"evidence,omitempty"`
AlertIdentifier string `json:"alert_identifier,omitempty"`
AlertType string `json:"alert_type,omitempty"`
Value float64 `json:"value,omitempty"`
Threshold float64 `json:"threshold,omitempty"`
IsThreshold bool `json:"is_threshold"`
AIContext string `json:"ai_context,omitempty"`
RootCauseID string `json:"root_cause_id,omitempty"`
CorrelatedIDs []string `json:"correlated_ids,omitempty"`
RemediationID string `json:"remediation_id,omitempty"`
AIConfidence float64 `json:"ai_confidence,omitempty"`
EnhancedByAI bool `json:"enhanced_by_ai"`
AIEnhancedAt *time.Time `json:"ai_enhanced_at,omitempty"`
InvestigationSessionID string `json:"investigation_session_id,omitempty"`
InvestigationStatus string `json:"investigation_status,omitempty"`
InvestigationOutcome string `json:"investigation_outcome,omitempty"`
LastInvestigatedAt *time.Time `json:"last_investigated_at,omitempty"`
InvestigationAttempts int `json:"investigation_attempts,omitempty"`
LoopState string `json:"loop_state,omitempty"`
Lifecycle []UnifiedFindingLifecycleEvent `json:"lifecycle,omitempty"`
RegressionCount int `json:"regression_count,omitempty"`
LastRegressionAt *time.Time `json:"last_regression_at,omitempty"`
DetectedAt time.Time `json:"detected_at"`
LastSeenAt time.Time `json:"last_seen_at"`
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
AcknowledgedAt *time.Time `json:"acknowledged_at,omitempty"`
SnoozedUntil *time.Time `json:"snoozed_until,omitempty"`
DismissedReason string `json:"dismissed_reason,omitempty"`
UserNote string `json:"user_note,omitempty"`
Suppressed bool `json:"suppressed"`
TimesRaised int `json:"times_raised"`
ID string `json:"id"`
Source FindingSource `json:"source"`
Severity UnifiedSeverity `json:"severity"`
Category UnifiedCategory `json:"category"`
ResourceID string `json:"resource_id"`
ResourceName string `json:"resource_name"`
ResourceType string `json:"resource_type"`
Node string `json:"node,omitempty"`
Title string `json:"title"`
Description string `json:"description"`
Recommendation string `json:"recommendation,omitempty"`
Evidence string `json:"evidence,omitempty"`
AlertIdentifier string `json:"alert_identifier,omitempty"`
AlertType string `json:"alert_type,omitempty"`
Value float64 `json:"value,omitempty"`
Threshold float64 `json:"threshold,omitempty"`
IsThreshold bool `json:"is_threshold"`
AIContext string `json:"ai_context,omitempty"`
RootCauseID string `json:"root_cause_id,omitempty"`
CorrelatedIDs []string `json:"correlated_ids,omitempty"`
RemediationID string `json:"remediation_id,omitempty"`
AIConfidence float64 `json:"ai_confidence,omitempty"`
EnhancedByAI bool `json:"enhanced_by_ai"`
AIEnhancedAt *time.Time `json:"ai_enhanced_at,omitempty"`
InvestigationSessionID string `json:"investigation_session_id,omitempty"`
InvestigationStatus string `json:"investigation_status,omitempty"`
InvestigationOutcome string `json:"investigation_outcome,omitempty"`
LastInvestigatedAt *time.Time `json:"last_investigated_at,omitempty"`
InvestigationAttempts int `json:"investigation_attempts,omitempty"`
InvestigationRecord *aicontracts.InvestigationRecord `json:"investigation_record,omitempty"`
LoopState string `json:"loop_state,omitempty"`
Lifecycle []UnifiedFindingLifecycleEvent `json:"lifecycle,omitempty"`
RegressionCount int `json:"regression_count,omitempty"`
LastRegressionAt *time.Time `json:"last_regression_at,omitempty"`
DetectedAt time.Time `json:"detected_at"`
LastSeenAt time.Time `json:"last_seen_at"`
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
AcknowledgedAt *time.Time `json:"acknowledged_at,omitempty"`
SnoozedUntil *time.Time `json:"snoozed_until,omitempty"`
DismissedReason string `json:"dismissed_reason,omitempty"`
UserNote string `json:"user_note,omitempty"`
Suppressed bool `json:"suppressed"`
TimesRaised int `json:"times_raised"`
}
func (f UnifiedFinding) MarshalJSON() ([]byte, error) {
@ -197,6 +201,7 @@ func (f UnifiedFinding) MarshalJSON() ([]byte, error) {
InvestigationOutcome: f.InvestigationOutcome,
LastInvestigatedAt: f.LastInvestigatedAt,
InvestigationAttempts: f.InvestigationAttempts,
InvestigationRecord: f.InvestigationRecord,
LoopState: f.LoopState,
Lifecycle: f.Lifecycle,
RegressionCount: f.RegressionCount,
@ -249,6 +254,7 @@ func (f *UnifiedFinding) UnmarshalJSON(data []byte) error {
InvestigationOutcome: payload.InvestigationOutcome,
LastInvestigatedAt: payload.LastInvestigatedAt,
InvestigationAttempts: payload.InvestigationAttempts,
InvestigationRecord: payload.InvestigationRecord,
LoopState: payload.LoopState,
Lifecycle: payload.Lifecycle,
RegressionCount: payload.RegressionCount,
@ -618,6 +624,7 @@ func (s *UnifiedStore) AddFromAI(finding *UnifiedFinding) (*UnifiedFinding, bool
existing.InvestigationOutcome = finding.InvestigationOutcome
existing.LastInvestigatedAt = finding.LastInvestigatedAt
existing.InvestigationAttempts = finding.InvestigationAttempts
existing.InvestigationRecord = finding.InvestigationRecord
existing.LoopState = finding.LoopState
existing.Lifecycle = finding.Lifecycle
existing.RegressionCount = finding.RegressionCount

View file

@ -3,6 +3,8 @@ package unified
import (
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/pkg/aicontracts"
)
func TestUnifiedStore_AddFromAlert(t *testing.T) {
@ -161,6 +163,51 @@ func TestUnifiedStore_AddFromAI(t *testing.T) {
}
}
func TestUnifiedStore_AddFromAI_MergesInvestigationRecord(t *testing.T) {
store := NewUnifiedStore(DefaultAlertToFindingConfig())
initial := &UnifiedFinding{
ID: "finding-1",
Source: SourceAIPatrol,
Severity: SeverityWarning,
Category: CategoryPerformance,
ResourceID: "vm-100",
ResourceName: "vm-100",
Title: "High CPU",
Description: "CPU high",
DetectedAt: time.Now(),
LastSeenAt: time.Now(),
}
store.AddFromAI(initial)
record := &aicontracts.InvestigationRecord{
ID: "investigation-1",
FindingID: "finding-1",
Status: aicontracts.InvestigationStatusCompleted,
Outcome: aicontracts.OutcomeFixQueued,
Evidence: []aicontracts.InvestigationRecordEvidence{},
ToolsUsed: []string{},
}
updated, isNew := store.AddFromAI(&UnifiedFinding{
ID: "finding-1",
Source: SourceAIPatrol,
Severity: SeverityWarning,
Category: CategoryPerformance,
ResourceID: "vm-100",
ResourceName: "vm-100",
Title: "High CPU",
Description: "CPU high",
InvestigationRecord: record,
DetectedAt: time.Now(),
LastSeenAt: time.Now(),
})
if isNew {
t.Fatal("expected merge into existing finding")
}
if updated.InvestigationRecord == nil || updated.InvestigationRecord.ID != "investigation-1" {
t.Fatalf("expected investigation record to merge, got %#v", updated.InvestigationRecord)
}
}
func TestUnifiedStore_GetBySource(t *testing.T) {
store := NewUnifiedStore(DefaultAlertToFindingConfig())

View file

@ -6,6 +6,8 @@ import (
"path/filepath"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/pkg/aicontracts"
)
func TestUnifiedFindingJSONCanonicalOutput(t *testing.T) {
@ -17,8 +19,15 @@ func TestUnifiedFindingJSONCanonicalOutput(t *testing.T) {
ResourceID: "res-1",
Title: "High CPU",
AlertIdentifier: "instance:node:100::metric/cpu",
DetectedAt: time.Now(),
LastSeenAt: time.Now(),
InvestigationRecord: &aicontracts.InvestigationRecord{
ID: "investigation-1",
FindingID: "f1",
Status: aicontracts.InvestigationStatusCompleted,
Evidence: []aicontracts.InvestigationRecordEvidence{},
ToolsUsed: []string{},
},
DetectedAt: time.Now(),
LastSeenAt: time.Now(),
}
raw, err := json.Marshal(finding)
@ -33,6 +42,9 @@ func TestUnifiedFindingJSONCanonicalOutput(t *testing.T) {
if payload["alert_identifier"] != "instance:node:100::metric/cpu" {
t.Fatalf("expected canonical alert_identifier, got %#v", payload["alert_identifier"])
}
if _, ok := payload["investigation_record"]; !ok {
t.Fatalf("expected investigation_record in canonical payload, got %#v", payload)
}
if _, ok := payload["alert_id"]; ok {
t.Fatalf("did not expect legacy alert_id in canonical payload, got %#v", payload["alert_id"])
}

View file

@ -12,6 +12,7 @@ import (
"github.com/rcourtman/pulse-go-rewrite/internal/ai/unified"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
"github.com/rcourtman/pulse-go-rewrite/pkg/aicontracts"
"github.com/rs/zerolog/log"
)
@ -1161,6 +1162,7 @@ func (h *AISettingsHandler) HandleGetUnifiedFindings(w http.ResponseWriter, r *h
InvestigationOutcome string `json:"investigation_outcome,omitempty"`
LastInvestigatedAt *time.Time `json:"last_investigated_at,omitempty"`
InvestigationAttempts int `json:"investigation_attempts,omitempty"`
InvestigationRecord *aicontracts.InvestigationRecord `json:"investigation_record,omitempty"`
LoopState string `json:"loop_state,omitempty"`
Lifecycle []unified.UnifiedFindingLifecycleEvent `json:"lifecycle"`
RegressionCount int `json:"regression_count,omitempty"`
@ -1246,6 +1248,7 @@ func (h *AISettingsHandler) HandleGetUnifiedFindings(w http.ResponseWriter, r *h
InvestigationOutcome: f.InvestigationOutcome,
LastInvestigatedAt: f.LastInvestigatedAt,
InvestigationAttempts: f.InvestigationAttempts,
InvestigationRecord: f.InvestigationRecord,
LoopState: f.LoopState,
Lifecycle: f.Lifecycle,
RegressionCount: f.RegressionCount,

View file

@ -12,6 +12,7 @@ import (
"github.com/rcourtman/pulse-go-rewrite/internal/ai/unified"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
"github.com/rcourtman/pulse-go-rewrite/pkg/aicontracts"
)
const legacyFindingAlertIDField = "alert_id"
@ -212,8 +213,16 @@ func TestHandleGetUnifiedFindings_WithStore(t *testing.T) {
Title: "CPU high",
Description: "cpu usage high",
AlertIdentifier: "instance:node:100::metric/cpu",
DetectedAt: time.Now(),
LastSeenAt: time.Now(),
InvestigationRecord: &aicontracts.InvestigationRecord{
ID: "investigation-1",
FindingID: "finding-1",
Status: aicontracts.InvestigationStatusCompleted,
Outcome: aicontracts.OutcomeFixQueued,
Evidence: []aicontracts.InvestigationRecordEvidence{},
ToolsUsed: []string{},
},
DetectedAt: time.Now(),
LastSeenAt: time.Now(),
})
handler := &AISettingsHandler{}
@ -258,4 +267,11 @@ func TestHandleGetUnifiedFindings_WithStore(t *testing.T) {
if _, ok := finding["lifecycle"]; !ok {
t.Fatalf("expected lifecycle to be present, got %#v", finding)
}
record, ok := finding["investigation_record"].(map[string]interface{})
if !ok {
t.Fatalf("expected investigation_record object, got %#v", finding["investigation_record"])
}
if record["id"] != "investigation-1" {
t.Fatalf("expected investigation record ID, got %#v", record)
}
}

View file

@ -42,6 +42,7 @@ import (
"github.com/rcourtman/pulse-go-rewrite/internal/updates"
"github.com/rcourtman/pulse-go-rewrite/internal/vmware"
agentshost "github.com/rcourtman/pulse-go-rewrite/pkg/agents/host"
"github.com/rcourtman/pulse-go-rewrite/pkg/aicontracts"
authpkg "github.com/rcourtman/pulse-go-rewrite/pkg/auth"
"github.com/rcourtman/pulse-go-rewrite/pkg/cloudauth"
pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing"
@ -2526,7 +2527,36 @@ func TestContract_FindingJSONSnapshot(t *testing.T) {
InvestigationOutcome: "fix_queued",
LastInvestigatedAt: &lastInvestigated,
InvestigationAttempts: 2,
LoopState: "remediation_planned",
InvestigationRecord: &aicontracts.InvestigationRecord{
ID: "investigation-1",
FindingID: "finding-1",
Subject: aicontracts.InvestigationRecordSubject{
ResourceID: "vm-100",
ResourceName: "web-server",
ResourceType: "vm",
Node: "pve-1",
},
Trigger: aicontracts.InvestigationRecordTrigger{
FindingKey: "cpu-high",
Source: "ai-analysis",
Severity: "critical",
Category: "performance",
Title: "High CPU usage",
DetectedAt: now,
Description: "CPU sustained above 95%",
},
Status: aicontracts.InvestigationStatusCompleted,
Outcome: aicontracts.OutcomeFixQueued,
Confidence: aicontracts.InvestigationRecordConfidenceMedium,
Evidence: []aicontracts.InvestigationRecordEvidence{{Kind: "finding_evidence", Summary: "cpu=96%"}},
Conclusion: "CPU sustained above 95%",
RecommendedAction: "Investigate processes and load",
Verification: []string{},
ToolsUsed: []string{"ssh.exec"},
StartedAt: now,
ApprovalID: "approval-1",
},
LoopState: "remediation_planned",
Lifecycle: []ai.FindingLifecycleEvent{
{
At: now,
@ -2580,6 +2610,7 @@ func TestContract_FindingJSONSnapshot(t *testing.T) {
"investigation_outcome":"fix_queued",
"last_investigated_at":"2026-02-08T13:29:15Z",
"investigation_attempts":2,
"investigation_record":{"id":"investigation-1","finding_id":"finding-1","subject":{"resource_id":"vm-100","resource_name":"web-server","resource_type":"vm","node":"pve-1"},"trigger":{"finding_key":"cpu-high","source":"ai-analysis","severity":"critical","category":"performance","title":"High CPU usage","detected_at":"2026-02-08T13:14:15Z","description":"CPU sustained above 95%"},"status":"completed","outcome":"fix_queued","confidence":"medium","evidence":[{"kind":"finding_evidence","summary":"cpu=96%"}],"conclusion":"CPU sustained above 95%","recommended_action":"Investigate processes and load","verification":[],"tools_used":["ssh.exec"],"started_at":"2026-02-08T13:14:15Z","approval_id":"approval-1"},
"loop_state":"remediation_planned",
"lifecycle":[{"at":"2026-02-08T13:14:15Z","type":"state_change","message":"Moved to investigating","from":"detected","to":"investigating","metadata":{"from":"detected","to":"investigating"}}],
"regression_count":1,

View file

@ -1709,6 +1709,7 @@ func (r *Router) startPatrolForContext(ctx context.Context, orgID string) bool {
InvestigationOutcome: f.InvestigationOutcome,
LastInvestigatedAt: f.LastInvestigatedAt,
InvestigationAttempts: f.InvestigationAttempts,
InvestigationRecord: f.InvestigationRecord,
LoopState: f.LoopState,
Lifecycle: toUnifiedLifecycle(f.Lifecycle),
RegressionCount: f.RegressionCount,
@ -1770,6 +1771,7 @@ func (r *Router) startPatrolForContext(ctx context.Context, orgID string) bool {
InvestigationOutcome: f.InvestigationOutcome,
LastInvestigatedAt: f.LastInvestigatedAt,
InvestigationAttempts: f.InvestigationAttempts,
InvestigationRecord: f.InvestigationRecord,
LoopState: f.LoopState,
Lifecycle: toUnifiedLifecycle(f.Lifecycle),
RegressionCount: f.RegressionCount,

View file

@ -19,6 +19,7 @@ import (
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/internal/notifications"
"github.com/rcourtman/pulse-go-rewrite/internal/securityutil"
"github.com/rcourtman/pulse-go-rewrite/pkg/aicontracts"
"github.com/rs/zerolog/log"
)
@ -2178,6 +2179,8 @@ func decodeOptionalJSONBool(raw json.RawMessage) (bool, bool) {
return value, true
}
const aiFindingsDataVersion = 4
// AIFindingsData represents persisted AI findings with metadata
type AIFindingsData struct {
// Version for future migrations
@ -2234,12 +2237,13 @@ type AIFindingRecord struct {
Suppressed bool `json:"suppressed"`
// Investigation fields - tracks autonomous AI investigation of findings
InvestigationSessionID string `json:"investigation_session_id,omitempty"`
InvestigationStatus string `json:"investigation_status,omitempty"`
InvestigationOutcome string `json:"investigation_outcome,omitempty"`
LastInvestigatedAt *time.Time `json:"last_investigated_at,omitempty"`
InvestigationAttempts int `json:"investigation_attempts"`
LoopState string `json:"loop_state,omitempty"`
InvestigationSessionID string `json:"investigation_session_id,omitempty"`
InvestigationStatus string `json:"investigation_status,omitempty"`
InvestigationOutcome string `json:"investigation_outcome,omitempty"`
LastInvestigatedAt *time.Time `json:"last_investigated_at,omitempty"`
InvestigationAttempts int `json:"investigation_attempts"`
InvestigationRecord *aicontracts.InvestigationRecord `json:"investigation_record,omitempty"`
LoopState string `json:"loop_state,omitempty"`
Lifecycle []struct {
At time.Time `json:"at"`
Type string `json:"type"`
@ -2296,7 +2300,7 @@ func (c *ConfigPersistence) SaveAIFindingsWithSuppression(findings map[string]*A
}
data := AIFindingsData{
Version: 3, // Bumped from 2: persisted suppression rules alongside findings
Version: aiFindingsDataVersion, // v4 persists structured investigation records alongside findings.
LastSaved: time.Now(),
Findings: findings,
SuppressionRules: suppressionRules,
@ -2332,7 +2336,7 @@ func (c *ConfigPersistence) LoadAIFindings() (*AIFindingsData, error) {
if os.IsNotExist(err) {
// Return empty data if file doesn't exist
return &AIFindingsData{
Version: 3,
Version: aiFindingsDataVersion,
Findings: make(map[string]*AIFindingRecord),
SuppressionRules: make(map[string]*AISuppressionRuleRecord),
}, nil
@ -2355,7 +2359,7 @@ func (c *ConfigPersistence) LoadAIFindings() (*AIFindingsData, error) {
log.Error().Err(err).Str("file", c.aiFindingsFile).Msg("Failed to parse AI findings file")
// Return empty data on parse error rather than failing
return &AIFindingsData{
Version: 3,
Version: aiFindingsDataVersion,
Findings: make(map[string]*AIFindingRecord),
SuppressionRules: make(map[string]*AISuppressionRuleRecord),
}, nil
@ -2374,13 +2378,13 @@ func (c *ConfigPersistence) LoadAIFindings() (*AIFindingsData, error) {
oldCount := len(findingsData.Findings)
findingsData.Findings = make(map[string]*AIFindingRecord)
findingsData.SuppressionRules = make(map[string]*AISuppressionRuleRecord)
findingsData.Version = 3
findingsData.Version = aiFindingsDataVersion
findingsData.LastSaved = time.Now()
if oldCount > 0 {
log.Info().
Int("cleared_count", oldCount).
Msg("AI findings cleared due to schema upgrade (v1 -> v3)")
Msg("AI findings cleared due to schema upgrade (v1 -> v4)")
}
// Persist the migrated (empty) file immediately to avoid re-migrating on restart
@ -2401,8 +2405,28 @@ func (c *ConfigPersistence) LoadAIFindings() (*AIFindingsData, error) {
}
c.mu.Unlock()
} else if findingsData.Version < 3 {
// v2 -> v3: keep findings; start persisting explicit suppression rules.
findingsData.Version = 3
// v2 -> v4: keep findings; start persisting explicit suppression rules and investigation records.
findingsData.Version = aiFindingsDataVersion
findingsData.LastSaved = time.Now()
c.mu.Lock()
if jsonData, err := json.Marshal(findingsData); err == nil {
if c.crypto != nil {
encrypted, encErr := c.crypto.Encrypt(jsonData)
if encErr != nil {
log.Warn().Err(encErr).Msg("Failed to encrypt migrated AI findings — skipping write to avoid plaintext storage")
c.mu.Unlock()
return &findingsData, nil
}
jsonData = encrypted
}
if err := c.writeConfigFileLocked(c.aiFindingsFile, jsonData, 0600); err != nil {
log.Warn().Err(err).Msg("Failed to persist migrated AI findings file")
}
}
c.mu.Unlock()
} else if findingsData.Version < aiFindingsDataVersion {
// v3 -> v4: keep findings and start writing structured investigation records.
findingsData.Version = aiFindingsDataVersion
findingsData.LastSaved = time.Now()
c.mu.Lock()
if jsonData, err := json.Marshal(findingsData); err == nil {

View file

@ -9,6 +9,8 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/rcourtman/pulse-go-rewrite/pkg/aicontracts"
)
func TestPersistence_AIFindings(t *testing.T) {
@ -25,6 +27,14 @@ func TestPersistence_AIFindings(t *testing.T) {
ID: "id1",
Description: "analysis",
DetectedAt: time.Now(),
InvestigationRecord: &aicontracts.InvestigationRecord{
ID: "investigation-1",
FindingID: "id1",
Status: aicontracts.InvestigationStatusCompleted,
Outcome: aicontracts.OutcomeFixQueued,
Evidence: []aicontracts.InvestigationRecordEvidence{},
ToolsUsed: []string{},
},
}
data.Findings["id1"] = record
@ -36,6 +46,8 @@ func TestPersistence_AIFindings(t *testing.T) {
require.NoError(t, err)
assert.Len(t, loaded.Findings, 1)
assert.Equal(t, "analysis", loaded.Findings["id1"].Description)
require.NotNil(t, loaded.Findings["id1"].InvestigationRecord)
assert.Equal(t, "investigation-1", loaded.Findings["id1"].InvestigationRecord.ID)
// Test Corrupt file (Unmarshal error)
require.NoError(t, os.WriteFile(filepath.Join(tempDir, "ai_findings.json"), []byte("{invalid"), 0644))
@ -53,6 +65,13 @@ func TestAIFindingRecordJSONCanonicalOutput(t *testing.T) {
AlertIdentifier: "instance:node:100::metric/cpu",
DetectedAt: time.Now(),
LastSeenAt: time.Now(),
InvestigationRecord: &aicontracts.InvestigationRecord{
ID: "investigation-1",
FindingID: "finding-1",
Status: aicontracts.InvestigationStatusCompleted,
Evidence: []aicontracts.InvestigationRecordEvidence{},
ToolsUsed: []string{},
},
}
raw, err := json.Marshal(record)
@ -61,6 +80,7 @@ func TestAIFindingRecordJSONCanonicalOutput(t *testing.T) {
var payload map[string]interface{}
require.NoError(t, json.Unmarshal(raw, &payload))
assert.Equal(t, "instance:node:100::metric/cpu", payload["alert_identifier"])
require.Contains(t, payload, "investigation_record")
_, hasLegacy := payload["alert_id"]
assert.False(t, hasLegacy)

View file

@ -55,6 +55,22 @@ func TestEmptyInvestigationSession_UsesCanonicalEmptyCollections(t *testing.T) {
}
}
func TestEmptyInvestigationRecord_UsesCanonicalEmptyCollections(t *testing.T) {
payload, err := json.Marshal(EmptyInvestigationRecord())
if err != nil {
t.Fatalf("marshal empty investigation record: %v", err)
}
if !strings.Contains(string(payload), `"evidence":[]`) {
t.Fatalf("expected empty investigation record to retain evidence array, got %s", payload)
}
if !strings.Contains(string(payload), `"verification":[]`) {
t.Fatalf("expected empty investigation record to retain verification array, got %s", payload)
}
if !strings.Contains(string(payload), `"tools_used":[]`) {
t.Fatalf("expected empty investigation record to retain tools_used array, got %s", payload)
}
}
func TestEmptyFix_UsesCanonicalEmptyCollections(t *testing.T) {
payload, err := json.Marshal(EmptyFix())
if err != nil {

View file

@ -121,6 +121,114 @@ func (s InvestigationSession) NormalizeCollections() InvestigationSession {
return s
}
// ---------------------------------------------------------------------------
// Investigation record
// ---------------------------------------------------------------------------
// InvestigationRecordConfidence is the confidence level for a durable
// investigation record.
type InvestigationRecordConfidence string
const (
InvestigationRecordConfidenceLow InvestigationRecordConfidence = "low"
InvestigationRecordConfidenceMedium InvestigationRecordConfidence = "medium"
InvestigationRecordConfidenceHigh InvestigationRecordConfidence = "high"
)
// InvestigationRecord is the durable product-facing summary of a Patrol
// investigation. It is intentionally separate from InvestigationSession:
// sessions are execution details, while records are the stable context that
// Patrol, Assistant, unified findings, persistence, and audit surfaces can share.
type InvestigationRecord struct {
ID string `json:"id"`
FindingID string `json:"finding_id"`
SessionID string `json:"session_id,omitempty"`
Subject InvestigationRecordSubject `json:"subject"`
Trigger InvestigationRecordTrigger `json:"trigger"`
Status InvestigationStatus `json:"status"`
Outcome InvestigationOutcome `json:"outcome,omitempty"`
Confidence InvestigationRecordConfidence `json:"confidence,omitempty"`
Evidence []InvestigationRecordEvidence `json:"evidence"`
Conclusion string `json:"conclusion,omitempty"`
RecommendedAction string `json:"recommended_action,omitempty"`
ProposedFix *InvestigationRecordFix `json:"proposed_fix,omitempty"`
Verification []string `json:"verification"`
ToolsUsed []string `json:"tools_used"`
StartedAt time.Time `json:"started_at"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
ApprovalID string `json:"approval_id,omitempty"`
Error string `json:"error,omitempty"`
}
// InvestigationRecordSubject identifies the infrastructure object under
// investigation.
type InvestigationRecordSubject struct {
ResourceID string `json:"resource_id"`
ResourceName string `json:"resource_name,omitempty"`
ResourceType string `json:"resource_type,omitempty"`
Node string `json:"node,omitempty"`
}
// InvestigationRecordTrigger captures the Patrol finding that caused the
// investigation to run.
type InvestigationRecordTrigger struct {
FindingKey string `json:"finding_key,omitempty"`
Source string `json:"source,omitempty"`
Severity string `json:"severity,omitempty"`
Category string `json:"category,omitempty"`
Title string `json:"title,omitempty"`
DetectedAt time.Time `json:"detected_at"`
Description string `json:"description,omitempty"`
}
// InvestigationRecordEvidence points to evidence Patrol used or generated
// during investigation.
type InvestigationRecordEvidence struct {
ID string `json:"id,omitempty"`
Kind string `json:"kind"`
Summary string `json:"summary,omitempty"`
}
// InvestigationRecordFix is the durable, product-facing version of a proposed
// remediation fix.
type InvestigationRecordFix struct {
ID string `json:"id"`
Description string `json:"description"`
Commands []string `json:"commands"`
RiskLevel string `json:"risk_level,omitempty"`
Destructive bool `json:"destructive"`
TargetHost string `json:"target_host,omitempty"`
Rationale string `json:"rationale,omitempty"`
}
func EmptyInvestigationRecord() InvestigationRecord {
return InvestigationRecord{}.NormalizeCollections()
}
func (r InvestigationRecord) NormalizeCollections() InvestigationRecord {
if r.Evidence == nil {
r.Evidence = []InvestigationRecordEvidence{}
}
if r.Verification == nil {
r.Verification = []string{}
}
if r.ToolsUsed == nil {
r.ToolsUsed = []string{}
}
if r.ProposedFix != nil {
normalizedFix := r.ProposedFix.NormalizeCollections()
r.ProposedFix = &normalizedFix
}
return r
}
func (f InvestigationRecordFix) NormalizeCollections() InvestigationRecordFix {
if f.Commands == nil {
f.Commands = []string{}
}
return f
}
// ---------------------------------------------------------------------------
// Fix
// ---------------------------------------------------------------------------

View file

@ -3625,8 +3625,8 @@ class SubsystemLookupTest(unittest.TestCase):
{
"heading": "## Shared Boundaries",
"path": "internal/api/access_control_handlers.go",
"line": 178,
"heading_line": 101,
"line": 182,
"heading_line": 105,
}
],
)