Trim dead resource graph surface

This commit is contained in:
rcourtman 2026-03-19 14:26:30 +00:00
parent cb20115799
commit cc806171dc
24 changed files with 100 additions and 360 deletions

View file

@ -151,8 +151,8 @@ surfaces can keep row summaries aligned without re-inferring totals from
consumer-local slices.
That same shared facet bundle now also carries grouped `recentChangeKinds`
counts by canonical change kind, so the lifecycle-adjacent detail surfaces can
report restart, anomaly, relationship, and capability distribution without
rebuilding timeline math in the browser.
report restart, anomaly, and other timeline distribution without rebuilding
timeline math in the browser.
That same shared facet bundle now also carries grouped
`recentChangeSourceTypes` counts by canonical source type, so the
lifecycle-adjacent detail surfaces can distinguish platform events, pulse

View file

@ -265,13 +265,13 @@ The canonical recent-change sentence formatting also lives in
`internal/unifiedresources.FormatResourceChangeSummary`, so AI runtime prompt
sections and Patrol seed context reuse the same change wording instead of
keeping another lane-local formatter.
The confidence percentage wording used by the drawer's relationship and
change timeline rows also flows through a shared frontend formatter, so the
same `50%`-style labels stay consistent across resource-graph surfaces
instead of being re-derived in the component.
The remaining fallback token humanization used by those same resource-graph
surfaces also flows through one shared frontend helper, so the title-casing
and underscore cleanup used for relationship, change, and drawer labels stay
The confidence percentage wording used by the drawer's change timeline rows
also flows through a shared frontend formatter, so the same `50%`-style
labels stay consistent across timeline surfaces instead of being re-derived
in the component.
The remaining fallback token humanization used by those same timeline and
drawer surfaces also flows through one shared frontend helper, so the
title-casing and underscore cleanup used for change and drawer labels stay
centralized instead of being reimplemented locally.
The canonical recent-change section wrapper also lives in
`internal/unifiedresources.FormatResourceRecentChangesContext`, so the AI

View file

@ -75,7 +75,7 @@ Own canonical runtime payload shapes between backend and frontend.
3. Add dedicated contract tests for new stable payloads
4. Route unified resource sensitivity, routing, and `aiSafeSummary` payload changes through `internal/api/resources.go`, `internal/api/contract_test.go`, and the canonical frontend resource consumer proofs together; resource governance metadata must not ship as an API-only or frontend-only heuristic
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 capability, relationship, 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 governed surface, including the backend-provided facet counts needed to distinguish loaded slices from total history
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
7. Route canonical AI intelligence summary and resource-intelligence reads through `frontend-modern/src/api/ai.ts`, `frontend-modern/src/stores/aiIntelligence.ts`, `frontend-modern/src/pages/AIIntelligence.tsx`, `internal/api/ai_handlers.go`, and `internal/api/contract_test.go` together so the summary card, store state, and backend payload stay aligned on one governed surface, including the canonical recent-changes slice
and the shared `frontend-modern/src/components/Infrastructure/ResourceChangeSummary.tsx` card, so canonical recent-change timelines stay rendered through one governed frontend card instead of separate page-local list loops
and the shared `frontend-modern/src/utils/resourceChangePresentation.ts` formatter used by the summary page and resource drawer, so canonical change wording does not drift across surfaces
@ -132,8 +132,9 @@ The unified resource API payload now carries the richer domain facets directly
through the owned backend response: resource objects can expose canonical
`capabilities`, `relationships`, `recentChanges`, and derived `facetCounts`
in addition to policy and identity metadata, so the backend payload contract
stays aligned with the
timeline and control-plane model instead of flattening those fields away.
stays aligned with the timeline and control-plane model instead of flattening
those fields away. The frontend consumer, however, only preserves the
timeline-first `recentChanges` slice and its counts on the bundle contract.
The same resource contract now also exposes a dedicated
`/api/resources/{id}/timeline` history endpoint and bundled facet reads under
`/api/resources/{id}/facets`, so operators can inspect change history without

View file

@ -159,10 +159,10 @@ The Patrol page and resource drawer now also share the canonical
`frontend-modern/src/utils/resourceChangePresentation.ts` formatter so
recent-change kind and headline wording stays aligned wherever the canonical
timeline is surfaced.
The Patrol page and resource drawer now also share a frontend relationship
presentation helper for graph labels and provenance wording, so the same
canonical relationship semantics render consistently across the resource and
intelligence surfaces.
The Patrol page and resource drawer now keep canonical relationship semantics
in the backend correlation path, while the visible frontend stays
timeline-first and does not surface a separate relationship presentation
helper.
The backend Patrol and AI runtime summaries now also share
`internal/unifiedresources/change_presentation.go` for the canonical
change-kind and provenance mapping, so the same resource-model semantics

View file

@ -167,8 +167,8 @@ pages do not hit a missing-provider 500 before the monitor is fully wired.
The shared unified-resource consumer hook now also preserves `recentChanges`,
`facetCounts`, `policy`, and `aiSafeSummary` fields when storage and recovery
surfaces read unified resources, so those pages see the same control-plane
timeline facets as the dedicated resource drawer instead of flattening them
away locally.
timeline facets and recent-change totals as the dedicated resource drawer
instead of flattening them away locally.
The same storage-facing runtime paths now also normalize org scope through
`frontend-modern/src/utils/orgScope.ts` before building cache keys or
multi-tenant fetch state, so Dashboard, StorageSummary, and other storage

