mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-19 07:54:10 +00:00
Add structured Patrol investigation records
This commit is contained in:
parent
cdd977a1c1
commit
75e3cb76fd
28 changed files with 932 additions and 121 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
228
internal/ai/investigation_records.go
Normal file
228
internal/ai/investigation_records.go
Normal 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
|
||||
}
|
||||
122
internal/ai/investigation_records_test.go
Normal file
122
internal/ai/investigation_records_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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 == "" {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue