diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 429f9de6e..cc9e18c5f 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -171,6 +171,9 @@ That same boundary now also assumes the Patrol-backed recent-changes API surface reads through the canonical intelligence facade first, so adjacent fleet and install surfaces do not bypass the shared unified timeline through the old detector-only handler path. +The Patrol-backed correlation API surface must follow the same canonical +intelligence-facade path, so fleet and install surfaces do not need to know +about the detector directly when they render learned relationship context. That same canonical /api/auto-register response must stay on one completion truth: caller-supplied Proxmox credentials complete registration with a direct-use action, and the runtime no longer preserves a dead pending-secret diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 6362b2c20..269314239 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -161,6 +161,11 @@ The AI correlation root-cause engine also consumes the canonical unified- resource relationship model directly, so cross-resource reasoning stays aligned with the same graph edges that back the resource API instead of maintaining a parallel relationship vocabulary inside AI correlation. +The Patrol-backed correlation endpoint, resource-intelligence payload, and +seed prompt correlations now flow through the shared AI intelligence facade +first, so the detector remains an implementation detail behind one canonical +correlation access path instead of being routed directly by handlers or prompt +builders. AI-facing policy metadata must also be cloned through the shared unified- resource policy helper so chat and tools consumers do not maintain their own policy copy logic. Chat mention prefetch now calls that shared helper directly diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 07c6493aa..9fa7e601b 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -195,6 +195,10 @@ through the shared `frontend-modern/src/stores/aiIntelligence.ts` store for the Patrol intelligence page and the AI summary page, so the learned-correlation list is governed by the same API contract that backs the resource drawer's graph evidence instead of being fetched as page-local state. +That correlations route now reads through the canonical AI intelligence +facade first, so the handler and its payload keep the detector behind one +shared access layer instead of routing directly to Patrol-local correlation +state. That store now also owns the dashboard load bundle used by the Patrol page, so the page refresh path stays aligned on one store-owned orchestration layer instead of re-encoding the AI bundle inline. diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index 1d2196607..07c26434f 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -100,6 +100,11 @@ That graph section is now rendered by the shared `internal/unifiedresources.FormatResourceGraphContext` helper, so the Patrol runtime only resolves the canonical resource graph rather than formatting the relationship section itself. +Patrol-owned correlation context now also comes through the shared AI +intelligence facade before reaching the detector, so the learned correlation +surface is routed through the same canonical AI ownership boundary as recent +changes and resource graph data instead of being pulled from the detector +directly in each caller. The Patrol seed context and AI runtime prompt path now also share the same correlation summary formatter from `internal/ai/correlation`, so learned-edge wording and confidence/count annotations stay canonical across the prompt diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 77418926f..a29714539 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -113,6 +113,9 @@ That same shared dependency also assumes the Patrol-backed recent-changes API surface reads through the canonical intelligence facade first, so storage and recovery handlers do not bypass the shared unified timeline through the older detector-only path. +The same shared boundary applies to the Patrol-backed correlation API +surface, which must read through the canonical intelligence facade before it +exposes learned relationship context to adjacent storage and recovery flows. The same shared API runtime also exposes unified-resource action, lifecycle, and export audit reads, but storage and recovery must continue to treat that as adjacent governed API ownership rather than timeline-store ownership. The diff --git a/internal/ai/intelligence.go b/internal/ai/intelligence.go index 8797771c5..54d3ab188 100644 --- a/internal/ai/intelligence.go +++ b/internal/ai/intelligence.go @@ -768,6 +768,44 @@ func (i *Intelligence) getLearningStats() LearningStats { return stats } +// HasCorrelationsSource reports whether the intelligence layer can provide +// learned correlation data. +func (i *Intelligence) HasCorrelationsSource() bool { + i.mu.RLock() + defer i.mu.RUnlock() + return i.correlations != nil +} + +// GetCorrelations returns canonical learned correlations for the optional +// resource scope. +func (i *Intelligence) GetCorrelations(resourceID string) []*correlation.Correlation { + i.mu.RLock() + detector := i.correlations + i.mu.RUnlock() + + if detector == nil { + return nil + } + + resourceID = strings.TrimSpace(resourceID) + if resourceID != "" { + return detector.GetCorrelationsForResource(resourceID) + } + return detector.GetCorrelations() +} + +// FormatCorrelationsContext returns the canonical AI prompt section for learned +// correlations. +func (i *Intelligence) FormatCorrelationsContext(resourceID string) string { + i.mu.RLock() + detector := i.correlations + i.mu.RUnlock() + if detector == nil { + return "" + } + return detector.FormatForContext(resourceID) +} + func (i *Intelligence) calculateOverallHealth(summary *IntelligenceSummary) HealthScore { health := HealthScore{ Score: 100, diff --git a/internal/ai/intelligence_test.go b/internal/ai/intelligence_test.go index 7dabeff4c..f6f2a1a64 100644 --- a/internal/ai/intelligence_test.go +++ b/internal/ai/intelligence_test.go @@ -389,6 +389,55 @@ func TestIntelligence_GetRecentChanges_FallsBackToMemoryDetector(t *testing.T) { } } +func TestIntelligence_GetCorrelations_UsesCanonicalFacade(t *testing.T) { + intel := NewIntelligence(IntelligenceConfig{}) + detector := correlation.NewDetector(correlation.Config{ + MaxEvents: 10, + CorrelationWindow: 2 * time.Hour, + MinOccurrences: 1, + RetentionWindow: 24 * time.Hour, + }) + base := time.Now().Add(-30 * time.Minute) + detector.RecordEvent(correlation.Event{ + ResourceID: "node-1", + ResourceName: "node-1", + ResourceType: "node", + EventType: correlation.EventHighCPU, + Timestamp: base, + }) + detector.RecordEvent(correlation.Event{ + ResourceID: "vm-1", + ResourceName: "vm-1", + ResourceType: "vm", + EventType: correlation.EventRestart, + Timestamp: base.Add(1 * time.Minute), + }) + intel.SetSubsystems(nil, nil, detector, nil, nil, nil, nil, nil) + + if !intel.HasCorrelationsSource() { + t.Fatal("expected correlation source to be available") + } + + correlations := intel.GetCorrelations("vm-1") + if len(correlations) != 1 { + t.Fatalf("expected 1 canonical correlation, got %d", len(correlations)) + } + if correlations[0].TargetID != "vm-1" { + t.Fatalf("target id = %q, want vm-1", correlations[0].TargetID) + } + + ctx := intel.FormatCorrelationsContext("vm-1") + for _, want := range []string{ + "## Resource Correlations", + "node-1", + "vm-1", + } { + if !strings.Contains(ctx, want) { + t.Fatalf("expected canonical correlation context %q to contain %q", ctx, want) + } + } +} + func TestIntelligence_DescribeResource_UsesUnifiedProvider(t *testing.T) { intel := NewIntelligence(IntelligenceConfig{}) intel.SetUnifiedResourceProvider(&mockUnifiedResourceProvider{ diff --git a/internal/ai/patrol_ai.go b/internal/ai/patrol_ai.go index 6fc95444f..450a2ed6f 100644 --- a/internal/ai/patrol_ai.go +++ b/internal/ai/patrol_ai.go @@ -1500,7 +1500,6 @@ func (p *PatrolService) seedPrecomputeIntelligenceState(snap patrolRuntimeState, mh := p.metricsHistory pd := p.patternDetector cd := p.changeDetector - corrDet := p.correlationDetector p.mu.RUnlock() var intel seedIntelligence @@ -1619,8 +1618,8 @@ func (p *PatrolService) seedPrecomputeIntelligenceState(snap patrolRuntimeState, } // Correlations - if corrDet != nil { - allCorrs := corrDet.GetCorrelations() + if intelFacade := p.GetIntelligence(); intelFacade != nil && intelFacade.HasCorrelationsSource() { + allCorrs := intelFacade.GetCorrelations("") for _, c := range allCorrs { if !seedIsInScope(scopedSet, c.SourceID) && !seedIsInScope(scopedSet, c.TargetID) { continue diff --git a/internal/ai/service.go b/internal/ai/service.go index 4c5c7429a..b02279d2e 100644 --- a/internal/ai/service.go +++ b/internal/ai/service.go @@ -4827,10 +4827,9 @@ func (s *Service) buildEnrichedResourceContext(resourceID, _ string, currentMetr sections = append(sections, graphContext) } - // Get shared correlation detector context for learned related resources. - correlationDetector := patrol.GetCorrelationDetector() - if correlationDetector != nil { - if ctx := correlationDetector.FormatForContext(resourceID); ctx != "" { + // Get shared correlation context for learned related resources. + if intel := patrol.GetIntelligence(); intel != nil { + if ctx := intel.FormatCorrelationsContext(resourceID); ctx != "" { sections = append(sections, ctx) } } diff --git a/internal/api/ai_intelligence_handlers.go b/internal/api/ai_intelligence_handlers.go index e4f6560e9..43f47ac52 100644 --- a/internal/api/ai_intelligence_handlers.go +++ b/internal/api/ai_intelligence_handlers.go @@ -289,11 +289,11 @@ func (h *AISettingsHandler) HandleGetCorrelations(w http.ResponseWriter, r *http return } - detector := patrol.GetCorrelationDetector() - if detector == nil { + intel := patrol.GetIntelligence() + if intel == nil || !intel.HasCorrelationsSource() { if err := utils.WriteJSONResponse(w, map[string]interface{}{ "correlations": []interface{}{}, - "message": "Correlation detector not initialized", + "message": "Correlation intelligence not initialized", }); err != nil { log.Error().Err(err).Msg("Failed to write correlations response") } @@ -302,13 +302,7 @@ func (h *AISettingsHandler) HandleGetCorrelations(w http.ResponseWriter, r *http // Get resource filter if provided resourceID := r.URL.Query().Get("resource_id") - - var correlations []*ai.Correlation - if resourceID != "" { - correlations = detector.GetCorrelationsForResource(resourceID) - } else { - correlations = detector.GetCorrelations() - } + correlations := intel.GetCorrelations(resourceID) var result []map[string]interface{} for _, corr := range correlations { diff --git a/internal/api/ai_intelligence_handlers_test.go b/internal/api/ai_intelligence_handlers_test.go index f93199175..be38f0719 100644 --- a/internal/api/ai_intelligence_handlers_test.go +++ b/internal/api/ai_intelligence_handlers_test.go @@ -232,6 +232,48 @@ func TestHandleGetCorrelations_ResourceIDFilter(t *testing.T) { } } +func TestHandleGetCorrelations_UsesCanonicalIntelligenceFacade(t *testing.T) { + t.Setenv("PULSE_MOCK_MODE", "true") + handler, _ := setupAIHandlerWithIntelligence(t) + + handler.defaultAIService.SetCorrelationDetector(seedCorrelationDetector(time.Now())) + intel := handler.defaultAIService.GetPatrolService().GetIntelligence() + if intel == nil || !intel.HasCorrelationsSource() { + t.Fatal("expected correlation source to be available through intelligence facade") + } + if correlations := intel.GetCorrelations("vm-1"); len(correlations) != 1 { + t.Fatalf("expected 1 canonical correlation from intelligence facade, got %d", len(correlations)) + } + if ctx := intel.FormatCorrelationsContext("vm-1"); !strings.Contains(ctx, "## Resource Correlations") { + t.Fatalf("expected canonical correlation context, got %q", ctx) + } + + req := httptest.NewRequest(http.MethodGet, "/api/ai/intelligence/correlations?resource_id=vm-1", nil) + rec := httptest.NewRecorder() + + handler.HandleGetCorrelations(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + + var resp struct { + Correlations []struct { + TargetID string `json:"target_id"` + } `json:"correlations"` + Count int `json:"count"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if resp.Count != 1 || len(resp.Correlations) != 1 { + t.Fatalf("correlations count = %d, want 1", resp.Count) + } + if resp.Correlations[0].TargetID != "vm-1" { + t.Fatalf("target_id = %s, want vm-1", resp.Correlations[0].TargetID) + } +} + // TestHandleGetRecentChanges tests the changes endpoint func TestHandleGetRecentChanges_MethodNotAllowed(t *testing.T) { t.Parallel() diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 3626d10a2..01cf1899c 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -527,6 +527,56 @@ func TestContract_RecentChangesEndpointUsesCanonicalTimeline(t *testing.T) { } } +func TestContract_AIIntelligenceCorrelationsJSONSnapshot(t *testing.T) { + now := time.Date(2026, 3, 18, 17, 30, 0, 0, time.UTC) + payload := map[string]any{ + "correlations": []map[string]any{ + { + "source_id": "node-1", + "source_name": "node-1", + "source_type": "node", + "target_id": "vm-1", + "target_name": "vm-1", + "target_type": "vm", + "event_pattern": "high_cpu -> restart", + "occurrences": 1, + "avg_delay": "1m0s", + "confidence": 0.1, + "last_seen": now, + "description": "When node-1 experiences high_cpu, vm-1 often follows within 1m0s", + }, + }, + "count": 1, + } + + got, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal correlations response: %v", err) + } + + const want = `{ + "correlations":[ + { + "avg_delay":"1m0s", + "confidence":0.1, + "description":"When node-1 experiences high_cpu, vm-1 often follows within 1m0s", + "event_pattern":"high_cpu -\u003e restart", + "last_seen":"2026-03-18T17:30:00Z", + "occurrences":1, + "source_id":"node-1", + "source_name":"node-1", + "source_type":"node", + "target_id":"vm-1", + "target_name":"vm-1", + "target_type":"vm" + } + ], + "count":1 + }` + + assertJSONSnapshot(t, got, want) +} + func TestContract_ResolveAuthEnvPathUsesCanonicalRuntimeDataDir(t *testing.T) { envDir := t.TempDir() t.Setenv("PULSE_DATA_DIR", envDir) diff --git a/internal/unifiedresources/code_standards_test.go b/internal/unifiedresources/code_standards_test.go index 1f51a1a69..075e4532e 100644 --- a/internal/unifiedresources/code_standards_test.go +++ b/internal/unifiedresources/code_standards_test.go @@ -342,6 +342,21 @@ func TestResourcePolicyCloneHelperUsedByAIConsumers(t *testing.T) { func TestResourcePolicyLabelHelpersUsedByAIConsumers(t *testing.T) { requiredSnippets := map[string][]string{ + filepath.Join("..", "ai", "intelligence.go"): { + "func (i *Intelligence) HasCorrelationsSource() bool", + "func (i *Intelligence) GetCorrelations(resourceID string) []*correlation.Correlation", + "func (i *Intelligence) FormatCorrelationsContext(resourceID string) string", + }, + filepath.Join("..", "ai", "service.go"): { + "intel.FormatCorrelationsContext(resourceID)", + }, + filepath.Join("..", "ai", "patrol_ai.go"): { + "intelFacade.GetCorrelations(\"\")", + }, + filepath.Join("..", "api", "ai_intelligence_handlers.go"): { + "intel.HasCorrelationsSource()", + "intel.GetCorrelations(resourceID)", + }, filepath.Join("..", "ai", "chat", "knowledge_extractor.go"): { "unifiedresources.ResourcePolicyLabel(", "unifiedresources.ResourcePolicyRedactedValue(", @@ -566,7 +581,7 @@ func TestResourceGraphContextUsesCanonicalRelationshipPresentation(t *testing.T) "unifiedresources.FormatResourceGraphContext(resource, 3)", "unifiedresources.FormatResourceRecentChangesContext(changes, false, \"###\")", "type canonicalResourceGetter interface {", - "correlationDetector.FormatForContext(resourceID)", + "intel.FormatCorrelationsContext(resourceID)", } for _, snippet := range requiredSnippets { if !strings.Contains(source, snippet) {