View file

@ -169,13 +169,12 @@ The canonical unified-resource change and relationship presenters now also
share the same elapsed-time and "ago" wording utilities, so `observed`,
`last seen`, and `ago` fragments stay consistent without each formatter
maintaining its own "time ago" implementation.
The drawer's relationship and change timeline confidence labels now also use
a shared frontend formatter, so the same percentage wording is emitted across
resource-graph surfaces instead of each consumer rounding confidence values on
its own.
Those same resource-graph surfaces now also share a token-humanization helper
for fallback labels, so underscore cleanup and title-casing for relationship,
change, and drawer labels stay aligned without local copies.
The drawer's change timeline confidence labels now also use a shared frontend
formatter, so the same percentage wording is emitted across timeline
surfaces instead of each consumer rounding confidence values on its own.
Those same timeline surfaces now also share a token-humanization helper for
fallback labels, so underscore cleanup and title-casing for change and drawer
labels stay aligned without local copies.
The same resource-change contract now also owns the canonical filter parser
used by `/api/resources/{id}/timeline`, so `kind`, `sourceType`, and
`sourceAdapter` validation stays with the change model instead of being
@ -213,15 +212,12 @@ drawer, which keeps the presentation surface aligned with the governed API
contract instead of rebuilding the graph and timeline inline.
The shared `ResourceFacetSummary` consumer now omits capability and
relationship badges from the default table/detail surface entirely, while the
backend contract still preserves those facet fields for governed consumers.
backend contract keeps capability and relationship data on the owned resource
model for governed AI and correlation consumers.
That keeps the proven monitoring UX centered on factual timeline
investigation while the richer facet payloads remain available as backend and
AI-facing foundations instead of being presented as first-class product facts
before they are fully populated.
That drawer now also uses a shared frontend relationship-presentation helper
for graph labels and provenance wording, so the UI stays aligned with the
canonical relationship semantics instead of keeping drawer-local token
humanization.
The same facet bundle now also returns grouped recent-change counts by
canonical change kind, so the detail drawer can surface the distribution of
state transitions, restarts, config updates, and anomalies without
@ -355,10 +351,10 @@ canonical `facetCounts` on the resource object when available, so the backend
list/read shapes remain the source of truth instead of forcing the frontend to
infer totals only from loaded slices. The drawer now fetches those facets
through one backend bundle endpoint, and that shared facet bundle preserves
backend counts for the hidden capability and relationship model alongside the
timeline slice so the overview card and history summary can report the total
facet history instead of collapsing to the currently loaded page when the
timeline endpoint is paginated. Timeline references in that drawer now route
the timeline slice plus recent-change counts so the overview card and history
summary can report the loaded history instead of collapsing to the currently
loaded page when the timeline endpoint is paginated. Timeline references in
that drawer now route
through the canonical infrastructure resource filter, so the resource history
remains navigable from the history surface instead of being purely
descriptive text.
@ -366,10 +362,9 @@ descriptive text.
`frontend-modern/src/utils/resourceChangePresentation.ts` label helper for
canonical change kinds, source types, and adapter provenance, so the chip
wording stays aligned across table, drawer, and intelligence surfaces.
Relationship cards in that drawer also surface `lastSeenAt` freshness and
optional metadata blocks, and timeline cards surface change metadata when it
is present, so the graph history view preserves the richer provenance already
carried by the unified-resource model instead of flattening those fields away.
Timeline cards in that drawer surface change metadata when it is present, so
the history view preserves the richer provenance already carried by the
unified-resource model instead of flattening those fields away.
The same Infrastructure resource-only links now also default through the
shared `frontend-modern/src/components/Infrastructure/ResourceChangeSummary.tsx`
and `frontend-modern/src/components/Infrastructure/ResourceCorrelationSummary.tsx`
@ -539,15 +534,15 @@ They also own the canonical sensitivity and routing order used to format
policy-posture count summaries with human-readable labels, so the AI summary
and frontend policy card both read the same presentation sequence from the
shared resource model.
Canonical resources now carry first-class graph-expansion fields: `Capabilities`
(bounded action definitions with approval levels), `Relationships` (typed
inter-resource links with direction and confidence), and `RecentChanges` (typed
change timeline entries with source, confidence, and related-resource
references). These fields are defined in `capabilities.go`, `relationships.go`,
`changes.go`, `privacy.go`, and `actions.go`. The frontend capability drawer
now formats the shared approval-level vocabulary through a canonical
presentation helper instead of a local switch, so the resource model and the
rendered labels stay aligned. The store now also owns a
Canonical resources now carry first-class graph-expansion fields:
`Capabilities` (bounded action definitions with approval levels),
`Relationships` (typed inter-resource links with direction and confidence),
and `RecentChanges` (typed change timeline entries with source, confidence,
and related-resource references). These fields are defined in
`capabilities.go`, `relationships.go`, `changes.go`, `privacy.go`, and
`actions.go`. The backend keeps those graph fields for AI and correlation use,
while the frontend consumer stays timeline-first and only preserves the
recent-change slice plus facet counts it actually renders. The store now also owns a
`resource_changes` persistence table with `RecordChange` and `GetRecentChanges`
methods so change history is queryable by canonical ID and time window.
The shared change presentation helper also owns the canonical kind, source

