diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index d1335fcdd..e4e54d1de 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index af92ce916..0f2db3409 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index dbf845496..c8aed21de 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index 605a1bffe..de22e074a 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index eecc0a55b..9cf2cf97b 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 61986a391..c433e8c7e 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -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 diff --git a/frontend-modern/src/api/__tests__/ai.test.ts b/frontend-modern/src/api/__tests__/ai.test.ts index 3a808d28e..dd7c81f7f 100644 --- a/frontend-modern/src/api/__tests__/ai.test.ts +++ b/frontend-modern/src/api/__tests__/ai.test.ts @@ -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', + }, }); }); diff --git a/frontend-modern/src/api/ai.ts b/frontend-modern/src/api/ai.ts index 4063f6cf5..b9e804265 100644 --- a/frontend-modern/src/api/ai.ts +++ b/frontend-modern/src/api/ai.ts @@ -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; diff --git a/frontend-modern/src/api/patrol.ts b/frontend-modern/src/api/patrol.ts index c66e205a6..b172a32f3 100644 --- a/frontend-modern/src/api/patrol.ts +++ b/frontend-modern/src/api/patrol.ts @@ -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 = diff --git a/frontend-modern/src/stores/__tests__/aiIntelligence.test.ts b/frontend-modern/src/stores/__tests__/aiIntelligence.test.ts index 2a6d2b960..0f5397ae8 100644 --- a/frontend-modern/src/stores/__tests__/aiIntelligence.test.ts +++ b/frontend-modern/src/stores/__tests__/aiIntelligence.test.ts @@ -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', + }, }); }); diff --git a/frontend-modern/src/stores/aiIntelligence.ts b/frontend-modern/src/stores/aiIntelligence.ts index ebf915a31..2c702675c 100644 --- a/frontend-modern/src/stores/aiIntelligence.ts +++ b/frontend-modern/src/stores/aiIntelligence.ts @@ -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, diff --git a/internal/ai/findings.go b/internal/ai/findings.go index cd9285e9c..4711f4dee 100644 --- a/internal/ai/findings.go +++ b/internal/ai/findings.go @@ -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() diff --git a/internal/ai/findings_persistence.go b/internal/ai/findings_persistence.go index 0768c5a56..91e6a9836 100644 --- a/internal/ai/findings_persistence.go +++ b/internal/ai/findings_persistence.go @@ -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, diff --git a/internal/ai/investigation_records.go b/internal/ai/investigation_records.go new file mode 100644 index 000000000..fc12e64d1 --- /dev/null +++ b/internal/ai/investigation_records.go @@ -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 +} diff --git a/internal/ai/investigation_records_test.go b/internal/ai/investigation_records_test.go new file mode 100644 index 000000000..31ba1c0ab --- /dev/null +++ b/internal/ai/investigation_records_test.go @@ -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") + } +} diff --git a/internal/ai/patrol_findings.go b/internal/ai/patrol_findings.go index f96ef7468..9f5771f49 100644 --- a/internal/ai/patrol_findings.go +++ b/internal/ai/patrol_findings.go @@ -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 == "" { diff --git a/internal/ai/unified/alerts.go b/internal/ai/unified/alerts.go index 69600d6d3..10d32efcf 100644 --- a/internal/ai/unified/alerts.go +++ b/internal/ai/unified/alerts.go @@ -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 diff --git a/internal/ai/unified/alerts_test.go b/internal/ai/unified/alerts_test.go index 52f505ec4..21bb1bc75 100644 --- a/internal/ai/unified/alerts_test.go +++ b/internal/ai/unified/alerts_test.go @@ -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()) diff --git a/internal/ai/unified/persistence_test.go b/internal/ai/unified/persistence_test.go index 6cabf6a7b..8d6066591 100644 --- a/internal/ai/unified/persistence_test.go +++ b/internal/ai/unified/persistence_test.go @@ -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"]) } diff --git a/internal/api/ai_intelligence_handlers.go b/internal/api/ai_intelligence_handlers.go index 43f47ac52..47e5bb2e9 100644 --- a/internal/api/ai_intelligence_handlers.go +++ b/internal/api/ai_intelligence_handlers.go @@ -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, diff --git a/internal/api/ai_intelligence_handlers_more_test.go b/internal/api/ai_intelligence_handlers_more_test.go index 81dc569a8..aed132d40 100644 --- a/internal/api/ai_intelligence_handlers_more_test.go +++ b/internal/api/ai_intelligence_handlers_more_test.go @@ -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) + } } diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index fd8de2fe8..bc5d3d2b9 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -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, diff --git a/internal/api/router.go b/internal/api/router.go index 9cdd3f317..ab9012cd5 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -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, diff --git a/internal/config/persistence.go b/internal/config/persistence.go index d6a31ee71..eaa4f9a1e 100644 --- a/internal/config/persistence.go +++ b/internal/config/persistence.go @@ -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 { diff --git a/internal/config/persistence_ai_test.go b/internal/config/persistence_ai_test.go index 137493797..bdef53673 100644 --- a/internal/config/persistence_ai_test.go +++ b/internal/config/persistence_ai_test.go @@ -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) diff --git a/pkg/aicontracts/contracts_test.go b/pkg/aicontracts/contracts_test.go index f23e1a7a3..a58ec5b90 100644 --- a/pkg/aicontracts/contracts_test.go +++ b/pkg/aicontracts/contracts_test.go @@ -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 { diff --git a/pkg/aicontracts/investigation.go b/pkg/aicontracts/investigation.go index f8eb75031..06f03ca6a 100644 --- a/pkg/aicontracts/investigation.go +++ b/pkg/aicontracts/investigation.go @@ -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 // --------------------------------------------------------------------------- diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py index d7b8bb014..936ebe0a6 100644 --- a/scripts/release_control/subsystem_lookup_test.py +++ b/scripts/release_control/subsystem_lookup_test.py @@ -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, } ], )