From 175f8b4bf15bb6f23c1287e1ffae30d000f9918c Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sat, 25 Apr 2026 22:07:57 +0100 Subject: [PATCH] Add relationship-aware resource timelines --- docs/release-control/v6/internal/status.json | 179 +++++++++++++----- .../v6/internal/subsystems/agent-lifecycle.md | 5 + .../v6/internal/subsystems/api-contracts.md | 8 +- .../internal/subsystems/storage-recovery.md | 5 + .../internal/subsystems/unified-resources.md | 6 + internal/api/contract_test.go | 106 +++++++++++ internal/api/resources.go | 2 + internal/api/resources_test.go | 76 +++++++- internal/unifiedresources/changes.go | 1 + .../unifiedresources/code_standards_test.go | 1 + internal/unifiedresources/store.go | 42 +++- internal/unifiedresources/store_test.go | 169 +++++++++++++++++ scripts/release_control/status_lookup_test.py | 2 +- .../release_control/subsystem_lookup_test.py | 2 +- 14 files changed, 539 insertions(+), 65 deletions(-) diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json index 606f60314..53676d3f1 100644 --- a/docs/release-control/v6/internal/status.json +++ b/docs/release-control/v6/internal/status.json @@ -3651,6 +3651,122 @@ "kind": "file" } ] + }, + { + "id": "L19", + "name": "Resource change intelligence", + "target_score": 6, + "current_score": 6, + "status": "partial", + "completion": { + "state": "bounded-residual", + "summary": "Resource change intelligence now has a first-class governed floor: canonical resource relationships, durable resource-change envelopes, AI/Patrol recent-change presentation, dedicated resource timeline/facet reads, and relationship-aware resource timelines are all owned by the unified-resource and API-contract boundary. Broader surfaced timeline IA, relationship graph exploration, enterprise correlation depth, and cross-resource investigation workflows remain a named post-RC hardening track.", + "tracking": [ + { + "kind": "lane-followup", + "id": "resource-change-intelligence-post-rc-hardening" + } + ] + }, + "blockers": [], + "subsystems": [], + "evidence": [ + { + "repo": "pulse", + "path": "docs/release-control/v6/internal/subsystems/api-contracts.md", + "kind": "file" + }, + { + "repo": "pulse", + "path": "docs/release-control/v6/internal/subsystems/unified-resources.md", + "kind": "file" + }, + { + "repo": "pulse", + "path": "frontend-modern/src/components/Infrastructure/ResourceChangeSummary.tsx", + "kind": "file" + }, + { + "repo": "pulse", + "path": "frontend-modern/src/utils/resourceChangePresentation.ts", + "kind": "file" + }, + { + "repo": "pulse", + "path": "internal/ai/intelligence.go", + "kind": "file" + }, + { + "repo": "pulse", + "path": "internal/ai/patrol_ai.go", + "kind": "file" + }, + { + "repo": "pulse", + "path": "internal/api/resources.go", + "kind": "file" + }, + { + "repo": "pulse", + "path": "internal/api/resources_test.go", + "kind": "file" + }, + { + "repo": "pulse", + "path": "internal/unifiedresources/change_emission.go", + "kind": "file" + }, + { + "repo": "pulse", + "path": "internal/unifiedresources/change_emission_test.go", + "kind": "file" + }, + { + "repo": "pulse", + "path": "internal/unifiedresources/change_filters.go", + "kind": "file" + }, + { + "repo": "pulse", + "path": "internal/unifiedresources/change_presentation.go", + "kind": "file" + }, + { + "repo": "pulse", + "path": "internal/unifiedresources/change_presentation_test.go", + "kind": "file" + }, + { + "repo": "pulse", + "path": "internal/unifiedresources/changes.go", + "kind": "file" + }, + { + "repo": "pulse", + "path": "internal/unifiedresources/relationship_presentation.go", + "kind": "file" + }, + { + "repo": "pulse", + "path": "internal/unifiedresources/relationship_presentation_test.go", + "kind": "file" + }, + { + "repo": "pulse", + "path": "internal/unifiedresources/relationships.go", + "kind": "file" + }, + { + "repo": "pulse", + "path": "internal/unifiedresources/store.go", + "kind": "file" + }, + { + "repo": "pulse", + "path": "internal/unifiedresources/store_test.go", + "kind": "file" + } + ] } ], "release_gates": [ @@ -4357,6 +4473,17 @@ "L18" ], "subsystem_ids": [] + }, + { + "id": "resource-change-intelligence-post-rc-hardening", + "summary": "Track broader resource-change intelligence hardening beyond the current canonical timeline floor, including surfaced cross-resource timeline IA, relationship graph exploration, enterprise correlation depth, and investigation workflows that promote relationship-aware timelines from backend foundation into a primary operator experience.", + "owner": "project-owner", + "status": "planned", + "recorded_at": "2026-04-25", + "lane_ids": [ + "L19" + ], + "subsystem_ids": [] } ], "coverage_gaps": [ @@ -4418,37 +4545,6 @@ } ] }, - { - "id": "resource-change-and-timeline", - "summary": "The current v6 lane map proves unified resources and monitoring floors, but the resource-change and cross-resource timeline work is now kept as hidden backend foundation for investigation and AI flows until the surfaced case is proven.", - "owner": "project-owner", - "status": "planned", - "recorded_at": "2026-03-17", - "lane_ids": [ - "L6", - "L13" - ], - "subsystem_ids": [ - "alerts", - "api-contracts", - "monitoring", - "unified-resources" - ], - "proposed_resolution": "lane-split", - "coverage_impact": 15, - "evidence": [ - { - "repo": "pulse", - "path": "docs/release-control/v6/internal/subsystems/unified-resources.md", - "kind": "file" - }, - { - "repo": "pulse", - "path": "docs/release-control/v6/internal/V6_BRIDGE_RELEASE_FOUNDATION_SPEC.md", - "kind": "file" - } - ] - }, { "id": "platform-admission-execution", "summary": "The platform support model now governs admission decisions, and VMware vSphere is already the current admitted strategic next-platform direction, but the lane map still underrepresents the cross-surface work needed to carry an admitted first-class platform from architecture lock to a proved support floor across setup, canonical projections, alerts, assistant read, and bounded control classification.", @@ -4593,27 +4689,6 @@ "agent-lifecycle" ] }, - { - "id": "resource-change-intelligence", - "name": "Resource change intelligence", - "summary": "Promote canonical resource relationships, first-class change envelopes, and cross-resource timelines into an explicit lane once the monitoring-first surface proves the case; keep the backend foundations hidden until then.", - "status": "planned", - "recorded_at": "2026-03-17", - "target_id": "v6-product-lane-expansion", - "current_lane_ids": [ - "L6", - "L13" - ], - "coverage_gap_ids": [ - "resource-change-and-timeline" - ], - "subsystem_ids": [ - "alerts", - "api-contracts", - "monitoring", - "unified-resources" - ] - }, { "id": "platform-admission-execution", "name": "Platform admission execution", diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index e75de46e2..976e2933c 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -923,6 +923,11 @@ Those timeline reads also accept governed filters for change kind, source type, and source adapter, and the underlying store owns the filtered counts so agent lifecycle routing still stays on canonical fleet-continuity ownership instead of re-deriving resource history locally. +Those dedicated resource timeline and facet reads are also relationship-aware +at the API boundary: lifecycle-adjacent fleet views may consume the direct plus +`relatedResources` history returned by `internal/api/resources.go`, but they +must not rebuild cross-resource timeline joins inside lifecycle-owned routes or +change the direct-only store default used by other callers. That same shared `internal/api/` boundary now also exposes a dedicated VM inventory export route for reporting. Fleet and install surfaces may coexist with that export, but `internal/api/reporting_inventory_handlers.go` and diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 0c99cfb16..a5d460cf3 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -212,7 +212,13 @@ the canonical monitored-system blocked payload. resource API JSON, and exercised with backend contract tests plus the canonical `useUnifiedResources` frontend hook proof whenever it changes. 5. Route unified-resource action, lifecycle, and export audit reads through `internal/api/activity_audit_handlers.go`, `internal/api/router_routes_licensing.go`, and `internal/api/contract_test.go` together so the control-plane execution trail stays on a governed API contract instead of a store-only shape -6. Route dedicated unified-resource timeline and facet-bundle reads through `frontend-modern/src/api/resources.ts`, `internal/api/resources.go`, and `internal/api/contract_test.go` together so the backend facet contract and the frontend client stay aligned on one timeline-first surface, while capability and relationship detail stays backend-owned for AI correlation and change detection +6. Route dedicated unified-resource timeline and facet-bundle reads through `frontend-modern/src/api/resources.ts`, `internal/api/resources.go`, and `internal/api/contract_test.go` together so the backend facet contract and the frontend client stay aligned on one timeline-first surface, while capability and relationship detail stays backend-owned for AI correlation and change detection. + `/api/resources/{id}/timeline` and `/api/resources/{id}/facets` must keep + resource timelines relationship-aware by opting into the canonical + `ResourceChangeFilters.IncludeRelated` store path, so a resource timeline + includes direct changes and changes that name the resource in + `relatedResources` instead of hiding child or dependency activity from the + owning resource. 7. Route unified-resource list ordering through `internal/api/resources.go`, `internal/api/contract_test.go`, and the owned unified-resource registry helpers together; list payloads must stay deterministic for equal-name resources by carrying one canonical `name -> type -> id` tie-break across cold seed, REST pagination, and websocket-backed refreshes instead of inheriting map order or page-local re-sorts That same shared API contract also owns the external resource `type`, canonical display name, and cluster identity published through `/api/resources` and `/api/state`; the websocket/state hydrate path must not emit legacy aliases or raw store labels once the unified resource contract has normalized them. 8. Route unified-agent installer and binary download headers through `internal/api/unified_agent.go` and `internal/api/contract_test.go` together; published release downloads must keep the canonical `X-Checksum-Sha256` plus `X-Signature-Ed25519` contract for updater clients and the base64-encoded `X-Signature-SSHSIG` contract for installer clients whether the asset is served locally or proxied from the matching GitHub release, instead of leaving callers to infer trust from source location alone. diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 7d3a540bd..85f83000a 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -1281,6 +1281,11 @@ Those resource timeline reads now also accept governed kind and source-type filters plus source-adapter filters, with filtered history counts owned by the unified-resource store so storage and recovery views can consume the same canonical history contract without re-deriving their own timeline slices. +Those same dedicated timeline and facet reads are relationship-aware at the API +boundary: storage and recovery detail views may consume direct changes plus +changes whose `relatedResources` names the current canonical resource, but they +must not rebuild a storage-local cross-resource timeline join or widen the +direct-only history default used by non-resource-detail callers. Invalid `sourceAdapter` values are rejected at the API boundary, which keeps storage and recovery reads aligned with the canonical adapter set instead of turning the timeline filter into an arbitrary free-text escape hatch. diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index a213c4ba6..066b1ef92 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -946,6 +946,12 @@ write when the target database still requires it. `/api/resources/{id}/timeline` reads, while the bundled `/api/resources/{id}/facets` surface keeps the facet summary and recent-change history available without forcing consumers to parse the full resource payload. +Those resource-owned timeline and facet reads are relationship-aware at the +API boundary: when the drawer requests a resource timeline, the store must +return direct changes for that canonical ID plus changes whose +`relatedResources` contains the same ID. The default store +`GetRecentChanges` path remains direct-resource-only for incident and AI +callers unless they explicitly opt into `ResourceChangeFilters.IncludeRelated`. Those filtered timeline reads are backed by dedicated `resource_changes` indexes on `canonical_id`, `kind`, `source_type`, and `observed_at`, so the canonical history path stays fast as the filtered timeline grows instead of diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index f6c878c39..1910bd696 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -11283,6 +11283,112 @@ func TestContract_ResourceFacetsJSONSnapshot(t *testing.T) { assertJSONSnapshot(t, got, want) } +func TestContract_ResourceTimelineEndpointsIncludeRelatedChanges(t *testing.T) { + now := time.Date(2026, 4, 25, 22, 15, 0, 0, time.UTC) + h := NewResourceHandlers(&config.Config{DataPath: t.TempDir()}) + h.SetStateProvider(resourceUnifiedSeedProvider{ + snapshot: models.StateSnapshot{LastUpdate: now}, + resources: []unifiedresources.Resource{ + { + ID: "node-contract-relationship", + Type: unifiedresources.ResourceTypeAgent, + Name: "node-contract-relationship", + Status: unifiedresources.StatusOnline, + LastSeen: now, + }, + { + ID: "vm-contract-relationship", + Type: unifiedresources.ResourceTypeVM, + Name: "vm-contract-relationship", + Status: unifiedresources.StatusOnline, + LastSeen: now, + }, + }, + }) + + store, err := h.getStore("default") + if err != nil { + t.Fatalf("get resource store: %v", err) + } + for _, change := range []unifiedresources.ResourceChange{ + { + ID: "change-related-contract", + ResourceID: "vm-contract-relationship", + ObservedAt: now, + Kind: unifiedresources.ChangeRestart, + SourceType: unifiedresources.SourcePlatformEvent, + SourceAdapter: unifiedresources.AdapterProxmox, + Confidence: unifiedresources.ConfidenceHigh, + RelatedResources: []string{" node-contract-relationship "}, + }, + { + ID: "change-direct-contract", + ResourceID: "node-contract-relationship", + ObservedAt: now.Add(-time.Minute), + Kind: unifiedresources.ChangeStateTransition, + SourceType: unifiedresources.SourcePulseDiff, + SourceAdapter: unifiedresources.AdapterProxmox, + Confidence: unifiedresources.ConfidenceMedium, + }, + } { + if err := store.RecordChange(change); err != nil { + t.Fatalf("record %s: %v", change.ID, err) + } + } + + timelineRec := httptest.NewRecorder() + timelineReq := httptest.NewRequest(http.MethodGet, "/api/resources/node-contract-relationship/timeline?limit=10", nil) + h.HandleResourceRoutes(timelineRec, timelineReq) + if timelineRec.Code != http.StatusOK { + t.Fatalf("timeline status = %d, body=%s", timelineRec.Code, timelineRec.Body.String()) + } + var timeline struct { + ResourceID string `json:"resourceId"` + RecentChanges []unifiedresources.ResourceChange `json:"recentChanges"` + Count int `json:"count"` + } + if err := json.NewDecoder(timelineRec.Body).Decode(&timeline); err != nil { + t.Fatalf("decode relationship-aware timeline: %v", err) + } + if timeline.ResourceID != "node-contract-relationship" || timeline.Count != 2 || len(timeline.RecentChanges) != 2 { + t.Fatalf("unexpected relationship-aware timeline: %#v", timeline) + } + if timeline.RecentChanges[0].ID != "change-related-contract" || timeline.RecentChanges[0].ResourceID != "vm-contract-relationship" { + t.Fatalf("timeline did not preserve related originating resource: %#v", timeline.RecentChanges) + } + + facetsRec := httptest.NewRecorder() + facetsReq := httptest.NewRequest(http.MethodGet, "/api/resources/node-contract-relationship/facets?kind=restart&limit=10", nil) + h.HandleResourceRoutes(facetsRec, facetsReq) + if facetsRec.Code != http.StatusOK { + t.Fatalf("facets status = %d, body=%s", facetsRec.Code, facetsRec.Body.String()) + } + var facets struct { + ResourceID string `json:"resourceId"` + RecentChanges []unifiedresources.ResourceChange `json:"recentChanges"` + Counts struct { + RecentChanges int `json:"recentChanges"` + RecentChangeKinds map[unifiedresources.ChangeKind]int `json:"recentChangeKinds"` + RecentAdapters map[unifiedresources.ChangeSourceAdapter]int `json:"recentChangeSourceAdapters"` + } `json:"counts"` + } + if err := json.NewDecoder(facetsRec.Body).Decode(&facets); err != nil { + t.Fatalf("decode relationship-aware facets: %v", err) + } + if facets.ResourceID != "node-contract-relationship" || facets.Counts.RecentChanges != 1 || len(facets.RecentChanges) != 1 { + t.Fatalf("unexpected relationship-aware facets: %#v", facets) + } + if facets.RecentChanges[0].ID != "change-related-contract" { + t.Fatalf("facets did not include related restart: %#v", facets.RecentChanges) + } + if got := facets.Counts.RecentChangeKinds[unifiedresources.ChangeRestart]; got != 1 { + t.Fatalf("restart facet count = %d, want 1", got) + } + if got := facets.Counts.RecentAdapters[unifiedresources.AdapterProxmox]; got != 1 { + t.Fatalf("adapter facet count = %d, want 1", got) + } +} + func TestContract_ResourceTimelineRejectsInvalidSourceAdapter(t *testing.T) { _, err := unifiedresources.ParseResourceChangeFilters(nil, nil, []string{"unsupported_adapter"}) if err == nil { diff --git a/internal/api/resources.go b/internal/api/resources.go index 278daa4dd..1d1dadf65 100644 --- a/internal/api/resources.go +++ b/internal/api/resources.go @@ -398,6 +398,7 @@ func (h *ResourceHandlers) HandleGetResourceFacets(w http.ResponseWriter, r *htt http.Error(w, err.Error(), http.StatusBadRequest) return } + filters.IncludeRelated = true recentChanges, err := store.GetRecentChangesFiltered(resourceID, since, limit, filters) if err != nil { @@ -550,6 +551,7 @@ func (h *ResourceHandlers) HandleGetResourceTimeline(w http.ResponseWriter, r *h http.Error(w, err.Error(), http.StatusBadRequest) return } + filters.IncludeRelated = true changes, err := store.GetRecentChangesFiltered(resourceID, since, limit, filters) if err != nil { diff --git a/internal/api/resources_test.go b/internal/api/resources_test.go index b400b8638..87ae8d9af 100644 --- a/internal/api/resources_test.go +++ b/internal/api/resources_test.go @@ -968,12 +968,19 @@ func TestResourceGetFacetsAndTimeline(t *testing.T) { }, }, } + node := unified.Resource{ + ID: "node-1", + Type: unified.ResourceTypeAgent, + Name: "pve-node-1", + Status: unified.StatusOnline, + LastSeen: now, + } cfg := &config.Config{DataPath: t.TempDir()} h := NewResourceHandlers(cfg) h.SetStateProvider(resourceUnifiedSeedProvider{ snapshot: models.StateSnapshot{LastUpdate: now}, - resources: []unified.Resource{resource}, + resources: []unified.Resource{resource, node}, }) store, err := h.getStore("default") @@ -1016,6 +1023,18 @@ func TestResourceGetFacetsAndTimeline(t *testing.T) { t.Fatalf("RecordChange extra %d: %v", i+1, err) } } + if err := store.RecordChange(unified.ResourceChange{ + ID: "chg-node-direct", + ResourceID: "node-1", + ObservedAt: now.Add(-30 * time.Second), + Kind: unified.ChangeStateTransition, + SourceType: unified.SourcePulseDiff, + SourceAdapter: unified.AdapterProxmox, + Confidence: unified.ConfidenceMedium, + Reason: "node state refreshed", + }); err != nil { + t.Fatalf("RecordChange node direct: %v", err) + } t.Run("facets", func(t *testing.T) { rec := httptest.NewRecorder() @@ -1083,6 +1102,32 @@ func TestResourceGetFacetsAndTimeline(t *testing.T) { } }) + t.Run("relationship-aware timeline", func(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/resources/node-1/timeline?limit=10", nil) + h.HandleResourceRoutes(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) + } + var payload struct { + ResourceID string `json:"resourceId"` + RecentChanges []unified.ResourceChange `json:"recentChanges"` + Count int `json:"count"` + } + if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil { + t.Fatalf("decode relationship-aware timeline: %v", err) + } + if payload.ResourceID != "node-1" || payload.Count != 4 || len(payload.RecentChanges) != 4 { + t.Fatalf("unexpected relationship-aware timeline payload: %#v", payload) + } + if payload.RecentChanges[0].ID != "chg-42" || payload.RecentChanges[1].ID != "chg-node-direct" { + t.Fatalf("relationship-aware timeline order = %#v, want related vm event then direct node event", payload.RecentChanges) + } + if got := payload.RecentChanges[0].ResourceID; got != "vm:42" { + t.Fatalf("related timeline preserves originating resource ID = %q, want vm:42", got) + } + }) + t.Run("filtered timeline", func(t *testing.T) { rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/resources/vm:42/timeline?kind=restart&sourceType=platform_event", nil) @@ -1169,6 +1214,35 @@ func TestResourceGetFacetsAndTimeline(t *testing.T) { } }) + t.Run("relationship-aware filtered facets", func(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/resources/node-1/facets?kind=restart&limit=10", nil) + h.HandleResourceRoutes(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) + } + var payload struct { + ResourceID string `json:"resourceId"` + RecentChanges []unified.ResourceChange `json:"recentChanges"` + Counts struct { + RecentChanges int `json:"recentChanges"` + RecentChangeKinds map[unified.ChangeKind]int `json:"recentChangeKinds"` + } `json:"counts"` + } + if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil { + t.Fatalf("decode relationship-aware filtered facets: %v", err) + } + if payload.ResourceID != "node-1" || payload.Counts.RecentChanges != 1 || len(payload.RecentChanges) != 1 { + t.Fatalf("unexpected relationship-aware filtered facets payload: %#v", payload) + } + if payload.RecentChanges[0].ID != "chg-42" { + t.Fatalf("unexpected relationship-aware filtered facets change: %#v", payload.RecentChanges[0]) + } + if got := payload.Counts.RecentChangeKinds[unified.ChangeRestart]; got != 1 { + t.Fatalf("relationship-aware restart facet count = %d, want 1", got) + } + }) + t.Run("filtered facets by source adapter", func(t *testing.T) { rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/resources/vm:42/facets?sourceAdapter=docker_adapter", nil) diff --git a/internal/unifiedresources/changes.go b/internal/unifiedresources/changes.go index ed1605a89..1bd067be2 100644 --- a/internal/unifiedresources/changes.go +++ b/internal/unifiedresources/changes.go @@ -60,6 +60,7 @@ type ResourceChangeFilters struct { Kinds []ChangeKind `json:"kinds,omitempty"` SourceTypes []ChangeSourceType `json:"sourceTypes,omitempty"` SourceAdapters []ChangeSourceAdapter `json:"sourceAdapters,omitempty"` + IncludeRelated bool `json:"includeRelated,omitempty"` } func (filters ResourceChangeFilters) matches(change ResourceChange) bool { diff --git a/internal/unifiedresources/code_standards_test.go b/internal/unifiedresources/code_standards_test.go index 0851a9988..cd49c77db 100644 --- a/internal/unifiedresources/code_standards_test.go +++ b/internal/unifiedresources/code_standards_test.go @@ -297,6 +297,7 @@ func TestResourceAPIExposesDedicatedFacetReads(t *testing.T) { "HandleGetResourceFacets", "HandleGetResourceTimeline", "unified.ParseResourceChangeFilters(r.URL.Query()[\"kind\"], r.URL.Query()[\"sourceType\"], r.URL.Query()[\"sourceAdapter\"])", + "filters.IncludeRelated = true", "GetRecentChangesFiltered(resourceID, since, limit, filters)", "CountRecentChangesFiltered(resourceID, since, filters)", "CountRecentChangesByKindFiltered(resourceID, since, filters)", diff --git a/internal/unifiedresources/store.go b/internal/unifiedresources/store.go index 513bd4ae4..c0dd3c3d7 100644 --- a/internal/unifiedresources/store.go +++ b/internal/unifiedresources/store.go @@ -699,8 +699,7 @@ func (s *SQLiteResourceStore) GetRecentChangesFiltered(canonicalID string, since conditions := []string{} canonicalID = CanonicalResourceID(canonicalID) if canonicalID != "" { - conditions = append(conditions, "canonical_id = ?") - args = append(args, canonicalID) + conditions, args = appendRecentChangeResourceCondition(conditions, args, canonicalID, filters.IncludeRelated) } else { conditions = append(conditions, "observed_at >= ?") args = append(args, since) @@ -1218,7 +1217,7 @@ func (m *MemoryStore) GetRecentChangesFiltered(canonicalID string, since time.Ti var out []ResourceChange for i := len(m.changes) - 1; i >= 0; i-- { change := m.changes[i] - if canonicalID != "" && CanonicalResourceID(change.ResourceID) != canonicalID { + if canonicalID != "" && !changeMatchesResource(change, canonicalID, filters.IncludeRelated) { continue } if !since.IsZero() && change.ObservedAt.Before(since) { @@ -1245,7 +1244,7 @@ func (m *MemoryStore) CountRecentChangesFiltered(canonicalID string, since time. canonicalID = CanonicalResourceID(canonicalID) count := 0 for _, change := range m.changes { - if canonicalID != "" && CanonicalResourceID(change.ResourceID) != canonicalID { + if canonicalID != "" && !changeMatchesResource(change, canonicalID, filters.IncludeRelated) { continue } if !since.IsZero() && change.ObservedAt.Before(since) { @@ -1269,7 +1268,7 @@ func (m *MemoryStore) CountRecentChangesByKindFiltered(canonicalID string, since canonicalID = CanonicalResourceID(canonicalID) counts := make(map[ChangeKind]int) for _, change := range m.changes { - if canonicalID != "" && CanonicalResourceID(change.ResourceID) != canonicalID { + if canonicalID != "" && !changeMatchesResource(change, canonicalID, filters.IncludeRelated) { continue } if !since.IsZero() && change.ObservedAt.Before(since) { @@ -1296,7 +1295,7 @@ func (m *MemoryStore) CountRecentChangesBySourceTypeFiltered(canonicalID string, canonicalID = CanonicalResourceID(canonicalID) counts := make(map[ChangeSourceType]int) for _, change := range m.changes { - if canonicalID != "" && CanonicalResourceID(change.ResourceID) != canonicalID { + if canonicalID != "" && !changeMatchesResource(change, canonicalID, filters.IncludeRelated) { continue } if !since.IsZero() && change.ObservedAt.Before(since) { @@ -1323,7 +1322,7 @@ func (m *MemoryStore) CountRecentChangesBySourceAdapterFiltered(canonicalID stri canonicalID = CanonicalResourceID(canonicalID) counts := make(map[ChangeSourceAdapter]int) for _, change := range m.changes { - if canonicalID != "" && CanonicalResourceID(change.ResourceID) != canonicalID { + if canonicalID != "" && !changeMatchesResource(change, canonicalID, filters.IncludeRelated) { continue } if !since.IsZero() && change.ObservedAt.Before(since) { @@ -1347,8 +1346,7 @@ func buildRecentChangeCountQuery(canonicalID string, since time.Time, filters Re args = append(args, since) canonicalID = CanonicalResourceID(canonicalID) if canonicalID != "" { - conditions = append(conditions, "canonical_id = ?") - args = append(args, canonicalID) + conditions, args = appendRecentChangeResourceCondition(conditions, args, canonicalID, filters.IncludeRelated) } if len(filters.Kinds) > 0 { placeholders := make([]string, 0, len(filters.Kinds)) @@ -1378,6 +1376,32 @@ func buildRecentChangeCountQuery(canonicalID string, since time.Time, filters Re return query, args } +func appendRecentChangeResourceCondition(conditions []string, args []any, canonicalID string, includeRelated bool) ([]string, []any) { + if !includeRelated { + return append(conditions, "canonical_id = ?"), append(args, canonicalID) + } + return append(conditions, `(canonical_id = ? OR EXISTS ( + SELECT 1 + FROM json_each(CASE WHEN json_valid(resource_changes.related_resources) THEN resource_changes.related_resources ELSE '[]' END) + WHERE TRIM(json_each.value) = ? + ))`), append(args, canonicalID, canonicalID) +} + +func changeMatchesResource(change ResourceChange, canonicalID string, includeRelated bool) bool { + if CanonicalResourceID(change.ResourceID) == canonicalID { + return true + } + if !includeRelated { + return false + } + for _, relatedID := range change.RelatedResources { + if CanonicalResourceID(relatedID) == canonicalID { + return true + } + } + return false +} + func (m *MemoryStore) RecordActionAudit(record ActionAuditRecord) error { m.mu.Lock() defer m.mu.Unlock() diff --git a/internal/unifiedresources/store_test.go b/internal/unifiedresources/store_test.go index 43a280576..c184d59cd 100644 --- a/internal/unifiedresources/store_test.go +++ b/internal/unifiedresources/store_test.go @@ -505,6 +505,175 @@ func TestRecordChange_RoundTrip(t *testing.T) { } } +func TestResourceChangeFiltersIncludeRelatedResources(t *testing.T) { + store := newTestStore(t) + now := time.Date(2026, 4, 25, 21, 10, 0, 0, time.UTC) + changes := []ResourceChange{ + { + ID: "chg-related-node", + ResourceID: "vm:100", + ObservedAt: now, + Kind: ChangeRestart, + SourceType: SourcePlatformEvent, + SourceAdapter: AdapterProxmox, + Confidence: ConfidenceHigh, + RelatedResources: []string{" node:1 "}, + Reason: "vm restarted on node", + }, + { + ID: "chg-direct-node", + ResourceID: "node:1", + ObservedAt: now.Add(-time.Minute), + Kind: ChangeStateTransition, + SourceType: SourcePulseDiff, + SourceAdapter: AdapterProxmox, + Confidence: ConfidenceMedium, + Reason: "node status refreshed", + }, + { + ID: "chg-related-storage", + ResourceID: "storage:1", + ObservedAt: now.Add(-2 * time.Minute), + Kind: ChangeAnomaly, + SourceType: SourcePulseDiff, + SourceAdapter: AdapterTrueNAS, + Confidence: ConfidenceMedium, + RelatedResources: []string{" node:1 "}, + Reason: "storage issue affects node", + }, + } + for _, change := range changes { + if err := store.RecordChange(change); err != nil { + t.Fatalf("RecordChange(%s): %v", change.ID, err) + } + } + + directOnly, err := store.GetRecentChangesFiltered("node:1", now.Add(-time.Hour), 10, ResourceChangeFilters{}) + if err != nil { + t.Fatalf("GetRecentChangesFiltered direct: %v", err) + } + if len(directOnly) != 1 || directOnly[0].ID != "chg-direct-node" { + t.Fatalf("direct timeline = %#v, want only chg-direct-node", directOnly) + } + + timeline, err := store.GetRecentChangesFiltered("node:1", now.Add(-time.Hour), 10, ResourceChangeFilters{IncludeRelated: true}) + if err != nil { + t.Fatalf("GetRecentChangesFiltered include related: %v", err) + } + if got := changeIDs(timeline); !sameStringSet(got, []string{"chg-related-node", "chg-direct-node", "chg-related-storage"}) { + t.Fatalf("relationship-aware timeline IDs = %#v, want direct plus related changes", got) + } + if timeline[0].ID != "chg-related-node" || timeline[1].ID != "chg-direct-node" || timeline[2].ID != "chg-related-storage" { + t.Fatalf("timeline order = %#v, want observed_at desc across direct and related changes", changeIDs(timeline)) + } + + filtered, err := store.GetRecentChangesFiltered("node:1", now.Add(-time.Hour), 10, ResourceChangeFilters{ + IncludeRelated: true, + Kinds: []ChangeKind{ChangeAnomaly}, + }) + if err != nil { + t.Fatalf("GetRecentChangesFiltered related kind: %v", err) + } + if len(filtered) != 1 || filtered[0].ID != "chg-related-storage" { + t.Fatalf("filtered related timeline = %#v, want chg-related-storage", filtered) + } + + count, err := store.CountRecentChangesFiltered("node:1", now.Add(-time.Hour), ResourceChangeFilters{IncludeRelated: true}) + if err != nil { + t.Fatalf("CountRecentChangesFiltered include related: %v", err) + } + if count != 3 { + t.Fatalf("relationship-aware count = %d, want 3", count) + } + + kindCounts, err := store.CountRecentChangesByKindFiltered("node:1", now.Add(-time.Hour), ResourceChangeFilters{IncludeRelated: true}) + if err != nil { + t.Fatalf("CountRecentChangesByKindFiltered include related: %v", err) + } + if got := kindCounts[ChangeRestart]; got != 1 { + t.Fatalf("restart count = %d, want 1", got) + } + if got := kindCounts[ChangeStateTransition]; got != 1 { + t.Fatalf("state transition count = %d, want 1", got) + } + if got := kindCounts[ChangeAnomaly]; got != 1 { + t.Fatalf("anomaly count = %d, want 1", got) + } + + sourceCounts, err := store.CountRecentChangesBySourceAdapterFiltered("node:1", now.Add(-time.Hour), ResourceChangeFilters{IncludeRelated: true}) + if err != nil { + t.Fatalf("CountRecentChangesBySourceAdapterFiltered include related: %v", err) + } + if got := sourceCounts[AdapterProxmox]; got != 2 { + t.Fatalf("proxmox count = %d, want 2", got) + } + if got := sourceCounts[AdapterTrueNAS]; got != 1 { + t.Fatalf("truenas count = %d, want 1", got) + } +} + +func TestMemoryStoreResourceChangeFiltersIncludeRelatedResources(t *testing.T) { + store := NewMemoryStore() + now := time.Date(2026, 4, 25, 21, 15, 0, 0, time.UTC) + for _, change := range []ResourceChange{ + { + ID: "mem-related", + ResourceID: "vm:100", + ObservedAt: now, + Kind: ChangeRestart, + SourceType: SourcePlatformEvent, + SourceAdapter: AdapterProxmox, + Confidence: ConfidenceHigh, + RelatedResources: []string{"node:1"}, + }, + { + ID: "mem-direct", + ResourceID: "node:1", + ObservedAt: now.Add(-time.Minute), + Kind: ChangeStateTransition, + SourceType: SourcePulseDiff, + SourceAdapter: AdapterProxmox, + Confidence: ConfidenceMedium, + }, + } { + if err := store.RecordChange(change); err != nil { + t.Fatalf("RecordChange(%s): %v", change.ID, err) + } + } + + directOnly, err := store.GetRecentChangesFiltered("node:1", now.Add(-time.Hour), 10, ResourceChangeFilters{}) + if err != nil { + t.Fatalf("GetRecentChangesFiltered direct: %v", err) + } + if len(directOnly) != 1 || directOnly[0].ID != "mem-direct" { + t.Fatalf("direct memory timeline = %#v, want only mem-direct", directOnly) + } + + timeline, err := store.GetRecentChangesFiltered("node:1", now.Add(-time.Hour), 10, ResourceChangeFilters{IncludeRelated: true}) + if err != nil { + t.Fatalf("GetRecentChangesFiltered include related: %v", err) + } + if len(timeline) != 2 || timeline[0].ID != "mem-direct" || timeline[1].ID != "mem-related" { + t.Fatalf("relationship-aware memory timeline = %#v, want reverse insertion order direct plus related", timeline) + } + + count, err := store.CountRecentChangesFiltered("node:1", now.Add(-time.Hour), ResourceChangeFilters{IncludeRelated: true}) + if err != nil { + t.Fatalf("CountRecentChangesFiltered include related: %v", err) + } + if count != 2 { + t.Fatalf("relationship-aware memory count = %d, want 2", count) + } +} + +func changeIDs(changes []ResourceChange) []string { + ids := make([]string, 0, len(changes)) + for _, change := range changes { + ids = append(ids, change.ID) + } + return ids +} + func TestRecordChange_IgnoresDuplicateIDs(t *testing.T) { store := newTestStore(t) now := time.Date(2026, 3, 30, 18, 20, 0, 0, time.UTC) diff --git a/scripts/release_control/status_lookup_test.py b/scripts/release_control/status_lookup_test.py index 672ab61c5..682bde962 100644 --- a/scripts/release_control/status_lookup_test.py +++ b/scripts/release_control/status_lookup_test.py @@ -12,7 +12,7 @@ REPORT = { }, }, "summary": { - "lane_count": 18, + "lane_count": 19, }, "coverage_gaps": [ { diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py index cb155804f..1cf14e46b 100644 --- a/scripts/release_control/subsystem_lookup_test.py +++ b/scripts/release_control/subsystem_lookup_test.py @@ -84,7 +84,7 @@ class SubsystemLookupTest(unittest.TestCase): {"v6-rc-cut", "v6-rc-stabilization", "v6-ga-promotion", "v6-product-lane-expansion"}, ) self.assertEqual(result["scope"]["control_plane_repo"], "pulse") - self.assertEqual(result["status_summary"]["lane_count"], 18) + self.assertEqual(result["status_summary"]["lane_count"], 19) file_entry = result["files"][0] matches = {match["subsystem"] for match in file_entry["matches"]}