View file

@ -16,6 +16,9 @@ const v2Resource = {
status: 'online',
lastSeen: '2026-02-06T12:00:00Z',
sources: ['agent'],
facetCounts: {
recentChanges: 1,
},
metrics: {
cpu: { percent: 15 },
memory: { used: 4 * 1024 * 1024, total: 8 * 1024 * 1024, percent: 50 },
@ -193,6 +196,9 @@ describe('useUnifiedResources', () => {
resourceId: 'host-1',
hostname: 'pve1',
});
expect(result!.resources()[0].facetCounts).toEqual({
recentChanges: 1,
});
dispose();
});

View file

@ -138,14 +138,7 @@ export interface ResourcePolicy {
routing: ResourceRoutingPolicy;
}
export type ResourceCapabilityType = 'common' | 'native';
export type ResourceApprovalLevel = 'none' | 'dry_run_only' | 'admin' | 'mfa';
export type ResourceRelationshipType =
| 'runs_on'
| 'depends_on'
| 'mounted_to'
| 'exposed_by'
| 'owned_by';
export type ResourceChangeConfidence = 'high' | 'medium' | 'low';
export type ResourceChangeKind =
| 'state_transition'
@ -173,26 +166,6 @@ export type ResourceFacetSourceAdapter =
| 'truenas_adapter'
| 'agent:ops-helper';
export interface ResourceCapabilityParam {
name: string;
type: string;
required: boolean;
enum?: string[];
pattern?: string;
defaultValue?: unknown;
isSensitive: boolean;
description?: string;
}
export interface ResourceCapability {
name: string;
type: ResourceCapabilityType;
description: string;
minimumApprovalLevel: ResourceApprovalLevel;
platform?: string;
params?: ResourceCapabilityParam[];
}
export interface ResourceFacetCounts {
recentChanges: number;
recentChangeKinds?: Partial<Record<ResourceChangeKind, number>>;
@ -200,18 +173,6 @@ export interface ResourceFacetCounts {
recentChangeSourceAdapters?: Partial<Record<ResourceFacetSourceAdapter, number>>;
}
export interface ResourceRelationship {
sourceId: string;
targetId: string;
type: ResourceRelationshipType;
confidence: number;
active: boolean;
discoverer: string;
observedAt: string;
lastSeenAt: string;
metadata?: Record<string, unknown>;
}
export interface ResourceChange {
id: string;
observedAt: string;

View file

@ -73,7 +73,6 @@ import dashboardEmptyStatePresentationSource from '@/utils/dashboardEmptyStatePr
import throughputPresentationSource from '@/utils/throughputPresentation.ts?raw';
import resourceChangeSummarySource from '@/components/Infrastructure/ResourceChangeSummary.tsx?raw';
import resourceChangePresentationSource from '@/utils/resourceChangePresentation.ts?raw';
import resourceRelationshipPresentationSource from '@/utils/resourceRelationshipPresentation.ts?raw';
import resourceCorrelationPresentationSource from '@/utils/resourceCorrelationPresentation.ts?raw';
import confidencePresentationSource from '@/utils/confidencePresentation.ts?raw';
import approvalPresentationSource from '@/utils/approvalPresentation.ts?raw';
@ -1914,7 +1913,6 @@ describe('frontend resource type boundaries', () => {
expect(resourceChangePresentationSource).toContain(
'getResourceChangeSourceAdapterPresentation',
);
expect(resourceRelationshipPresentationSource).toContain('formatConfidencePercentage');
expect(resourceCorrelationPresentationSource).toContain('formatConfidencePercentage');
expect(resourceCorrelationPresentationSource).toContain('humanizeArrowDelimitedLabel');
expect(resourceCorrelationPresentationSource).toContain('asTrimmedString');
@ -1924,13 +1922,11 @@ describe('frontend resource type boundaries', () => {
expect(throughputPresentationSource).toContain('formatThroughputRate');
expect(resourceDetailDrawerSource).toContain('formatIdentifierLabel');
expect(resourceChangePresentationSource).toContain('humanizeToken');
expect(resourceRelationshipPresentationSource).toContain('humanizeToken');
expect(textPresentationSource).toContain('humanizeArrowDelimitedLabel');
expect(resourceCorrelationPresentationSource).not.toContain('formatResourceCorrelationEndpointLabel');
expect(resourceCorrelationPresentationSource).not.toContain(
"replace(/\\s*->\\s*/g, ' → ')",
);
expect(resourceDetailDrawerSource).toContain('humanizeToken');
expect(textPresentationSource).toContain('humanizeToken');
expect(textPresentationSource).toContain('formatIdentifierLabel');
expect(resourceCorrelationPresentationSource).not.toContain('formatTrimmedLabel');

View file

@ -1,40 +0,0 @@
import { describe, expect, it } from 'vitest';
import { describeResourceRelationship, formatResourceRelationshipType } from '@/utils/resourceRelationshipPresentation';
describe('resourceRelationshipPresentation', () => {
it('formats canonical relationship type labels', () => {
expect(formatResourceRelationshipType('runs_on')).toBe('Runs on');
expect(formatResourceRelationshipType('depends_on')).toBe('Depends on');
expect(formatResourceRelationshipType('mounted_to')).toBe('Mounted to');
expect(formatResourceRelationshipType('exposed_by')).toBe('Exposed by');
expect(formatResourceRelationshipType('owned_by')).toBe('Owned by');
expect(formatResourceRelationshipType('custom_link')).toBe('Custom Link');
expect(formatResourceRelationshipType('')).toBe('Related to');
});
it('describes canonical relationship context fragments', () => {
const presentation = describeResourceRelationship({
sourceId: 'node-1',
targetId: 'vm-1',
type: 'runs_on',
confidence: 0.85,
active: false,
discoverer: 'proxmox_adapter',
observedAt: '2026-03-18T12:00:00Z',
lastSeenAt: '2026-03-18T12:05:00Z',
metadata: {
region: 'lab',
},
});
expect(presentation).toMatchObject({
typeLabel: 'Runs on',
direction: 'node-1 → vm-1',
provenance: 'proxmox_adapter',
stateLabel: 'Historical',
confidence: '85%',
hasMetadata: true,
});
});
});

View file

@ -1,43 +0,0 @@
import type { ResourceRelationship, ResourceRelationshipType } from '@/types/resource';
import { formatConfidencePercentage } from '@/utils/confidencePresentation';
import { humanizeToken } from '@/utils/textPresentation';
export interface ResourceRelationshipPresentation {
typeLabel: string;
direction: string;
provenance: string;
stateLabel: string;
confidence: string;
hasMetadata: boolean;
}
export function formatResourceRelationshipType(type: ResourceRelationshipType | string): string {
switch (type) {
case 'runs_on':
return 'Runs on';
case 'depends_on':
return 'Depends on';
case 'mounted_to':
return 'Mounted to';
case 'exposed_by':
return 'Exposed by';
case 'owned_by':
return 'Owned by';
default: {
return humanizeToken(String(type), { fallback: 'Related to' });
}
}
}
export function describeResourceRelationship(
relationship: ResourceRelationship,
): ResourceRelationshipPresentation {
return {
typeLabel: formatResourceRelationshipType(relationship.type),
direction: `${relationship.sourceId.trim()}${relationship.targetId.trim()}`,
provenance: relationship.discoverer.trim(),
stateLabel: relationship.active ? 'Active' : 'Historical',
confidence: relationship.confidence > 0 ? formatConfidencePercentage(relationship.confidence) : '',
hasMetadata: Boolean(relationship.metadata && Object.keys(relationship.metadata).length > 0),
};
}

View file

@ -51,7 +51,6 @@ type (
MetadataUpdater = tools.MetadataUpdater
IncidentRecorderProvider = tools.IncidentRecorderProvider
EventCorrelatorProvider = tools.EventCorrelatorProvider
TopologyProvider = tools.TopologyProvider
KnowledgeStoreProvider = tools.KnowledgeStoreProvider
MCPDiscoveryProvider = tools.DiscoveryProvider
MCPUnifiedResourceProvider = tools.UnifiedResourceProvider
@ -1218,14 +1217,6 @@ func (s *Service) SetEventCorrelatorProvider(provider EventCorrelatorProvider) {
}
}
func (s *Service) SetTopologyProvider(provider TopologyProvider) {
s.mu.Lock()
defer s.mu.Unlock()
if s.executor != nil {
s.executor.SetTopologyProvider(provider)
}
}
func (s *Service) SetKnowledgeStoreProvider(provider KnowledgeStoreProvider) {
s.mu.Lock()
defer s.mu.Unlock()

View file

@ -17,7 +17,6 @@ func TestServiceSettersAndAutonomousMode(t *testing.T) {
service.SetIncidentRecorderProvider(nil)
service.SetEventCorrelatorProvider(nil)
service.SetTopologyProvider(nil)
service.SetKnowledgeStoreProvider(nil)
service.SetAutonomousMode(true)

View file

@ -362,7 +362,6 @@ type ExecutorConfig struct {
// Optional providers - intelligence
IncidentRecorderProvider IncidentRecorderProvider
EventCorrelatorProvider EventCorrelatorProvider
TopologyProvider TopologyProvider
KnowledgeStoreProvider KnowledgeStoreProvider
// Optional providers - discovery
@ -414,7 +413,6 @@ type PulseToolExecutor struct {
// Intelligence providers
incidentRecorderProvider IncidentRecorderProvider
eventCorrelatorProvider EventCorrelatorProvider
topologyProvider TopologyProvider
knowledgeStoreProvider KnowledgeStoreProvider
// Discovery provider
@ -493,7 +491,6 @@ func NewPulseToolExecutor(cfg ExecutorConfig) *PulseToolExecutor {
agentProfileManager: cfg.AgentProfileManager,
incidentRecorderProvider: cfg.IncidentRecorderProvider,
eventCorrelatorProvider: cfg.EventCorrelatorProvider,
topologyProvider: cfg.TopologyProvider,
knowledgeStoreProvider: cfg.KnowledgeStoreProvider,
discoveryProvider: cfg.DiscoveryProvider,
unifiedResourceProvider: cfg.UnifiedResourceProvider,
@ -684,11 +681,6 @@ func (e *PulseToolExecutor) SetEventCorrelatorProvider(provider EventCorrelatorP
e.eventCorrelatorProvider = provider
}
// SetTopologyProvider sets the topology provider for relationship graphs
func (e *PulseToolExecutor) SetTopologyProvider(provider TopologyProvider) {
e.topologyProvider = provider
}
// SetKnowledgeStoreProvider sets the knowledge store provider for notes
func (e *PulseToolExecutor) SetKnowledgeStoreProvider(provider KnowledgeStoreProvider) {
e.knowledgeStoreProvider = provider
@ -785,7 +777,7 @@ func (e *PulseToolExecutor) isToolAvailable(name string) bool {
case "pulse_discovery":
return e.discoveryProvider != nil
case "pulse_knowledge":
return e.knowledgeStoreProvider != nil || e.incidentRecorderProvider != nil || e.eventCorrelatorProvider != nil || e.topologyProvider != nil
return e.knowledgeStoreProvider != nil || e.incidentRecorderProvider != nil || e.eventCorrelatorProvider != nil
case "pulse_pmg":
return e.hasReadState()
case "patrol_report_finding", "patrol_resolve_finding", "patrol_get_findings":

View file

@ -59,19 +59,6 @@ type EventCorrelation struct {
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
// TopologyProvider provides access to resource relationships
type TopologyProvider interface {
GetRelatedResources(resourceID string, depth int) []RelatedResource
}
// RelatedResource represents a resource related to another resource
type RelatedResource struct {
ResourceID string `json:"resource_id"`
ResourceName string `json:"resource_name"`
ResourceType string `json:"resource_type"`
Relationship string `json:"relationship"`
}
// KnowledgeStoreProvider provides access to stored knowledge/notes
type KnowledgeStoreProvider interface {
SaveNote(resourceID, note, category string) error
@ -100,21 +87,19 @@ Actions:
- recall: Retrieve saved notes about a resource
- incidents: Get high-resolution incident recording data
- correlate: Get correlated events around a timestamp
- relationships: Get resource dependency graph
Examples:
- Save note: action="remember", resource_id="101", note="Production database server", category="purpose"
- Recall: action="recall", resource_id="101"
- Get incidents: action="incidents", resource_id="101"
- Correlate events: action="correlate", resource_id="101", window_minutes=30
- Get relationships: action="relationships", resource_id="101"`,
- Correlate events: action="correlate", resource_id="101", window_minutes=30`,
InputSchema: InputSchema{
Type: "object",
Properties: map[string]PropertySchema{
"action": {
Type: "string",
Description: "Knowledge action to perform",
Enum: []string{"remember", "recall", "incidents", "correlate", "relationships"},
Enum: []string{"remember", "recall", "incidents", "correlate"},
},
"resource_id": {
Type: "string",
@ -140,10 +125,6 @@ Examples:
Type: "integer",
Description: "For correlate: time window in minutes (default: 15)",
},
"depth": {
Type: "integer",
Description: "For relationships: levels to traverse (default: 1, max: 3)",
},
"limit": {
Type: "integer",
Description: "For incidents: max windows to return (default: 5)",
@ -170,10 +151,8 @@ func (e *PulseToolExecutor) executeKnowledge(ctx context.Context, args map[strin
return e.executeGetIncidentWindow(ctx, args)
case "correlate":
return e.executeCorrelateEvents(ctx, args)
case "relationships":
return e.executeGetRelationshipGraph(ctx, args)
default:
return NewErrorResult(fmt.Errorf("unknown action: %s. Use: remember, recall, incidents, correlate, relationships", action)), nil
return NewErrorResult(fmt.Errorf("unknown action: %s. Use: remember, recall, incidents, correlate", action)), nil
}
}
@ -258,39 +237,6 @@ func (e *PulseToolExecutor) executeCorrelateEvents(_ context.Context, args map[s
}), nil
}
func (e *PulseToolExecutor) executeGetRelationshipGraph(_ context.Context, args map[string]interface{}) (CallToolResult, error) {
resourceID, _ := args["resource_id"].(string)
depth := intArg(args, "depth", 1)
if resourceID == "" {
return NewErrorResult(fmt.Errorf("resource_id is required")), nil
}
// Cap depth to prevent excessive traversal
if depth < 1 {
depth = 1
}
if depth > 3 {
depth = 3
}
if e.topologyProvider == nil {
return NewTextResult("Topology information not available."), nil
}
related := e.topologyProvider.GetRelatedResources(resourceID, depth)
if len(related) == 0 {
return NewTextResult(fmt.Sprintf("No relationships found for resource '%s'.", resourceID)), nil
}
return NewJSONResult(map[string]interface{}{
"resource_id": resourceID,
"depth": depth,
"related_resources": related,
"count": len(related),
}), nil
}
func (e *PulseToolExecutor) executeRemember(_ context.Context, args map[string]interface{}) (CallToolResult, error) {
resourceID, _ := args["resource_id"].(string)
note, _ := args["note"].(string)

View file

@ -64,7 +64,6 @@ type AIService interface {
SetKnowledgeStoreProvider(provider chat.KnowledgeStoreProvider)
SetIncidentRecorderProvider(provider chat.IncidentRecorderProvider)
SetEventCorrelatorProvider(provider chat.EventCorrelatorProvider)
SetTopologyProvider(provider chat.TopologyProvider)
SetDiscoveryProvider(provider chat.MCPDiscoveryProvider)
SetUnifiedResourceProvider(provider chat.MCPUnifiedResourceProvider)
UpdateControlSettings(cfg *config.AIConfig)

View file

@ -146,7 +146,6 @@ func (m *MockAIService) SetIncidentRecorderProvider(provider chat.IncidentRecord
func (m *MockAIService) SetEventCorrelatorProvider(provider chat.EventCorrelatorProvider) {
m.Called(provider)
}
func (m *MockAIService) SetTopologyProvider(provider chat.TopologyProvider) { m.Called(provider) }
func (m *MockAIService) SetDiscoveryProvider(provider chat.MCPDiscoveryProvider) {
m.Called(provider)
}

View file

@ -3810,8 +3810,6 @@ func TestContract_ResourceListCarriesTimelineAndCapabilityContracts(t *testing.T
},
},
FacetCounts: unifiedresources.ResourceFacetCounts{
Capabilities: 1,
Relationships: 1,
RecentChanges: 1,
},
},
@ -3894,8 +3892,6 @@ func TestContract_ResourceListCarriesTimelineAndCapabilityContracts(t *testing.T
}
],
"facetCounts":{
"capabilities":1,
"relationships":1,
"recentChanges":1
}
}
@ -4258,13 +4254,9 @@ func TestContract_ResourceTimelineAnomalyJSONSnapshot(t *testing.T) {
func TestContract_ResourceFacetsJSONSnapshot(t *testing.T) {
now := time.Date(2026, 3, 18, 17, 0, 0, 0, time.UTC)
payload := struct {
ResourceID string `json:"resourceId"`
Capabilities []unifiedresources.ResourceCapability `json:"capabilities"`
Relationships []unifiedresources.ResourceRelationship `json:"relationships"`
RecentChanges []unifiedresources.ResourceChange `json:"recentChanges"`
ResourceID string `json:"resourceId"`
RecentChanges []unifiedresources.ResourceChange `json:"recentChanges"`
Counts struct {
Capabilities int `json:"capabilities"`
Relationships int `json:"relationships"`
RecentChanges int `json:"recentChanges"`
RecentChangeKinds map[unifiedresources.ChangeKind]int `json:"recentChangeKinds"`
RecentChangeSourceTypes map[unifiedresources.ChangeSourceType]int `json:"recentChangeSourceTypes"`
@ -4272,30 +4264,6 @@ func TestContract_ResourceFacetsJSONSnapshot(t *testing.T) {
} `json:"counts"`
}{
ResourceID: "vm:42",
Capabilities: []unifiedresources.ResourceCapability{
{
Name: "restart",
Type: unifiedresources.CapabilityTypeCommon,
Description: "Restart the VM",
MinimumApprovalLevel: unifiedresources.ApprovalAdmin,
},
},
Relationships: []unifiedresources.ResourceRelationship{
{
SourceID: "vm:42",
TargetID: "node-1",
Type: unifiedresources.RelRunsOn,
Confidence: 1,
Active: true,
Discoverer: "proxmox_adapter",
ObservedAt: now,
LastSeenAt: now,
Metadata: map[string]any{
"source": "live",
"cluster": "pve-prod",
},
},
},
RecentChanges: []unifiedresources.ResourceChange{
{
ID: "chg-42",
@ -4316,15 +4284,11 @@ func TestContract_ResourceFacetsJSONSnapshot(t *testing.T) {
},
},
Counts: struct {
Capabilities int `json:"capabilities"`
Relationships int `json:"relationships"`
RecentChanges int `json:"recentChanges"`
RecentChangeKinds map[unifiedresources.ChangeKind]int `json:"recentChangeKinds"`
RecentChangeSourceTypes map[unifiedresources.ChangeSourceType]int `json:"recentChangeSourceTypes"`
RecentChangeSourceAdapters map[unifiedresources.ChangeSourceAdapter]int `json:"recentChangeSourceAdapters"`
}{
Capabilities: 1,
Relationships: 1,
RecentChanges: 3,
RecentChangeKinds: map[unifiedresources.ChangeKind]int{unifiedresources.ChangeRestart: 1, unifiedresources.ChangeAnomaly: 2},
RecentChangeSourceTypes: map[unifiedresources.ChangeSourceType]int{
@ -4345,27 +4309,6 @@ func TestContract_ResourceFacetsJSONSnapshot(t *testing.T) {
const want = `{
"resourceId":"vm:42",
"capabilities":[
{
"name":"restart",
"type":"common",
"description":"Restart the VM",
"minimumApprovalLevel":"admin"
}
],
"relationships":[
{
"sourceId":"vm:42",
"targetId":"node-1",
"type":"runs_on",
"confidence":1,
"active":true,
"discoverer":"proxmox_adapter",
"observedAt":"2026-03-18T17:00:00Z",
"lastSeenAt":"2026-03-18T17:00:00Z",
"metadata":{"cluster":"pve-prod","source":"live"}
}
],
"recentChanges":[
{
"id":"chg-42",
@ -4383,8 +4326,6 @@ func TestContract_ResourceFacetsJSONSnapshot(t *testing.T) {
}
],
"counts":{
"capabilities":1,
"relationships":1,
"recentChanges":3,
"recentChangeKinds":{
"metric_anomaly":2,

View file

@ -278,11 +278,9 @@ func (h *ResourceHandlers) HandleGetResource(w http.ResponseWriter, r *http.Requ
type resourceFacetCountsResponse = unified.ResourceFacetCounts
type resourceFacetBundleResponse struct {
ResourceID string `json:"resourceId"`
Capabilities []unified.ResourceCapability `json:"capabilities"`
Relationships []unified.ResourceRelationship `json:"relationships"`
RecentChanges []unified.ResourceChange `json:"recentChanges"`
Counts resourceFacetCountsResponse `json:"counts"`
ResourceID string `json:"resourceId"`
RecentChanges []unified.ResourceChange `json:"recentChanges"`
Counts resourceFacetCountsResponse `json:"counts"`
}
// HandleResourceRoutes dispatches nested resource routes.
@ -346,7 +344,7 @@ func (h *ResourceHandlers) HandleGetResourceFacets(w http.ResponseWriter, r *htt
return
}
resource, ok := registry.Get(resourceID)
_, ok := registry.Get(resourceID)
if !ok {
http.Error(w, "Resource not found", http.StatusNotFound)
return
@ -402,24 +400,11 @@ func (h *ResourceHandlers) HandleGetResourceFacets(w http.ResponseWriter, r *htt
return
}
capabilities := resource.Capabilities
if capabilities == nil {
capabilities = []unified.ResourceCapability{}
}
relationships := resource.Relationships
if relationships == nil {
relationships = []unified.ResourceRelationship{}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resourceFacetBundleResponse{
ResourceID: resourceID,
Capabilities: capabilities,
Relationships: relationships,
RecentChanges: recentChanges,
Counts: resourceFacetCountsResponse{
Capabilities: resource.FacetCounts.Capabilities,
Relationships: resource.FacetCounts.Relationships,
RecentChanges: changeCount,
RecentChangeKinds: changeKindCounts,
RecentChangeSourceTypes: sourceTypeCounts,

View file

@ -246,8 +246,8 @@ func TestResourceListUsesUnifiedSeedProvider(t *testing.T) {
if strings.TrimSpace(resp.Data[0].AISafeSummary) == "" {
t.Fatal("expected aiSafeSummary on seeded resource")
}
if got := resp.Data[0].FacetCounts; got.Capabilities != 1 || got.Relationships != 1 || got.RecentChanges != 1 {
t.Fatalf("facetCounts = %+v, want 1/1/1", got)
if got := resp.Data[0].FacetCounts; got.RecentChanges != 1 {
t.Fatalf("facetCounts = %+v, want recentChanges=1", got)
}
}
@ -981,13 +981,9 @@ func TestResourceGetFacetsAndTimeline(t *testing.T) {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
var payload struct {
ResourceID string `json:"resourceId"`
Capabilities []unified.ResourceCapability `json:"capabilities"`
Relationships []unified.ResourceRelationship `json:"relationships"`
RecentChanges []unified.ResourceChange `json:"recentChanges"`
ResourceID string `json:"resourceId"`
RecentChanges []unified.ResourceChange `json:"recentChanges"`
Counts struct {
Capabilities int `json:"capabilities"`
Relationships int `json:"relationships"`
RecentChanges int `json:"recentChanges"`
RecentChangeKinds map[unified.ChangeKind]int `json:"recentChangeKinds"`
RecentChangeSourceTypes map[unified.ChangeSourceType]int `json:"recentChangeSourceTypes"`
@ -1000,9 +996,6 @@ func TestResourceGetFacetsAndTimeline(t *testing.T) {
if payload.ResourceID != "vm:42" || payload.Counts.RecentChanges != 3 || len(payload.RecentChanges) != 1 {
t.Fatalf("unexpected facets payload: %#v", payload)
}
if payload.Counts.Capabilities != 1 || payload.Counts.Relationships != 1 {
t.Fatalf("unexpected facet counts: %#v", payload.Counts)
}
if got := payload.Counts.RecentChangeKinds; len(got) != 2 || got[unified.ChangeRestart] != 1 || got[unified.ChangeAnomaly] != 2 {
t.Fatalf("unexpected recent change kind counts: %#v", got)
}
@ -1012,9 +1005,6 @@ func TestResourceGetFacetsAndTimeline(t *testing.T) {
if got := payload.Counts.RecentChangeSourceAdapters; len(got) != 2 || got[unified.AdapterProxmox] != 2 || got[unified.AdapterDocker] != 1 {
t.Fatalf("unexpected recent change source adapter counts: %#v", got)
}
if got := payload.Relationships[0].Metadata["cluster"]; got != "pve-prod" {
t.Fatalf("unexpected relationship metadata: %#v", payload.Relationships[0].Metadata)
}
if got := payload.RecentChanges[0].Metadata["ticket"]; got != "INC-1234" {
t.Fatalf("unexpected change metadata: %#v", payload.RecentChanges[0].Metadata)
}
@ -1106,12 +1096,9 @@ func TestResourceGetFacetsAndTimeline(t *testing.T) {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
var payload struct {
ResourceID string `json:"resourceId"`
RecentChanges []unified.ResourceChange `json:"recentChanges"`
Relationships []unified.ResourceRelationship `json:"relationships"`
ResourceID string `json:"resourceId"`
RecentChanges []unified.ResourceChange `json:"recentChanges"`
Counts struct {
Capabilities int `json:"capabilities"`
Relationships int `json:"relationships"`
RecentChanges int `json:"recentChanges"`
RecentChangeKinds map[unified.ChangeKind]int `json:"recentChangeKinds"`
RecentChangeSourceTypes map[unified.ChangeSourceType]int `json:"recentChangeSourceTypes"`
@ -1146,12 +1133,9 @@ func TestResourceGetFacetsAndTimeline(t *testing.T) {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
var payload struct {
ResourceID string `json:"resourceId"`
RecentChanges []unified.ResourceChange `json:"recentChanges"`
Relationships []unified.ResourceRelationship `json:"relationships"`
ResourceID string `json:"resourceId"`
RecentChanges []unified.ResourceChange `json:"recentChanges"`
Counts struct {
Capabilities int `json:"capabilities"`
Relationships int `json:"relationships"`
RecentChanges int `json:"recentChanges"`
RecentChangeKinds map[unified.ChangeKind]int `json:"recentChangeKinds"`
RecentChangeSourceTypes map[unified.ChangeSourceType]int `json:"recentChangeSourceTypes"`

View file

@ -2229,7 +2229,6 @@ func (r *Router) wireAIChatDependenciesForService(ctx context.Context, service A
// Wire intelligence providers for MCP tools
// - IncidentRecorderProvider: high-frequency incident data (pulse_get_incident_window)
// - EventCorrelatorProvider: Proxmox events (pulse_correlate_events)
// - TopologyProvider: relationship graph (pulse_get_relationship_graph)
// - KnowledgeStoreProvider: notes (pulse_remember, pulse_recall)
// Wire incident recorder provider (high-frequency incident data)

View file

@ -47,8 +47,6 @@ func cloneResource(in *Resource) Resource {
func resourceFacetCounts(resource Resource) ResourceFacetCounts {
return ResourceFacetCounts{
Capabilities: len(resource.Capabilities),
Relationships: len(resource.Relationships),
RecentChanges: len(resource.RecentChanges),
}
}

View file

@ -804,6 +804,39 @@ func TestResourceFacetCountsAreCanonicalResourceFields(t *testing.T) {
}
}
func TestCanonicalIdentityIsCanonicalResourceField(t *testing.T) {
typesData, err := os.ReadFile(filepath.Join("types.go"))
if err != nil {
t.Fatalf("failed to read types.go: %v", err)
}
typesSource := string(typesData)
requiredSnippets := []string{
"json:\"canonicalIdentity,omitempty\"",
"type CanonicalIdentity struct {",
"DisplayName string `json:\"displayName,omitempty\"`",
"Aliases []string `json:\"aliases,omitempty\"`",
}
for _, snippet := range requiredSnippets {
if !strings.Contains(typesSource, snippet) {
t.Fatalf("internal/unifiedresources/types.go must keep the canonical identity contract snippet %q", snippet)
}
}
cloneData, err := os.ReadFile(filepath.Join("clone.go"))
if err != nil {
t.Fatalf("failed to read clone.go: %v", err)
}
cloneSource := string(cloneData)
requiredCloneSnippets := []string{
"RefreshCanonicalMetadata(&out)",
}
for _, snippet := range requiredCloneSnippets {
if !strings.Contains(cloneSource, snippet) {
t.Fatalf("internal/unifiedresources/clone.go must preserve canonical identity via %q", snippet)
}
}
}
// TestNoLegacyHostResourceTypeSymbol prevents reintroducing the removed
// ResourceTypeHost symbol. v6 code must use ResourceTypeAgent and
// CanonicalResourceType() for legacy normalization.

View file

@ -69,8 +69,6 @@ type Resource struct {
// ResourceFacetCounts captures the total count of each resource facet that
// may be surfaced in row summaries or detail drawers.
type ResourceFacetCounts struct {
Capabilities int `json:"capabilities"`
Relationships int `json:"relationships"`
RecentChanges int `json:"recentChanges"`
RecentChangeKinds map[ChangeKind]int `json:"recentChangeKinds,omitempty"`
RecentChangeSourceTypes map[ChangeSourceType]int `json:"recentChangeSourceTypes,omitempty"`