diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index f198b53345..a309f1f713 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -223,6 +223,12 @@ text unless they also hold action execution scope. Lifecycle UI and agents may use those redacted events as doorbells or status summaries, but they must fetch governed detail through the approval/action surfaces and must not treat a monitoring-readable event stream as command disclosure or execution authority. +The resource-context endpoint's additive context sections are the same +read-only boundary: lifecycle consumers, MCP adapters, and external agents may +use their bounded facts, provenance, freshness, and explicit redaction metadata +to understand a resource, but those sections must not be expanded into +provider-local config, raw discovery command output, environment values, +secret-like metadata, install-token scope, or command authority. Discovery context exposed by `/api/discovery` is similarly API-owned observability context, not lifecycle command authority. Lifecycle and infrastructure setup surfaces may display service versions, config paths, diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index b68208246b..dc7d634b91 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -552,7 +552,14 @@ runtime cost control, and shared AI transport surfaces. references through the canonical unified-resource provider before querying timeline changes, with raw handoff IDs used only as a compatibility fallback. Those timeline facts remain read-only explanation context and do not grant - action authority. The runtime may also persist structured pending-action and + action authority. Assistant runtime must also treat the shared + `internal/agentcontext` resource context pack as the canonical rich + resource-grounding substrate for product-originated resource handoffs: + runtime/discovery, topology, safety, policy, and recent-change facts are + hydrated as model-only context from canonical unified resources and the + resource store, carry provenance/freshness/redaction metadata, and must not + include raw command output, provider config, environment values, mount paths, + label maps, or secret-bearing metadata. The runtime may also persist structured pending-action and approval references from the same investigation record as model-context metadata, and the API handoff builder may recover the current live Patrol investigation-fix approval by finding ID when the durable record @@ -658,6 +665,11 @@ runtime cost control, and shared AI transport surfaces. The Assistant drawer may also render an attached context briefing for that handoff, but the briefing is runtime context visibility only: it must not mutate chat control settings, execute tools, or reveal raw command payloads. + Resource-drawer Assistant entries use that same briefing path with + `handoff_metadata.kind=resource_context`, a structured `handoff_resources` + reference, and `autonomous_mode:false`; they must not prefill or submit a + browser-authored prompt, and any rich resource facts must be hydrated by the + backend context-pack path rather than reconstructed in the browser. Safe route-owned briefing actions may render as app links when the handoff includes an `actionHref`, but those links are navigation guidance only and do not grant tool execution or approval authority. diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 4e1f470b35..28c70ce8d1 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -109,6 +109,7 @@ product API routes free of maintainer commercial analytics. 69. `frontend-modern/src/api/aiChat.ts` 70. `frontend-modern/src/api/patrol.ts` 71. `internal/api/agent_exec_token_binding.go` +72. `internal/agentcontext/` ## Shared Boundaries @@ -2009,6 +2010,15 @@ view and the doorbell speak the same vocabulary on probe outcomes; action and verification command/note text follows the same stable-marker redaction rule as action-audit readback, while stable status/error prefixes remain verbatim. +The same response also carries additive `contextSections`, built by the +shared `internal/agentcontext` package, with stable fact/redaction objects, +source, trust-tier, observed-at, and generated-at metadata. Those sections are +the canonical rich resource context pack for Assistant, MCP/tool clients, and +copy/export consumers: they may expose bounded Pulse-authored or +Pulse-observed runtime/discovery, topology, safety, policy, and recent-change +facts, but must not expose raw command output, provider config, environment +values, unbounded metadata maps, bind-mount paths, label values, or secret-like +capability parameters. The shape is intentionally narrower than the full internal types so agents see a stable agent-paradigm contract, decoupled from internal type evolution. @@ -2952,7 +2962,11 @@ references used to seed canonical approval and action-audit refresh. `handoff_metadata` is the browser-safe identity envelope for restoring saved product handoffs, currently including Patrol run kind, run ID, safe run type/status, a runtime-failure boolean rather than runtime failure detail, and -bounded resource/action counts when available. Frontend-visible Patrol +bounded resource/action counts when available. Resource drawer handoffs use +`handoff_metadata.kind=resource_context` with a structured `handoff_resources` +reference and no browser-authored prompt or model text; the backend chat +runtime must hydrate the shared resource context pack from canonical resources +instead of trusting the browser to serialize rich context. Frontend-visible Patrol assessment briefings must not render recommendation fields as separate title, reason, route-action facts, or prompt chips; the configured model owns those decisions from the structured handoff metadata and bounded chat context. diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index 977a408d5b..54e4f32237 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -176,6 +176,12 @@ regression protection. process version once, but they must not add per-request release lookups, filesystem walks, or other heavy work just to compute whether an attached agent is current. + Workload drawer Assistant and context-copy entries are also click-triggered + boundaries. Rendering `GuestDrawer` and `useGuestDrawerState` may derive + canonical workload identity and Discovery coordinates from already-loaded row + data, but it must not fetch the agent resource-context bundle or hydrate rich + Assistant context while a row is merely rendered, expanded, filtered, sorted, + or windowed. The `/api/version.agentUpdateTargetVersion` projection follows that same rule: it may mirror the cached deployable agent target exposed to agents, but it must not inspect attached agents, download artifacts, or resolve diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 5049e35faf..c3268ac9ee 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -199,6 +199,12 @@ services, including mock-mode config/data/log path examples. That context is read-only evidence: it must not become a storage/recovery-owned command path, secret source, restore entitlement, or frontend-only fixture separate from the canonical `/api/discovery` payload. +The agent resource-context endpoint follows the same adjacent-evidence rule for +storage/recovery consumers: bounded context sections, provenance, redactions, +and recent action counts may help explain a workload or protected service, but +they must not create restore authority, backup visibility, storage-local +approval policy, or a bypass around the API-owned action and recovery +contracts. Storage and recovery may also consume unified-resource TrueNAS app and VM metadata, TrueNAS native service metadata, plus TrueNAS network-share metadata, as read-only workload, system, and storage-access context when explaining diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index eb1a9562b4..0540c4af44 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -179,6 +179,14 @@ workload, service, storage, configuration, policy, and event object rows belong in their workflow tabs. If a future Overview repeats a detailed table, the owning workflow must be retired or the Overview content must be reduced to aggregate signal. +Product-originated resource references may arrive as registered unified +resource IDs, source-specific IDs, or canonical identity aliases. The +unified-resource registry owns resolving those references through +`GetByReference`, returning the registered resource ID for downstream store, +action, finding, and context-pack lookups. Resource drawer and workload drawer +Assistant handoffs may pass stable source IDs such as Proxmox +`instance:node:vmid`, but they must not expose generated registry IDs as the +browser-side contract or rebuild alias matching in frontend code. Across platform/runtime pages, workflow tabs are evidence-gated from the canonical model that owns their rows. `Overview` is the stable landing tab; supporting tabs appear only when their native inventory or signal exists, and diff --git a/frontend-modern/src/api/__tests__/agentContext.test.ts b/frontend-modern/src/api/__tests__/agentContext.test.ts new file mode 100644 index 0000000000..a8e7117bbf --- /dev/null +++ b/frontend-modern/src/api/__tests__/agentContext.test.ts @@ -0,0 +1,34 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const apiFetchJSONMock = vi.hoisted(() => vi.fn()); + +vi.mock('@/utils/apiClient', () => ({ + apiFetchJSON: apiFetchJSONMock, +})); + +import { AgentContextAPI } from '@/api/agentContext'; + +describe('AgentContextAPI', () => { + afterEach(() => { + apiFetchJSONMock.mockReset(); + }); + + it('fetches resource context with encoded canonical or source IDs', async () => { + apiFetchJSONMock.mockResolvedValue({ + canonicalId: 'system-container-6adaf34f529d241a', + resourceType: 'system-container', + resourceName: 'homeassistant', + activeFindings: [], + pendingApprovals: [], + recentActions: [], + contextSections: [], + generatedAt: '2026-06-04T15:00:00Z', + }); + + await AgentContextAPI.getResourceContext('delly:delly:101'); + + expect(apiFetchJSONMock).toHaveBeenCalledWith( + '/api/agent/resource-context/delly%3Adelly%3A101', + ); + }); +}); diff --git a/frontend-modern/src/api/agentContext.ts b/frontend-modern/src/api/agentContext.ts new file mode 100644 index 0000000000..9f6b7e46a1 --- /dev/null +++ b/frontend-modern/src/api/agentContext.ts @@ -0,0 +1,85 @@ +import { apiFetchJSON } from '@/utils/apiClient'; + +export interface AgentResourceContextFact { + label: string; + value: string; + source?: string; + trustTier?: string; + observedAt?: string; + redacted?: boolean; +} + +export interface AgentResourceContextRedaction { + field: string; + reason: string; +} + +export interface AgentResourceContextSection { + id: string; + title: string; + summary?: string; + source: string; + trustTier: string; + observedAt?: string; + generatedAt: string; + facts: AgentResourceContextFact[]; + redactions?: AgentResourceContextRedaction[]; +} + +export interface AgentResourceFindingSnapshot { + id: string; + title: string; + severity: string; + category?: string; + description?: string; + impact?: string; + recommendation?: string; + confidence?: string; + regressionCount: number; + previousResolvedFixSummary?: string; + detectedAt?: string; + lastSeenAt?: string; +} + +export interface AgentResourceApprovalSummary { + id: string; + riskLevel: string; + requestedBy?: string; + requestedAt: string; + expiresAt: string; + command?: string; + commandRedacted?: boolean; +} + +export interface AgentResourceActionSummary { + id: string; + capabilityName: string; + state: string; + success: boolean; + errorMessage?: string; + requestedBy?: string; + createdAt: string; + updatedAt: string; + command?: string; + commandRedacted?: boolean; +} + +export interface AgentResourceContext { + canonicalId: string; + resourceType: string; + resourceName: string; + technology?: string; + activeFindings: AgentResourceFindingSnapshot[]; + pendingApprovals: AgentResourceApprovalSummary[]; + recentActions: AgentResourceActionSummary[]; + contextSections: AgentResourceContextSection[]; + generatedAt: string; +} + +export const AgentContextAPI = { + getResourceContext(resourceId: string): Promise { + return apiFetchJSON( + `/api/agent/resource-context/${encodeURIComponent(resourceId)}`, + ); + }, +}; diff --git a/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx b/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx index 0658ce3065..878548c249 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx @@ -72,26 +72,27 @@ const { error: vi.fn(), }; - const emptyChatContext = () => ({ - targetType: undefined, - targetId: undefined, - findingId: undefined, - autonomousMode: undefined, - context: undefined, + const emptyChatContext = () => ({ + targetType: undefined, + targetId: undefined, + findingId: undefined, + autonomousMode: undefined, + context: undefined, briefing: undefined, handoffContext: undefined, handoffResources: undefined, handoffActions: undefined, + handoffMetadata: undefined, }); - const mockAiChatStore = { - isOpenSignal: vi.fn(() => true), - context: emptyChatContext() as { - targetType?: string; - targetId?: string; - findingId?: string; - autonomousMode?: boolean; - context?: Record; + const mockAiChatStore = { + isOpenSignal: vi.fn(() => true), + context: emptyChatContext() as { + targetType?: string; + targetId?: string; + findingId?: string; + autonomousMode?: boolean; + context?: Record; handoffContext?: string; handoffResources?: Array<{ id: string; @@ -104,6 +105,13 @@ const { approvalId?: string; approvalStatus?: string; }>; + handoffMetadata?: { + kind?: string; + runId?: string; + runType?: string; + runStatus?: string; + runtimeFailure?: boolean; + }; briefing?: { sourceLabel: string; title: string; @@ -132,6 +140,7 @@ const { handoffContext: _handoffContext, handoffResources: _handoffResources, handoffActions: _handoffActions, + handoffMetadata: _handoffMetadata, ...rest } = mockAiChatStore.context; mockAiChatStore.context = rest; @@ -398,8 +407,7 @@ describe('AIChat', () => { briefing: { sourceLabel: 'Pulse Patrol', title: 'Patrol configuration failure attached', - subject: - 'patrol_autonomy_pro_required: Investigation and auto-fix require Pulse Pro.', + subject: 'patrol_autonomy_pro_required: Investigation and auto-fix require Pulse Pro.', statusLabel: 'HTTP 402 · model_unsupported_tools', detailLines: ['Provider: openrouter'], evidence: ['Command: sensitive or command detail withheld'], @@ -418,6 +426,47 @@ describe('AIChat', () => { expect(context).toHaveTextContent('Approval required before any action.'); }); + it('renders resource context handoff details without prompt text injection', () => { + mockAiChatStore.context = { + targetType: 'resource', + autonomousMode: false, + handoffResources: [ + { + id: 'app-container:homeassistant', + name: 'Home Assistant', + type: 'app-container', + node: 'ha-lxc', + }, + ], + handoffMetadata: { + kind: 'resource_context', + }, + briefing: { + sourceLabel: 'Pulse resource context', + title: 'Home Assistant', + subject: 'app-container / online / docker', + statusLabel: 'Read-only context attached', + detailLines: [ + 'Resource ID: app-container:homeassistant', + 'Parent: ha-lxc', + 'Discovery: app-container:homeassistant', + ], + safetyNote: 'Approval required before any action.', + }, + }; + + renderChat(); + + const context = screen.getByLabelText('Assistant context'); + expect(context).toHaveTextContent('Pulse resource context'); + expect(context).toHaveTextContent('Home Assistant'); + expect(context).toHaveTextContent('app-container / online / docker'); + expect(context).toHaveTextContent('Resource ID: app-container:homeassistant'); + expect(context).toHaveTextContent('Parent: ha-lxc'); + expect(context).toHaveTextContent('Approval required before any action.'); + expect(context).not.toHaveTextContent('[Resource Context Pack]'); + }); + it('renders Patrol run handoff details without replacing the attached headline', () => { mockAiChatStore.context = { targetType: 'patrol-run', diff --git a/frontend-modern/src/components/AI/Chat/index.tsx b/frontend-modern/src/components/AI/Chat/index.tsx index 7ecb55331c..a61c54a873 100644 --- a/frontend-modern/src/components/AI/Chat/index.tsx +++ b/frontend-modern/src/components/AI/Chat/index.tsx @@ -67,6 +67,7 @@ const MODEL_SESSION_STORAGE_KEY = 'pulse:ai_chat_models_by_session'; const DEFAULT_SESSION_KEY = '__default__'; const AI_CHAT_MIN_DOCKED_VIEWPORT_WIDTH = 1200; const STRUCTURED_PATROL_CONTEXT_TARGETS = new Set(['patrol-configuration', 'patrol-run']); +const STRUCTURED_RESOURCE_CONTEXT_HANDOFF_KINDS = new Set(['resource_context']); interface AIChatProps { onClose: () => void; @@ -81,6 +82,10 @@ const pluralizeCount = (count: number, singular: string, plural: string) => const shouldShowStructuredPatrolContext = (targetType: string | undefined) => STRUCTURED_PATROL_CONTEXT_TARGETS.has(targetType ?? ''); +const shouldShowStructuredBriefingContext = (context: AIChatContext) => + shouldShowStructuredPatrolContext(context.targetType) || + STRUCTURED_RESOURCE_CONTEXT_HANDOFF_KINDS.has(context.handoffMetadata?.kind ?? ''); + const isPatrolFindingSessionHandoff = (summary: ChatSessionHandoffSummary) => summary.kind === 'patrol_finding' || Boolean(summary.finding_id); @@ -515,7 +520,7 @@ export const AIChat: Component = (props) => { const title = briefing.title?.trim() ?? ''; const subject = briefing.subject?.trim() ?? ''; - if (shouldShowStructuredPatrolContext(aiChatStore.context.targetType)) { + if (shouldShowStructuredBriefingContext(aiChatStore.context)) { return title || subject || 'Context attached'; } if (subject && /attached|briefing/i.test(title)) { @@ -527,7 +532,7 @@ export const AIChat: Component = (props) => { const contextBriefingDetailLines = createMemo(() => { const briefing = contextBriefing(); if (!briefing) return []; - if (!shouldShowStructuredPatrolContext(aiChatStore.context.targetType)) return []; + if (!shouldShowStructuredBriefingContext(aiChatStore.context)) return []; const seen = new Set(); const lines: string[] = []; diff --git a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx index cb5abc772f..c9177726be 100644 --- a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx +++ b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx @@ -1,5 +1,8 @@ import { Show, For, Suspense } from 'solid-js'; import type { Component } from 'solid-js'; +import CopyIcon from 'lucide-solid/icons/copy'; +import MessageSquareIcon from 'lucide-solid/icons/message-square'; +import XIcon from 'lucide-solid/icons/x'; import type { Resource } from '@/types/resource'; import { StatusDot } from '@/components/shared/StatusDot'; import { Subtabs } from '@/components/shared/Subtabs'; @@ -102,30 +105,50 @@ const DrawerContent: Component = (props) => { - +
+ + + - + + + +
drawer.setActiveTab(value as Parameters[0])} + onChange={(value) => + drawer.setActiveTab(value as Parameters[0]) + } tabs={drawer.tabs().map((tab) => ({ value: tab.id, label: tab.label }))} /> diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.identity-runtime.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.identity-runtime.test.tsx index 97ff756c24..48bbc6caa7 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.identity-runtime.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.identity-runtime.test.tsx @@ -1,7 +1,8 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { fireEvent, render, waitFor, within } from '@solidjs/testing-library'; import type { Resource } from '@/types/resource'; +import { aiChatStore } from '@/stores/aiChat'; import { ResourceDetailDrawer, getSpecializedTabAvailabilityMessage, @@ -9,6 +10,10 @@ import { const wsState = vi.hoisted(() => ({ pmg: [] as any[] })); const reconnectSpy = vi.hoisted(() => vi.fn()); +const getResourceContextMock = vi.hoisted(() => vi.fn()); +const copyToClipboardMock = vi.hoisted(() => vi.fn()); +const notificationSuccessMock = vi.hoisted(() => vi.fn()); +const notificationErrorMock = vi.hoisted(() => vi.fn()); vi.mock('@/contexts/appRuntime', () => ({ useWebSocket: () => ({ @@ -80,6 +85,23 @@ vi.mock('@/api/ai', () => ({ }, })); +vi.mock('@/api/agentContext', () => ({ + AgentContextAPI: { + getResourceContext: getResourceContextMock, + }, +})); + +vi.mock('@/utils/clipboard', () => ({ + copyToClipboard: copyToClipboardMock, +})); + +vi.mock('@/stores/notifications', () => ({ + notificationStore: { + success: notificationSuccessMock, + error: notificationErrorMock, + }, +})); + class ResizeObserverMock { constructor(_callback: ResizeObserverCallback) {} observe() {} @@ -105,7 +127,86 @@ const baseResource = (overrides: Partial): Resource => ({ ...overrides, }); +afterEach(() => { + aiChatStore.setEnabled(false); + getResourceContextMock.mockReset(); + copyToClipboardMock.mockReset(); + notificationSuccessMock.mockReset(); + notificationErrorMock.mockReset(); +}); + describe('ResourceDetailDrawer runtime and identity cards', () => { + it('opens Pulse Assistant with a resource-scoped handoff from the drawer header', () => { + aiChatStore.setEnabled(true); + const openSpy = vi.spyOn(aiChatStore, 'open'); + const resource = baseResource({ + type: 'app-container', + displayName: 'Home Assistant', + technology: 'docker', + parentName: 'ha-lxc', + discoveryTarget: { + resourceType: 'app-container', + agentId: 'agent:pve-1', + resourceId: 'homeassistant', + }, + }); + + const { getByRole } = render(() => ); + + fireEvent.click(getByRole('button', { name: 'Ask Pulse Assistant about Home Assistant' })); + + expect(openSpy).toHaveBeenCalledWith( + expect.objectContaining({ + targetType: 'resource', + targetId: 'resource-1', + autonomousMode: false, + handoffResources: [ + { + id: 'resource-1', + name: 'Home Assistant', + type: 'app-container', + node: 'ha-lxc', + }, + ], + handoffMetadata: { kind: 'resource_context' }, + }), + ); + openSpy.mockRestore(); + }); + + it('copies the canonical agent resource context from the drawer header', async () => { + getResourceContextMock.mockResolvedValue({ + canonicalId: 'resource-1', + resourceType: 'agent', + resourceName: 'host-1', + activeFindings: [], + pendingApprovals: [], + recentActions: [], + generatedAt: '2026-05-06T14:00:00Z', + contextSections: [ + { + id: 'identity', + title: 'Identity', + source: 'unified-resource', + trustTier: 'pulse-authored', + generatedAt: '2026-05-06T14:00:00Z', + facts: [{ label: 'Display name', value: 'host-1' }], + }, + ], + }); + copyToClipboardMock.mockResolvedValue(true); + + const { getByRole } = render(() => ); + + fireEvent.click(getByRole('button', { name: 'Copy Pulse context for host-1' })); + + await waitFor(() => { + expect(getResourceContextMock).toHaveBeenCalledWith('resource-1'); + expect(copyToClipboardMock).toHaveBeenCalledWith(expect.stringContaining('host-1')); + expect(notificationSuccessMock).toHaveBeenCalledWith('Resource context copied.'); + }); + }); + it('renders a unified summary shell without repeating healthy source status', () => { const resource = baseResource({ platformData: { diff --git a/frontend-modern/src/components/Infrastructure/__tests__/resourceAssistantContextModel.test.ts b/frontend-modern/src/components/Infrastructure/__tests__/resourceAssistantContextModel.test.ts new file mode 100644 index 0000000000..a9e303b16c --- /dev/null +++ b/frontend-modern/src/components/Infrastructure/__tests__/resourceAssistantContextModel.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { buildResourceAssistantContext } from '@/utils/resourceAssistantContextModel'; +import type { Resource } from '@/types/resource'; + +const resource: Resource = { + id: 'app-container:homeassistant', + type: 'app-container', + name: 'homeassistant', + displayName: 'Home Assistant', + platformId: 'homeassistant', + platformType: 'docker', + sourceType: 'agent', + status: 'online', + technology: 'docker', + parentName: 'ha-lxc', + lastSeen: Date.now(), + discoveryTarget: { + resourceType: 'app-container', + agentId: 'agent:pve-1', + resourceId: 'homeassistant', + }, +}; + +describe('buildResourceAssistantContext', () => { + it('creates a model-only resource handoff without prefilled prompt context', () => { + const context = buildResourceAssistantContext(resource); + + expect(context.targetType).toBe('resource'); + expect(context.targetId).toBe('app-container:homeassistant'); + expect(context.autonomousMode).toBe(false); + expect(context.handoffContext).toBeUndefined(); + expect(context.handoffResources).toEqual([ + { + id: 'app-container:homeassistant', + name: 'Home Assistant', + type: 'app-container', + node: 'ha-lxc', + }, + ]); + expect(context.handoffMetadata).toEqual({ kind: 'resource_context' }); + expect(context.briefing?.detailLines).toContain('Resource ID: app-container:homeassistant'); + expect(context.briefing?.detailLines).toContain('Parent: ha-lxc'); + expect(context.briefing?.detailLines).toContain('Discovery: app-container:homeassistant'); + }); +}); diff --git a/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerIdentityModel.test.ts b/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerIdentityModel.test.ts index 4db57a26ed..6c5a640564 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerIdentityModel.test.ts +++ b/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerIdentityModel.test.ts @@ -8,6 +8,7 @@ import { buildSourceSections, } from '@/components/Infrastructure/resourceDetailDrawerIdentityModel'; import type { Resource } from '@/types/resource'; +import { buildResourceAssistantContext } from '@/utils/resourceAssistantContextModel'; const baseResource = (overrides: Partial = {}): Resource => ({ id: 'resource-1', @@ -42,9 +43,7 @@ describe('resourceDetailDrawerIdentityModel', () => { value: 'vm:101', }); expect(identityView.identityIpValues).toEqual(['192.0.2.10']); - expect(identityView.aliasPreviewValues).toEqual( - identityView.identityAliasValues.slice(0, 4), - ); + expect(identityView.aliasPreviewValues).toEqual(identityView.identityAliasValues.slice(0, 4)); expect(identityView.hasAliasOverflow).toBe(true); expect(identityView.identityCardHasRichData).toBe(true); }); @@ -75,6 +74,39 @@ describe('resourceDetailDrawerIdentityModel', () => { ).toBeNull(); }); + it('builds resource Assistant handoffs from canonical resource identity without prompt text', () => { + const context = buildResourceAssistantContext( + baseResource({ + id: 'system-container-6adaf34f529d241a', + type: 'system-container', + name: 'homeassistant', + displayName: 'homeassistant', + technology: 'lxc', + parentName: 'delly', + discoveryTarget: { + resourceType: 'system-container', + agentId: 'delly', + resourceId: '101', + hostname: 'homeassistant', + }, + }), + ); + + expect(context.targetType).toBe('resource'); + expect(context.targetId).toBe('system-container-6adaf34f529d241a'); + expect(context.handoffContext).toBeUndefined(); + expect(context.handoffMetadata).toEqual({ kind: 'resource_context' }); + expect(context.handoffResources).toEqual([ + { + id: 'system-container-6adaf34f529d241a', + name: 'homeassistant', + type: 'system-container', + node: 'delly', + }, + ]); + expect(context.briefing?.detailLines).toContain('Discovery: system-container:101'); + }); + it('builds canonical source sections and identity-match precedence', () => { const platformData = { agent: { hostname: 'host-1' }, @@ -110,12 +142,12 @@ describe('resourceDetailDrawerIdentityModel', () => { resource, platformData: { proxmox: { nodeName: 'pve-1' }, - docker: { runtime: 'docker' }, - vmware: { connectionName: 'Lab VC' }, - }, - sourceStatus: { - proxmox: { status: 'online' }, - }, + docker: { runtime: 'docker' }, + vmware: { connectionName: 'Lab VC' }, + }, + sourceStatus: { + proxmox: { status: 'online' }, + }, identityMatchInfo: { matchedBy: 'hostname' }, }), ).toEqual({ diff --git a/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts b/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts index f290d264d0..94dad4e066 100644 --- a/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts +++ b/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts @@ -1,12 +1,18 @@ import { createEffect, createSignal } from 'solid-js'; +import { AgentContextAPI } from '@/api/agentContext'; import type { Resource } from '@/types/resource'; import type { HistoryTimeRange } from '@/api/charts'; import { GUEST_DRAWER_HISTORY_DEFAULT_RANGE } from '@/components/Workloads/guestDrawerModel'; +import { aiChatStore } from '@/stores/aiChat'; +import { notificationStore } from '@/stores/notifications'; import { createLocalStorageBooleanSignal, STORAGE_KEYS } from '@/utils/localStorage'; import { isPulseAgentPlatformResource } from '@/utils/agentResources'; +import { copyToClipboard } from '@/utils/clipboard'; +import { formatAgentResourceContextForClipboard } from '@/utils/agentContextPresentation'; import { useResourceDetailDrawerDockerActionsState } from './useResourceDetailDrawerDockerActionsState'; import { useResourceDetailDrawerHistoryState } from './useResourceDetailDrawerHistoryState'; import { useResourceDetailDrawerDerivedState } from './useResourceDetailDrawerDerivedState'; +import { buildResourceAssistantContext } from '@/utils/resourceAssistantContextModel'; import type { ResourceDetailDrawerPresentation } from './resourceDetailDrawerPresentation'; type DrawerTab = @@ -35,6 +41,8 @@ export const useResourceDetailDrawerState = (options: UseResourceDetailDrawerSta ); const [debugEnabled] = createLocalStorageBooleanSignal(STORAGE_KEYS.DEBUG_MODE, false); const [copied, setCopied] = createSignal(false); + const [copyingAgentContext, setCopyingAgentContext] = createSignal(false); + const [agentContextCopied, setAgentContextCopied] = createSignal(false); const [showReportModal, setShowReportModal] = createSignal(false); const [showHistoryFilters, setShowHistoryFilters] = createSignal(false); const [showAccessContext, setShowAccessContext] = createSignal( @@ -113,6 +121,35 @@ export const useResourceDetailDrawerState = (options: UseResourceDetailDrawerSta } }; + const assistantAvailable = () => aiChatStore.enabled === true; + + const openAssistantForResource = () => { + if (!assistantAvailable()) return; + aiChatStore.open(buildResourceAssistantContext(resource)); + }; + + const copyAgentContext = async () => { + if (copyingAgentContext()) return; + setCopyingAgentContext(true); + setAgentContextCopied(false); + + try { + const context = await AgentContextAPI.getResourceContext(resource.id); + const copiedContext = await copyToClipboard(formatAgentResourceContextForClipboard(context)); + if (!copiedContext) { + throw new Error('Clipboard unavailable'); + } + setAgentContextCopied(true); + notificationStore.success('Resource context copied.'); + setTimeout(() => setAgentContextCopied(false), 2000); + } catch { + notificationStore.error('Unable to copy resource context.'); + setAgentContextCopied(false); + } finally { + setCopyingAgentContext(false); + } + }; + return { activeTab, setActiveTab, @@ -120,6 +157,8 @@ export const useResourceDetailDrawerState = (options: UseResourceDetailDrawerSta setMetricsHistoryRange, debugEnabled, copied, + copyingAgentContext, + agentContextCopied, showReportModal, setShowReportModal, showHistoryFilters, @@ -147,6 +186,9 @@ export const useResourceDetailDrawerState = (options: UseResourceDetailDrawerSta ...history, ...derived, ...dockerActions, + assistantAvailable, + openAssistantForResource, + copyAgentContext, handleCopyJson, }; }; diff --git a/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx b/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx index 02c8f5b8cd..9cf65db801 100644 --- a/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx +++ b/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx @@ -4,6 +4,7 @@ import { Suspense } from 'solid-js'; import type { WorkloadGuest } from '@/types/workloads'; import type { Memory, Disk, GuestNetworkInterface } from '@/types/api'; import { resetCreateNonSuspendingQueryCacheForTest } from '@/hooks/createNonSuspendingQuery'; +import { aiChatStore } from '@/stores/aiChat'; import { getCanonicalWorkloadId } from '@/utils/workloads'; import { getDiscoveryProvenanceTitle } from '@/utils/discoveryPresentation'; @@ -58,6 +59,10 @@ const discoveryApiMocks = vi.hoisted(() => ({ null, ), })); +const getResourceContextMock = vi.hoisted(() => vi.fn()); +const copyToClipboardMock = vi.hoisted(() => vi.fn()); +const notificationSuccessMock = vi.hoisted(() => vi.fn()); +const notificationErrorMock = vi.hoisted(() => vi.fn()); vi.mock('@/api/discovery', async () => { const actual = await vi.importActual('@/api/discovery'); @@ -67,6 +72,23 @@ vi.mock('@/api/discovery', async () => { }; }); +vi.mock('@/api/agentContext', () => ({ + AgentContextAPI: { + getResourceContext: getResourceContextMock, + }, +})); + +vi.mock('@/utils/clipboard', () => ({ + copyToClipboard: copyToClipboardMock, +})); + +vi.mock('@/stores/notifications', () => ({ + notificationStore: { + success: notificationSuccessMock, + error: notificationErrorMock, + }, +})); + vi.mock('@/components/shared/WebInterfaceUrlField', () => ({ WebInterfaceUrlField: (props: { metadataKind: string; @@ -150,6 +172,7 @@ beforeEach(() => { afterEach(() => { cleanup(); + aiChatStore.setEnabled(false); vi.clearAllMocks(); vi.useRealTimers(); }); @@ -157,6 +180,96 @@ afterEach(() => { // ── Tests ────────────────────────────────────────────────────────────── describe('GuestDrawer', () => { + describe('Assistant context actions', () => { + it('opens Pulse Assistant with a canonical Proxmox LXC resource handoff', () => { + aiChatStore.setEnabled(true); + const openSpy = vi.spyOn(aiChatStore, 'open'); + const guest = makeGuest({ + id: 'legacy-row-id', + instance: 'pve-main', + node: 'pve-a', + vmid: 101, + name: 'homeassistant', + type: 'lxc', + workloadType: 'system-container', + platformType: 'proxmox', + status: 'running', + }); + + render(() => ); + + fireEvent.click( + screen.getByRole('button', { name: 'Ask Pulse Assistant about homeassistant' }), + ); + + expect(openSpy).toHaveBeenCalledWith( + expect.objectContaining({ + targetType: 'resource', + targetId: 'pve-main:pve-a:101', + autonomousMode: false, + handoffResources: [ + { + id: 'pve-main:pve-a:101', + name: 'homeassistant', + type: 'system-container', + node: 'pve-a', + }, + ], + handoffMetadata: { kind: 'resource_context' }, + }), + ); + expect(openSpy.mock.calls[0]?.[0]?.handoffContext).toBeUndefined(); + expect(openSpy.mock.calls[0]?.[0]?.briefing?.detailLines).toContain( + 'Discovery: system-container:101', + ); + openSpy.mockRestore(); + }); + + it('copies the safe agent context bundle using canonical guest identity', async () => { + getResourceContextMock.mockResolvedValue({ + canonicalId: 'pve-main:pve-a:101', + resourceType: 'system-container', + resourceName: 'homeassistant', + activeFindings: [], + pendingApprovals: [], + recentActions: [], + generatedAt: '2026-05-06T14:00:00Z', + contextSections: [ + { + id: 'runtime', + title: 'Runtime', + source: 'discovery', + trustTier: 'pulse-observed', + generatedAt: '2026-05-06T14:00:00Z', + facts: [{ label: 'Service', value: 'Home Assistant' }], + }, + ], + }); + copyToClipboardMock.mockResolvedValue(true); + const guest = makeGuest({ + id: 'legacy-row-id', + instance: 'pve-main', + node: 'pve-a', + vmid: 101, + name: 'homeassistant', + type: 'lxc', + workloadType: 'system-container', + }); + + render(() => ); + + fireEvent.click(screen.getByRole('button', { name: 'Copy Pulse context for homeassistant' })); + + await waitFor(() => { + expect(getResourceContextMock).toHaveBeenCalledWith('pve-main:pve-a:101'); + expect(copyToClipboardMock).toHaveBeenCalledWith( + expect.stringContaining('Pulse resource context: homeassistant'), + ); + expect(notificationSuccessMock).toHaveBeenCalledWith('Resource context copied.'); + }); + }); + }); + // ── Tabs ── describe('tab switching', () => { diff --git a/frontend-modern/src/components/Workloads/GuestDrawer.tsx b/frontend-modern/src/components/Workloads/GuestDrawer.tsx index e03793b4b9..592c15c2cd 100644 --- a/frontend-modern/src/components/Workloads/GuestDrawer.tsx +++ b/frontend-modern/src/components/Workloads/GuestDrawer.tsx @@ -1,4 +1,7 @@ import { Component, Show, Suspense, createMemo } from 'solid-js'; +import CopyIcon from 'lucide-solid/icons/copy'; +import MessageSquareIcon from 'lucide-solid/icons/message-square'; +import XIcon from 'lucide-solid/icons/x'; import { DiscoveryTab } from '../Discovery/DiscoveryTab'; import { DrawerSubjectHeading } from '@/components/shared/DrawerSubjectHeading'; import { Subtabs, type SubtabOption } from '@/components/shared/Subtabs'; @@ -13,7 +16,9 @@ export const GuestDrawer: Component = (props) => { activeTab, agentLabel, agentTitle, + agentContextCopied, backupPresentation, + copyingAgentContext, discoveryAgentId, discoveryIdentifiedSummary, discoveryLoadingState, @@ -33,6 +38,9 @@ export const GuestDrawer: Component = (props) => { guestOsSummary, networkInterfaces, normalizedTags, + assistantAvailable, + copyAgentContext, + openAssistantForGuest, setHistoryRange, showInGuestAgentInstallCue, switchTab, @@ -47,12 +55,47 @@ export const GuestDrawer: Component = (props) => { return (
- +
+ +
+ + + + + +
+
): string | undefined => { + for (const value of values) { + const trimmed = (value || '').trim(); + if (trimmed) return trimmed; + } + return undefined; +}; + +export const getGuestAssistantTechnology = (guest: WorkloadGuest): string | undefined => { + const workloadType = resolveWorkloadType(guest); + if (workloadType === 'app-container') { + return firstTrimmed([guest.containerRuntime, guest.platformType, guest.type]); + } + if (workloadType === 'pod') { + return firstTrimmed([guest.platformType, 'kubernetes']); + } + return firstTrimmed([guest.platformType, guest.type]); +}; + +export const buildGuestAssistantContext = (guest: WorkloadGuest): AIChatContext => { + const canonicalId = getCanonicalWorkloadId(guest); + const workloadType = resolveWorkloadType(guest); + const discoveryTarget = resolveDiscoveryTargetForWorkload(guest); + + return buildResourceAssistantContextForTarget({ + id: canonicalId, + name: guest.name || canonicalId, + type: workloadType, + source: 'guest-drawer', + status: guest.status, + technology: getGuestAssistantTechnology(guest), + parentName: guest.node, + primaryIdentity: guest.id, + discoveryTarget, + }); +}; diff --git a/frontend-modern/src/components/Workloads/useGuestDrawerState.ts b/frontend-modern/src/components/Workloads/useGuestDrawerState.ts index fdabca1661..f8ea3d682e 100644 --- a/frontend-modern/src/components/Workloads/useGuestDrawerState.ts +++ b/frontend-modern/src/components/Workloads/useGuestDrawerState.ts @@ -1,10 +1,15 @@ import { createEffect, createMemo, createSignal } from 'solid-js'; +import { AgentContextAPI } from '@/api/agentContext'; import { getDiscovery } from '@/api/discovery'; import type { HistoryTimeRange } from '@/api/charts'; import { createNonSuspendingQuery } from '@/hooks/createNonSuspendingQuery'; +import { aiChatStore } from '@/stores/aiChat'; import { useAlertsActivation } from '@/stores/alertsActivation'; +import { notificationStore } from '@/stores/notifications'; import type { ResourceDiscovery, ResourceType as DiscoveryResourceType } from '@/types/discovery'; +import { formatAgentResourceContextForClipboard } from '@/utils/agentContextPresentation'; +import { copyToClipboard } from '@/utils/clipboard'; import { getDiscoveryIdentifiedSummary, getDiscoveryLoadingState, @@ -36,6 +41,7 @@ import { type GuestDrawerProps, type GuestDrawerTab, } from './guestDrawerModel'; +import { buildGuestAssistantContext } from './guestAssistantContextModel'; import { shouldShowInGuestAgentInstallCue } from './workloadAgentReadiness'; interface GuestDiscoverySourceKey { @@ -50,6 +56,8 @@ export function useGuestDrawerState(props: GuestDrawerProps) { const [historyRange, setHistoryRange] = createSignal( GUEST_DRAWER_HISTORY_DEFAULT_RANGE, ); + const [copyingAgentContext, setCopyingAgentContext] = createSignal(false); + const [agentContextCopied, setAgentContextCopied] = createSignal(false); const guestId = createMemo(() => getCanonicalWorkloadId(props.guest)); const alertThresholdScope = createMemo(() => getWorkloadAlertThresholdScope(props.guest)); @@ -109,7 +117,10 @@ export function useGuestDrawerState(props: GuestDrawerProps) { if (!type || !agent || !resource) return null; return { type, agent, resource } as GuestDiscoverySourceKey; }); - const discoveryRecord = createNonSuspendingQuery({ + const discoveryRecord = createNonSuspendingQuery< + ResourceDiscovery | null, + GuestDiscoverySourceKey + >({ source: discoverySourceKey, initialValue: null, cacheKey: (key) => `guest-drawer-discovery:${key.type}:${key.agent}:${key.resource}`, @@ -129,6 +140,35 @@ export function useGuestDrawerState(props: GuestDrawerProps) { setActiveTab(tab); }; + const assistantAvailable = () => aiChatStore.enabled === true; + + const openAssistantForGuest = () => { + if (!assistantAvailable()) return; + aiChatStore.open(buildGuestAssistantContext(props.guest)); + }; + + const copyAgentContext = async () => { + if (copyingAgentContext()) return; + setCopyingAgentContext(true); + setAgentContextCopied(false); + + try { + const context = await AgentContextAPI.getResourceContext(guestId()); + const copiedContext = await copyToClipboard(formatAgentResourceContextForClipboard(context)); + if (!copiedContext) { + throw new Error('Clipboard unavailable'); + } + setAgentContextCopied(true); + notificationStore.success('Resource context copied.'); + setTimeout(() => setAgentContextCopied(false), 2000); + } catch { + notificationStore.error('Unable to copy resource context.'); + setAgentContextCopied(false); + } finally { + setCopyingAgentContext(false); + } + }; + createEffect(() => { if (activeTab() === 'discovery' && !hasDiscoverySupport()) { setActiveTab('overview'); @@ -143,6 +183,8 @@ export function useGuestDrawerState(props: GuestDrawerProps) { agentLabel, agentTitle, backupPresentation, + copyingAgentContext, + agentContextCopied, discoveryAgentId, discoveryIdentifiedSummary, discoveryLoadingState: getDiscoveryLoadingState(), @@ -165,6 +207,9 @@ export function useGuestDrawerState(props: GuestDrawerProps) { osName, osVersion, showInGuestAgentInstallCue, + assistantAvailable, + openAssistantForGuest, + copyAgentContext, switchTab, setHistoryRange, webInterfaceTargetLabel, diff --git a/frontend-modern/src/utils/__tests__/agentContextPresentation.test.ts b/frontend-modern/src/utils/__tests__/agentContextPresentation.test.ts new file mode 100644 index 0000000000..ad325ce429 --- /dev/null +++ b/frontend-modern/src/utils/__tests__/agentContextPresentation.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { formatAgentResourceContextForClipboard } from '@/utils/agentContextPresentation'; + +describe('formatAgentResourceContextForClipboard', () => { + it('formats bounded resource context sections with provenance and redactions', () => { + const text = formatAgentResourceContextForClipboard({ + canonicalId: 'app-container:homeassistant', + resourceType: 'app-container', + resourceName: 'homeassistant', + technology: 'docker', + activeFindings: [], + pendingApprovals: [], + recentActions: [], + generatedAt: '2026-05-06T14:00:00Z', + contextSections: [ + { + id: 'runtime', + title: 'Runtime and Discovery', + source: 'unified-resource', + trustTier: 'runtime-observed', + observedAt: '2026-05-06T13:59:00Z', + generatedAt: '2026-05-06T14:00:00Z', + facts: [ + { + label: 'Ports', + value: '8123:8123/tcp', + source: 'unified-resource', + trustTier: 'discovered', + }, + { + label: 'Storage path', + value: 'redacted by policy', + source: 'unified-resource', + trustTier: 'runtime-observed', + redacted: true, + }, + ], + redactions: [ + { + field: 'resource identifiers', + reason: 'canonical resource policy redacts path', + }, + ], + }, + ], + }); + + expect(text).toContain('# Pulse resource context: homeassistant'); + expect(text).toContain('Canonical ID: app-container:homeassistant'); + expect(text).toContain('## Runtime and Discovery'); + expect(text).toContain('- Ports: 8123:8123/tcp'); + expect(text).toContain('- Storage path: redacted by policy'); + expect(text).toContain('redacted=true'); + expect(text).toContain('Redaction: resource identifiers'); + }); +}); diff --git a/frontend-modern/src/utils/agentContextPresentation.ts b/frontend-modern/src/utils/agentContextPresentation.ts new file mode 100644 index 0000000000..2b6346b506 --- /dev/null +++ b/frontend-modern/src/utils/agentContextPresentation.ts @@ -0,0 +1,78 @@ +import type { + AgentResourceContext, + AgentResourceContextFact, + AgentResourceContextRedaction, + AgentResourceContextSection, +} from '@/api/agentContext'; + +const formatOptionalTime = (value?: string): string => { + if (!value) return ''; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return date.toISOString(); +}; + +const formatProvenance = (fact: AgentResourceContextFact): string => { + const parts = [ + fact.source ? `source=${fact.source}` : '', + fact.trustTier ? `trust=${fact.trustTier}` : '', + fact.observedAt ? `observed=${formatOptionalTime(fact.observedAt)}` : '', + fact.redacted ? 'redacted=true' : '', + ].filter(Boolean); + return parts.length > 0 ? ` (${parts.join(', ')})` : ''; +}; + +const formatFact = (fact: AgentResourceContextFact): string => + `- ${fact.label}: ${fact.value}${formatProvenance(fact)}`; + +const formatRedaction = (redaction: AgentResourceContextRedaction): string => + `- Redaction: ${redaction.field}${redaction.reason ? ` - ${redaction.reason}` : ''}`; + +const formatSectionHeader = (section: AgentResourceContextSection): string => { + const provenance = [ + section.source ? `source=${section.source}` : '', + section.trustTier ? `trust=${section.trustTier}` : '', + section.observedAt ? `observed=${formatOptionalTime(section.observedAt)}` : '', + ].filter(Boolean); + return provenance.length > 0 + ? `## ${section.title} (${provenance.join(', ')})` + : `## ${section.title}`; +}; + +export const formatAgentResourceContextForClipboard = (context: AgentResourceContext): string => { + const lines = [ + `# Pulse resource context: ${context.resourceName || context.canonicalId}`, + '', + `Generated: ${formatOptionalTime(context.generatedAt)}`, + `Canonical ID: ${context.canonicalId}`, + `Resource type: ${context.resourceType}`, + ]; + + if (context.technology) { + lines.push(`Technology: ${context.technology}`); + } + + lines.push( + `Active findings: ${context.activeFindings.length}`, + `Pending approvals: ${context.pendingApprovals.length}`, + `Recent actions: ${context.recentActions.length}`, + '', + 'Context facts below are bounded, read-only Pulse context. Redacted values were withheld by policy.', + ); + + for (const section of context.contextSections) { + if (section.facts.length === 0 && !section.redactions?.length) continue; + lines.push('', formatSectionHeader(section)); + if (section.summary) { + lines.push(section.summary); + } + for (const fact of section.facts) { + lines.push(formatFact(fact)); + } + for (const redaction of section.redactions ?? []) { + lines.push(formatRedaction(redaction)); + } + } + + return `${lines.join('\n').trim()}\n`; +}; diff --git a/frontend-modern/src/utils/resourceAssistantContextModel.ts b/frontend-modern/src/utils/resourceAssistantContextModel.ts new file mode 100644 index 0000000000..a3b42d75ef --- /dev/null +++ b/frontend-modern/src/utils/resourceAssistantContextModel.ts @@ -0,0 +1,82 @@ +import type { AIChatContext } from '@/stores/aiChat'; +import type { Resource, ResourceDiscoveryTarget } from '@/types/resource'; +import { + getPreferredResourceDisplayName, + getPrimaryResourceIdentity, +} from '@/utils/resourceIdentity'; + +const compact = (values: Array): string[] => + values.filter((value): value is string => typeof value === 'string' && value.trim().length > 0); + +export interface ResourceAssistantContextTarget { + id: string; + name: string; + type: string; + source: string; + status?: string; + technology?: string; + parentName?: string; + primaryIdentity?: string; + discoveryTarget?: ResourceDiscoveryTarget | null; +} + +export const buildResourceAssistantContextForTarget = ( + target: ResourceAssistantContextTarget, +): AIChatContext => { + const displayName = target.name || target.id; + const subjectParts = compact([target.type, target.status, target.technology]); + const detailLines = compact([ + `Resource ID: ${target.id}`, + target.primaryIdentity && target.primaryIdentity !== target.id + ? `Primary identity: ${target.primaryIdentity}` + : '', + target.parentName ? `Parent: ${target.parentName}` : '', + target.discoveryTarget + ? `Discovery: ${target.discoveryTarget.resourceType}:${target.discoveryTarget.resourceId}` + : '', + ]); + + return { + targetType: 'resource', + targetId: target.id, + context: { + source: target.source, + resourceId: target.id, + resourceType: target.type, + resourceStatus: target.status, + }, + briefing: { + sourceLabel: 'Pulse resource context', + title: displayName, + subject: subjectParts.join(' / '), + statusLabel: 'Read-only context attached', + detailLines, + safetyNote: 'Approval required before any action.', + }, + handoffResources: [ + { + id: target.id, + name: displayName, + type: target.type, + node: target.parentName, + }, + ], + handoffMetadata: { + kind: 'resource_context', + }, + autonomousMode: false, + }; +}; + +export const buildResourceAssistantContext = (resource: Resource): AIChatContext => + buildResourceAssistantContextForTarget({ + id: resource.id, + name: getPreferredResourceDisplayName(resource), + type: resource.type, + source: 'resource-detail-drawer', + status: resource.status, + technology: resource.technology, + parentName: resource.parentName, + primaryIdentity: getPrimaryResourceIdentity(resource), + discoveryTarget: resource.discoveryTarget, + }); diff --git a/internal/agentcontext/resource_context_sections.go b/internal/agentcontext/resource_context_sections.go new file mode 100644 index 0000000000..bef3cff99e --- /dev/null +++ b/internal/agentcontext/resource_context_sections.go @@ -0,0 +1,738 @@ +package agentcontext + +import ( + "fmt" + "strings" + "time" + + unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" +) + +// Fact is a bounded, typed fact in a resource context pack. Values come +// from Pulse-owned runtime state only; raw command output, config files, +// environment variables, and secret-bearing metadata do not belong here. +type Fact struct { + Label string `json:"label"` + Value string `json:"value"` + Source string `json:"source,omitempty"` + TrustTier string `json:"trustTier,omitempty"` + ObservedAt *time.Time `json:"observedAt,omitempty"` + Redacted bool `json:"redacted,omitempty"` +} + +// Redaction records why a context section withheld a field. It gives +// agents and UI inspectors a visible safety boundary without leaking +// the underlying value. +type Redaction struct { + Field string `json:"field"` + Reason string `json:"reason"` +} + +// Section groups related facts with one provenance/freshness envelope. +// Additive sections make a context pack useful to Assistant and external +// MCP clients without breaking older clients that parse top-level fields. +type Section struct { + ID string `json:"id"` + Title string `json:"title"` + Summary string `json:"summary,omitempty"` + Source string `json:"source"` + TrustTier string `json:"trustTier"` + ObservedAt *time.Time `json:"observedAt,omitempty"` + GeneratedAt time.Time `json:"generatedAt"` + Facts []Fact `json:"facts"` + Redactions []Redaction `json:"redactions,omitempty"` +} + +// OperatorState is the context-pack projection of operator intent. It is +// intentionally narrower than the persistence/API shape so this package +// stays independent of HTTP and storage owners. +type OperatorState struct { + IntentionallyOffline bool + NeverAutoRemediate bool + MaintenanceWindowActive bool + MaintenanceStartAt *time.Time + MaintenanceEndAt *time.Time + Criticality string + NotePresent bool + SetAt time.Time +} + +// BuildOptions supplies operational counts owned outside the unified +// resource model. +type BuildOptions struct { + GeneratedAt time.Time + OperatorState *OperatorState + ActiveFindingCount int + PendingApprovalCount int + RecentActionCount int +} + +const ( + agentContextTrustPulseAuthored = "pulse-authored" + agentContextTrustRuntimeObserved = "runtime-observed" + agentContextTrustDiscovered = "discovered" + agentContextTrustUserTaught = "user-taught" + + agentContextSourceUnifiedResource = "unified-resource" + agentContextSourceResourceStore = "resource-store" + agentContextSourceOperatorState = "operator-state" + agentContextSourcePatrol = "patrol" + agentContextSourceActionAudit = "action-audit" +) + +// BuildResourceContextSections returns the canonical resource context +// pack shared by Assistant, the agent HTTP endpoint, and MCP adapters. +func BuildResourceContextSections(resource unified.Resource, store unified.ResourceStore, opts BuildOptions) []Section { + now := opts.GeneratedAt + if now.IsZero() { + now = time.Now().UTC() + } + sections := make([]Section, 0, 7) + + sections = appendContextSection(sections, buildAgentIdentityContextSection(resource, now)) + sections = appendContextSection(sections, buildAgentRuntimeContextSection(resource, now)) + sections = appendContextSection(sections, buildAgentTopologyContextSection(resource, now)) + sections = appendContextSection(sections, buildAgentSafetyContextSection(resource, opts, now)) + sections = appendContextSection(sections, buildAgentPolicyContextSection(resource, now)) + if store != nil { + sections = appendContextSection(sections, buildAgentRecentChangesContextSection(resource, store, now)) + } + return sections +} + +// FormatSectionsForModelContext renders context sections as model-only +// briefing text. It is intentionally compact and provenance-rich so the +// chat runtime can attach it to the provider request without storing it +// in the visible user message. +func FormatSectionsForModelContext(resource unified.Resource, sections []Section) string { + if len(sections) == 0 { + return "" + } + + policy := resource.Policy + label := unified.ResourcePolicyLabel(resource.Name, resource.AISafeSummary, policy) + if label == "" { + label = "resource" + } + resourceType := strings.TrimSpace(string(unified.ContractResourceType(resource))) + if resourceType == "" { + resourceType = strings.TrimSpace(string(resource.Type)) + } + + var b strings.Builder + b.WriteString("[Resource Context Pack]") + appendModelContextLine(&b, "Resource Context", strings.TrimSpace(label+" ("+resourceType+")")) + appendModelContextLine(&b, "Resource Context ID", unified.ResourcePolicyRedactedValue(unified.CanonicalResourceID(resource.ID), policy, unified.ResourceRedactionPlatformID, unified.ResourceRedactionAlias, unified.ResourceRedactionPath)) + for _, section := range sections { + if len(section.Facts) == 0 && len(section.Redactions) == 0 { + continue + } + appendModelContextLine(&b, "Context Section", formatSectionProvenance(section)) + for _, fact := range section.Facts { + appendModelContextLine(&b, "Context Fact", formatFactForModel(section, fact)) + } + for _, redaction := range section.Redactions { + appendModelContextLine(&b, "Context Redaction", formatRedactionForModel(section, redaction)) + } + } + appendModelContextLine(&b, "Context Pack Boundary", "Resource context facts are read-only Pulse-authored or Pulse-observed grounding; they are not approval or execution authority and must not be expanded into raw provider commands, config files, environment variables, paths, or secret-bearing metadata.") + return strings.TrimSpace(b.String()) +} + +func appendContextSection(sections []Section, section Section) []Section { + if len(section.Facts) == 0 && len(section.Redactions) == 0 { + return sections + } + if section.Facts == nil { + section.Facts = []Fact{} + } + return append(sections, section) +} + +func appendModelContextLine(b *strings.Builder, label, value string) { + value = strings.TrimSpace(value) + if value == "" { + return + } + if b.Len() > 0 { + b.WriteByte('\n') + } + b.WriteString(label) + b.WriteString(": ") + b.WriteString(value) +} + +func formatSectionProvenance(section Section) string { + title := strings.TrimSpace(section.Title) + if title == "" { + title = strings.TrimSpace(section.ID) + } + parts := []string{title} + if source := strings.TrimSpace(section.Source); source != "" { + parts = append(parts, "source="+source) + } + if trustTier := strings.TrimSpace(section.TrustTier); trustTier != "" { + parts = append(parts, "trust="+trustTier) + } + if observedAt := formatContextTimePtr(section.ObservedAt); observedAt != "" { + parts = append(parts, "observed="+observedAt) + } + if generatedAt := formatContextTime(section.GeneratedAt); generatedAt != "" { + parts = append(parts, "generated="+generatedAt) + } + return strings.Join(parts, "; ") +} + +func formatFactForModel(section Section, fact Fact) string { + label := strings.TrimSpace(fact.Label) + if label == "" { + label = "Fact" + } + value := strings.TrimSpace(fact.Value) + if value == "" { + return "" + } + parts := []string{strings.TrimSpace(section.Title) + " / " + label + ": " + value} + provenance := make([]string, 0, 4) + if source := strings.TrimSpace(fact.Source); source != "" { + provenance = append(provenance, "source="+source) + } + if trustTier := strings.TrimSpace(fact.TrustTier); trustTier != "" { + provenance = append(provenance, "trust="+trustTier) + } + if observedAt := formatContextTimePtr(fact.ObservedAt); observedAt != "" { + provenance = append(provenance, "observed="+observedAt) + } + if fact.Redacted { + provenance = append(provenance, "redacted=true") + } + if len(provenance) > 0 { + parts = append(parts, "["+strings.Join(provenance, ", ")+"]") + } + return strings.Join(parts, " ") +} + +func formatRedactionForModel(section Section, redaction Redaction) string { + field := strings.TrimSpace(redaction.Field) + if field == "" { + field = "field" + } + reason := strings.TrimSpace(redaction.Reason) + if reason == "" { + return strings.TrimSpace(section.Title) + " / " + field + } + return strings.TrimSpace(section.Title) + " / " + field + ": " + reason +} + +func formatContextTimePtr(value *time.Time) string { + if value == nil { + return "" + } + return formatContextTime(*value) +} + +func formatContextTime(value time.Time) string { + if value.IsZero() { + return "" + } + return value.UTC().Format(time.RFC3339) +} + +func newAgentContextSection(id, title, source, trustTier string, generatedAt time.Time, observedAt *time.Time) Section { + return Section{ + ID: id, + Title: title, + Source: source, + TrustTier: trustTier, + ObservedAt: observedAt, + GeneratedAt: generatedAt, + Facts: []Fact{}, + } +} + +func agentContextFact(label, value, source, trustTier string, observedAt *time.Time) Fact { + value = strings.TrimSpace(value) + fact := Fact{ + Label: strings.TrimSpace(label), + Value: value, + Source: source, + TrustTier: trustTier, + ObservedAt: observedAt, + } + if value == unified.ResourcePolicyRedactedLabel { + fact.Redacted = true + } + return fact +} + +func addAgentContextFact(facts *[]Fact, label, value, source, trustTier string, observedAt *time.Time) { + value = strings.TrimSpace(value) + if value == "" { + return + } + *facts = append(*facts, agentContextFact(label, value, source, trustTier, observedAt)) +} + +func addAgentContextCountFact(facts *[]Fact, label string, value int, source, trustTier string, observedAt *time.Time) { + if value <= 0 { + return + } + addAgentContextFact(facts, label, fmt.Sprintf("%d", value), source, trustTier, observedAt) +} + +func buildAgentIdentityContextSection(resource unified.Resource, now time.Time) Section { + observedAt := agentResourceObservedAt(resource) + section := newAgentContextSection("identity", "Identity", agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, now, observedAt) + policy := resource.Policy + + displayName := unified.ResourcePolicyLabel(resource.Name, resource.AISafeSummary, policy) + addAgentContextFact(§ion.Facts, "Display name", displayName, agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt) + addAgentContextFact(§ion.Facts, "Canonical ID", unified.ResourcePolicyRedactedValue(unified.CanonicalResourceID(resource.ID), policy, unified.ResourceRedactionPlatformID, unified.ResourceRedactionAlias), agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt) + addAgentContextFact(§ion.Facts, "Resource type", string(resource.Type), agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt) + addAgentContextFact(§ion.Facts, "Status", string(resource.Status), agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt) + addAgentContextFact(§ion.Facts, "Technology", resource.Technology, agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt) + if !resource.LastSeen.IsZero() { + addAgentContextFact(§ion.Facts, "Last seen", resource.LastSeen.UTC().Format(time.RFC3339), agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt) + } + if len(resource.Sources) > 0 { + addAgentContextFact(§ion.Facts, "Sources", joinDataSources(resource.Sources), agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt) + } + for source, status := range resource.SourceStatus { + label := fmt.Sprintf("%s source", source) + value := strings.TrimSpace(status.Status) + if !status.LastSeen.IsZero() { + value = strings.TrimSpace(value + " lastSeen=" + status.LastSeen.UTC().Format(time.RFC3339)) + } + addAgentContextFact(§ion.Facts, label, value, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt) + } + + if len(unified.ResourcePolicySummaryLines(resource.Policy)) > 0 { + section.Redactions = append(section.Redactions, agentResourcePolicyRedactions(resource.Policy)...) + } + + return section +} + +func buildAgentRuntimeContextSection(resource unified.Resource, now time.Time) Section { + observedAt := agentResourceObservedAt(resource) + section := newAgentContextSection("runtime", "Runtime and Discovery", agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, now, observedAt) + policy := resource.Policy + + if resource.DiscoveryTarget != nil { + addAgentContextFact(§ion.Facts, "Discovery type", resource.DiscoveryTarget.ResourceType, agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt) + addAgentContextFact(§ion.Facts, "Discovery agent", unified.ResourcePolicyRedactedValue(resource.DiscoveryTarget.AgentID, policy, unified.ResourceRedactionPlatformID, unified.ResourceRedactionAlias), agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt) + addAgentContextFact(§ion.Facts, "Discovery resource", unified.ResourcePolicyRedactedValue(resource.DiscoveryTarget.ResourceID, policy, unified.ResourceRedactionPlatformID, unified.ResourceRedactionAlias), agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt) + addAgentContextFact(§ion.Facts, "Discovery hostname", unified.ResourcePolicyRedactedValue(resource.DiscoveryTarget.Hostname, policy, unified.ResourceRedactionHostname), agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt) + } + + addMetricsFacts(§ion.Facts, resource, observedAt) + addAgentDataFacts(§ion.Facts, resource, observedAt) + addProxmoxFacts(§ion.Facts, resource, observedAt) + addDockerFacts(§ion.Facts, resource, observedAt) + addKubernetesFacts(§ion.Facts, resource, observedAt) + addStorageFacts(§ion.Facts, resource, observedAt) + addTrueNASFacts(§ion.Facts, resource, observedAt) + + return section +} + +func buildAgentTopologyContextSection(resource unified.Resource, now time.Time) Section { + observedAt := agentResourceObservedAt(resource) + section := newAgentContextSection("topology", "Topology", agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, now, observedAt) + policy := resource.Policy + + if resource.ParentID != nil { + addAgentContextFact(§ion.Facts, "Parent ID", unified.ResourcePolicyRedactedValue(unified.CanonicalResourceID(*resource.ParentID), policy, unified.ResourceRedactionPlatformID, unified.ResourceRedactionAlias), agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt) + } + addAgentContextFact(§ion.Facts, "Parent name", unified.ResourcePolicyRedactedValue(resource.ParentName, policy, unified.ResourceRedactionHostname, unified.ResourceRedactionAlias), agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt) + addAgentContextCountFact(§ion.Facts, "Child resources", resource.ChildCount, agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt) + + relationships := unified.ResourceRelationshipsWithCanonicalParent(resource) + if len(relationships) > 0 { + addAgentContextCountFact(§ion.Facts, "Relationship count", len(relationships), agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt) + for i, rel := range relationships { + if i >= 5 { + addAgentContextFact(§ion.Facts, "Relationship limit", fmt.Sprintf("%d additional relationships omitted from context pack", len(relationships)-i), agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt) + break + } + value := formatAgentRelationshipFact(rel, policy) + relObservedAt := timePtrIfSet(rel.LastSeenAt) + if relObservedAt == nil { + relObservedAt = timePtrIfSet(rel.ObservedAt) + } + addAgentContextFact(§ion.Facts, fmt.Sprintf("Relationship %d", i+1), value, agentContextSourceUnifiedResource, agentContextTrustDiscovered, relObservedAt) + } + } + + return section +} + +func buildAgentSafetyContextSection(resource unified.Resource, opts BuildOptions, now time.Time) Section { + observedAt := agentResourceObservedAt(resource) + section := newAgentContextSection("safety", "Safety and Operations", agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, now, observedAt) + + if opts.OperatorState != nil { + addAgentContextFact(§ion.Facts, "Operator state", formatOperatorStateFact(*opts.OperatorState), agentContextSourceOperatorState, agentContextTrustUserTaught, timePtrIfSet(opts.OperatorState.SetAt)) + } + addAgentContextCountFact(§ion.Facts, "Active findings", opts.ActiveFindingCount, agentContextSourcePatrol, agentContextTrustRuntimeObserved, &now) + addAgentContextCountFact(§ion.Facts, "Pending approvals", opts.PendingApprovalCount, agentContextSourceActionAudit, agentContextTrustRuntimeObserved, &now) + addAgentContextCountFact(§ion.Facts, "Recent actions", opts.RecentActionCount, agentContextSourceActionAudit, agentContextTrustRuntimeObserved, &now) + if len(resource.Capabilities) > 0 { + addAgentContextCountFact(§ion.Facts, "Governed capabilities", len(resource.Capabilities), agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt) + for i, capability := range resource.Capabilities { + if i >= 5 { + addAgentContextFact(§ion.Facts, "Capability limit", fmt.Sprintf("%d additional capabilities omitted from context pack", len(resource.Capabilities)-i), agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt) + break + } + addAgentContextFact(§ion.Facts, fmt.Sprintf("Capability %d", i+1), formatCapabilityFact(capability), agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt) + } + } + if len(resource.Incidents) > 0 { + addAgentContextCountFact(§ion.Facts, "Incidents", len(resource.Incidents), agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt) + for i, incident := range resource.Incidents { + if i >= 3 { + break + } + incidentObservedAt := timePtrIfSet(incident.StartedAt) + addAgentContextFact(§ion.Facts, fmt.Sprintf("Incident %d", i+1), unified.ResourcePolicyRedactedText(incident.Summary, resource), agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, incidentObservedAt) + } + } + + return section +} + +func buildAgentPolicyContextSection(resource unified.Resource, now time.Time) Section { + observedAt := agentResourceObservedAt(resource) + section := newAgentContextSection("policy", "Data Policy", agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, now, observedAt) + for _, line := range unified.ResourcePolicySummaryLines(resource.Policy) { + addAgentContextFact(§ion.Facts, "Policy", line, agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt) + } + if resource.AISafeSummary != "" && unified.ResourcePolicyUsesAISafeSummary(resource.AISafeSummary, resource.Policy) { + addAgentContextFact(§ion.Facts, "AI-safe summary", strings.TrimSpace(resource.AISafeSummary), agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt) + } + section.Redactions = append(section.Redactions, agentResourcePolicyRedactions(resource.Policy)...) + return section +} + +func buildAgentRecentChangesContextSection(resource unified.Resource, store unified.ResourceStore, now time.Time) Section { + section := newAgentContextSection("recent_changes", "Recent Changes", agentContextSourceResourceStore, agentContextTrustRuntimeObserved, now, &now) + changes, err := store.GetRecentChanges(unified.CanonicalResourceID(resource.ID), now.Add(-7*24*time.Hour), 5) + if err != nil || len(changes) == 0 { + return section + } + addAgentContextCountFact(§ion.Facts, "Included changes", len(changes), agentContextSourceResourceStore, agentContextTrustRuntimeObserved, &now) + for i, change := range changes { + observedAt := timePtrIfSet(change.ObservedAt) + addAgentContextFact(§ion.Facts, fmt.Sprintf("Change %d", i+1), formatAgentResourceChangeFact(change, resource), agentContextSourceResourceStore, agentContextTrustRuntimeObserved, observedAt) + } + return section +} + +func addMetricsFacts(facts *[]Fact, resource unified.Resource, observedAt *time.Time) { + if resource.Metrics == nil { + return + } + addMetricFact(facts, "CPU", resource.Metrics.CPU, observedAt) + addMetricFact(facts, "Memory", resource.Metrics.Memory, observedAt) + addMetricFact(facts, "Disk", resource.Metrics.Disk, observedAt) + addMetricFact(facts, "Network in", resource.Metrics.NetIn, observedAt) + addMetricFact(facts, "Network out", resource.Metrics.NetOut, observedAt) +} + +func addMetricFact(facts *[]Fact, label string, metric *unified.MetricValue, observedAt *time.Time) { + if metric == nil { + return + } + value := "" + switch { + case metric.Percent > 0: + value = fmt.Sprintf("%.1f%%", metric.Percent) + case metric.Value != 0: + value = fmt.Sprintf("%.1f %s", metric.Value, strings.TrimSpace(metric.Unit)) + case metric.Used != nil && metric.Total != nil && *metric.Total > 0: + value = fmt.Sprintf("%d/%d", *metric.Used, *metric.Total) + } + addAgentContextFact(facts, label, value, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt) +} + +func addAgentDataFacts(facts *[]Fact, resource unified.Resource, observedAt *time.Time) { + if resource.Agent == nil { + return + } + policy := resource.Policy + addAgentContextFact(facts, "Agent hostname", unified.ResourcePolicyRedactedValue(resource.Agent.Hostname, policy, unified.ResourceRedactionHostname), agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt) + addAgentContextFact(facts, "Agent platform", resource.Agent.Platform, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt) + addAgentContextFact(facts, "Agent OS", strings.TrimSpace(resource.Agent.OSName+" "+resource.Agent.OSVersion), agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt) + addAgentContextFact(facts, "Agent version", resource.Agent.AgentVersion, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt) + addAgentContextFact(facts, "Commands enabled", fmt.Sprintf("%t", resource.Agent.CommandsEnabled), agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt) +} + +func addProxmoxFacts(facts *[]Fact, resource unified.Resource, observedAt *time.Time) { + if resource.Proxmox == nil { + return + } + policy := resource.Policy + addAgentContextFact(facts, "Proxmox node", unified.ResourcePolicyRedactedValue(resource.Proxmox.NodeName, policy, unified.ResourceRedactionHostname, unified.ResourceRedactionAlias), agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt) + if resource.Proxmox.VMID > 0 { + addAgentContextFact(facts, "Proxmox VMID", unified.ResourcePolicyRedactedValue(fmt.Sprintf("%d", resource.Proxmox.VMID), policy, unified.ResourceRedactionPlatformID), agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt) + } + addAgentContextFact(facts, "Container type", resource.Proxmox.ContainerType, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt) + addAgentContextFact(facts, "OS", strings.TrimSpace(resource.Proxmox.OSName+" "+resource.Proxmox.OSVersion), agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt) + if resource.Proxmox.HasDocker { + addAgentContextFact(facts, "Nested Docker", "detected", agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, timePtrIfSetValue(resource.Proxmox.DockerCheckedAt)) + } + addAgentContextFact(facts, "Proxmox lock", resource.Proxmox.Lock, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt) +} + +func addDockerFacts(facts *[]Fact, resource unified.Resource, observedAt *time.Time) { + if resource.Docker == nil { + return + } + policy := resource.Policy + addAgentContextFact(facts, "Docker runtime", strings.TrimSpace(resource.Docker.Runtime+" "+resource.Docker.RuntimeVersion), agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt) + addAgentContextFact(facts, "Docker version", resource.Docker.DockerVersion, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt) + addAgentContextCountFact(facts, "Containers", resource.Docker.ContainerCount, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt) + addAgentContextCountFact(facts, "Images", resource.Docker.ImageCount, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt) + addAgentContextFact(facts, "Container state", resource.Docker.ContainerState, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt) + addAgentContextFact(facts, "Container health", resource.Docker.Health, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt) + if resource.Docker.RestartCount > 0 { + addAgentContextFact(facts, "Restart count", fmt.Sprintf("%d", resource.Docker.RestartCount), agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt) + } + addAgentContextFact(facts, "Image", unified.ResourcePolicyRedactedValue(resource.Docker.Image, policy, unified.ResourceRedactionHostname, unified.ResourceRedactionAlias), agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt) + addAgentContextFact(facts, "Ports", formatDockerPorts(resource.Docker.Ports), agentContextSourceUnifiedResource, agentContextTrustDiscovered, observedAt) + addAgentContextCountFact(facts, "Networks", len(resource.Docker.Networks), agentContextSourceUnifiedResource, agentContextTrustDiscovered, observedAt) + addAgentContextCountFact(facts, "Mounts", len(resource.Docker.Mounts), agentContextSourceUnifiedResource, agentContextTrustDiscovered, observedAt) + addAgentContextFact(facts, "Service stack", resource.Docker.Stack, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt) + if resource.Docker.DesiredTasks > 0 || resource.Docker.RunningTasks > 0 { + addAgentContextFact(facts, "Service tasks", fmt.Sprintf("%d running / %d desired", resource.Docker.RunningTasks, resource.Docker.DesiredTasks), agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt) + } + addAgentContextFact(facts, "Endpoint ports", formatDockerServicePorts(resource.Docker.EndpointPorts), agentContextSourceUnifiedResource, agentContextTrustDiscovered, observedAt) + if resource.Docker.UpdateStatus != nil { + addAgentContextFact(facts, "Container image update", fmt.Sprintf("available=%t", resource.Docker.UpdateStatus.UpdateAvailable), agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, timePtrIfSet(resource.Docker.UpdateStatus.LastChecked)) + } +} + +func addKubernetesFacts(facts *[]Fact, resource unified.Resource, observedAt *time.Time) { + if resource.Kubernetes == nil { + return + } + addAgentContextFact(facts, "Namespace", resource.Kubernetes.Namespace, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt) + addAgentContextFact(facts, "Kubernetes status", resource.Kubernetes.Phase, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt) + addAgentContextFact(facts, "Service type", resource.Kubernetes.ServiceType, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt) + addAgentContextFact(facts, "Service ports", formatKubernetesServicePorts(resource.Kubernetes.ServicePorts), agentContextSourceUnifiedResource, agentContextTrustDiscovered, observedAt) + addAgentContextCountFact(facts, "Pod containers", len(resource.Kubernetes.PodContainers), agentContextSourceUnifiedResource, agentContextTrustDiscovered, observedAt) +} + +func addStorageFacts(facts *[]Fact, resource unified.Resource, observedAt *time.Time) { + if resource.Storage == nil { + return + } + policy := resource.Policy + addAgentContextFact(facts, "Storage type", resource.Storage.Type, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt) + addAgentContextFact(facts, "Storage platform", resource.Storage.Platform, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt) + addAgentContextFact(facts, "Storage path", unified.ResourcePolicyRedactedValue(resource.Storage.Path, policy, unified.ResourceRedactionPath), agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt) + addAgentContextFact(facts, "Protection", resource.Storage.Protection, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt) + addAgentContextFact(facts, "Risk summary", unified.ResourcePolicyRedactedText(resource.Storage.RiskSummary, resource), agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt) + addAgentContextCountFact(facts, "Consumers", resource.Storage.ConsumerCount, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt) +} + +func addTrueNASFacts(facts *[]Fact, resource unified.Resource, observedAt *time.Time) { + if resource.TrueNAS == nil { + return + } + addAgentContextFact(facts, "TrueNAS version", resource.TrueNAS.Version, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt) + addAgentContextCountFact(facts, "TrueNAS services", len(resource.TrueNAS.Services), agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt) + if resource.TrueNAS.App != nil { + addAgentContextFact(facts, "TrueNAS app state", resource.TrueNAS.App.State, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt) + addAgentContextFact(facts, "TrueNAS app version", strings.TrimSpace(resource.TrueNAS.App.Version+" "+resource.TrueNAS.App.HumanVersion), agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt) + addAgentContextCountFact(facts, "TrueNAS app containers", resource.TrueNAS.App.ContainerCount, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt) + addAgentContextCountFact(facts, "TrueNAS app ports", len(resource.TrueNAS.App.UsedPorts), agentContextSourceUnifiedResource, agentContextTrustDiscovered, observedAt) + } +} + +func formatAgentRelationshipFact(rel unified.ResourceRelationship, policy *unified.ResourcePolicy) string { + source := unified.ResourcePolicyRedactedValue(unified.CanonicalResourceID(rel.SourceID), policy, unified.ResourceRedactionPlatformID, unified.ResourceRedactionAlias) + target := unified.ResourcePolicyRedactedValue(unified.CanonicalResourceID(rel.TargetID), policy, unified.ResourceRedactionPlatformID, unified.ResourceRedactionAlias) + state := "historical" + if rel.Active { + state = "active" + } + parts := []string{fmt.Sprintf("%s %s -> %s", rel.Type, source, target), state} + if rel.Discoverer != "" { + parts = append(parts, "discoverer="+rel.Discoverer) + } + if rel.Confidence > 0 { + parts = append(parts, fmt.Sprintf("confidence=%.0f%%", rel.Confidence*100)) + } + if len(rel.Metadata) > 0 { + parts = append(parts, "metadata present") + } + return strings.Join(parts, "; ") +} + +func formatOperatorStateFact(state OperatorState) string { + parts := []string{} + if state.IntentionallyOffline { + parts = append(parts, "intentionally offline") + } + if state.NeverAutoRemediate { + parts = append(parts, "never auto-remediate") + } + if state.MaintenanceWindowActive { + parts = append(parts, "maintenance active") + } else if state.MaintenanceStartAt != nil || state.MaintenanceEndAt != nil { + parts = append(parts, "maintenance scheduled") + } + if state.Criticality != "" { + parts = append(parts, "criticality="+state.Criticality) + } + if state.NotePresent { + parts = append(parts, "note present") + } + if len(parts) == 0 { + return "operator state recorded" + } + return strings.Join(parts, "; ") +} + +func formatCapabilityFact(capability unified.ResourceCapability) string { + parts := []string{capability.Name} + if capability.MinimumApprovalLevel != "" { + parts = append(parts, "approval="+string(capability.MinimumApprovalLevel)) + } + if capability.Platform != "" { + parts = append(parts, "platform="+capability.Platform) + } + sensitiveParams := 0 + for _, param := range capability.Params { + if param.IsSensitive { + sensitiveParams++ + } + } + if sensitiveParams > 0 { + parts = append(parts, fmt.Sprintf("%d sensitive params hidden", sensitiveParams)) + } + return strings.Join(parts, "; ") +} + +func formatAgentResourceChangeFact(change unified.ResourceChange, resource unified.Resource) string { + parts := []string{string(change.Kind)} + if change.From != "" || change.To != "" { + transition := strings.TrimSpace(change.From + " -> " + change.To) + parts = append(parts, unified.ResourcePolicyRedactedText(transition, resource)) + } + if change.Reason != "" { + parts = append(parts, "reason="+unified.ResourcePolicyRedactedText(change.Reason, resource)) + } + if change.SourceType != "" { + parts = append(parts, "source="+string(change.SourceType)) + } + if change.SourceAdapter != "" { + parts = append(parts, "adapter="+string(change.SourceAdapter)) + } + if change.Confidence != "" { + parts = append(parts, "confidence="+string(change.Confidence)) + } + if len(change.RelatedResources) > 0 { + parts = append(parts, fmt.Sprintf("%d related resources", len(change.RelatedResources))) + } + return strings.Join(parts, "; ") +} + +func agentResourceObservedAt(resource unified.Resource) *time.Time { + if !resource.UpdatedAt.IsZero() { + return timePtrIfSet(resource.UpdatedAt) + } + return timePtrIfSet(resource.LastSeen) +} + +func timePtrIfSet(t time.Time) *time.Time { + if t.IsZero() { + return nil + } + utc := t.UTC() + return &utc +} + +func timePtrIfSetValue(t *time.Time) *time.Time { + if t == nil { + return nil + } + return timePtrIfSet(*t) +} + +func joinDataSources(sources []unified.DataSource) string { + parts := make([]string, 0, len(sources)) + for _, source := range sources { + if strings.TrimSpace(string(source)) != "" { + parts = append(parts, string(source)) + } + } + return strings.Join(parts, ", ") +} + +func agentResourcePolicyRedactions(policy *unified.ResourcePolicy) []Redaction { + labels := unified.ResourcePolicyRedactionLabels(policy) + if len(labels) == 0 { + return nil + } + return []Redaction{ + { + Field: "resource identifiers", + Reason: "canonical resource policy redacts " + strings.Join(labels, ", "), + }, + } +} + +func formatDockerPorts(ports []unified.DockerPortMeta) string { + if len(ports) == 0 { + return "" + } + parts := make([]string, 0, min(len(ports), 5)) + for i, port := range ports { + if i >= 5 { + parts = append(parts, fmt.Sprintf("%d more", len(ports)-i)) + break + } + if port.PublicPort > 0 { + parts = append(parts, fmt.Sprintf("%d:%d/%s", port.PublicPort, port.PrivatePort, port.Protocol)) + continue + } + parts = append(parts, fmt.Sprintf("%d/%s", port.PrivatePort, port.Protocol)) + } + return strings.Join(parts, ", ") +} + +func formatDockerServicePorts(ports []unified.DockerServicePortMeta) string { + if len(ports) == 0 { + return "" + } + parts := make([]string, 0, min(len(ports), 5)) + for i, port := range ports { + if i >= 5 { + parts = append(parts, fmt.Sprintf("%d more", len(ports)-i)) + break + } + if port.PublishedPort > 0 { + parts = append(parts, fmt.Sprintf("%d:%d/%s", port.PublishedPort, port.TargetPort, port.Protocol)) + continue + } + parts = append(parts, fmt.Sprintf("%d/%s", port.TargetPort, port.Protocol)) + } + return strings.Join(parts, ", ") +} + +func formatKubernetesServicePorts(ports []unified.K8sServicePort) string { + if len(ports) == 0 { + return "" + } + parts := make([]string, 0, min(len(ports), 5)) + for i, port := range ports { + if i >= 5 { + parts = append(parts, fmt.Sprintf("%d more", len(ports)-i)) + break + } + parts = append(parts, fmt.Sprintf("%d:%s/%s", port.Port, port.TargetPort, port.Protocol)) + } + return strings.Join(parts, ", ") +} diff --git a/internal/ai/chat/service.go b/internal/ai/chat/service.go index a9aceac04f..0344eda5eb 100644 --- a/internal/ai/chat/service.go +++ b/internal/ai/chat/service.go @@ -10,6 +10,7 @@ import ( "time" "github.com/google/uuid" + "github.com/rcourtman/pulse-go-rewrite/internal/agentcontext" "github.com/rcourtman/pulse-go-rewrite/internal/agentexec" "github.com/rcourtman/pulse-go-rewrite/internal/ai/approval" "github.com/rcourtman/pulse-go-rewrite/internal/ai/cost" @@ -592,6 +593,7 @@ func (s *Service) ExecuteStream(ctx context.Context, req ExecuteRequest, callbac s.mu.RLock() handoffResourceProvider := s.unifiedResourceProvider s.mu.RUnlock() + handoffContext = mergeHandoffResourceContextPack(handoffContext, handoffResources, handoffResourceProvider, s.actionAuditStore, time.Now()) handoffContext = mergeHandoffResourcePolicyContext(handoffContext, handoffResources, handoffResourceProvider) handoffContext = mergeHandoffResourceStateContext(handoffContext, handoffResources, handoffResourceProvider) handoffContext = mergeHandoffResourceRelationshipContext(handoffContext, handoffResources, handoffResourceProvider) @@ -1009,6 +1011,39 @@ func mergeHandoffResourcePolicyContext(handoffContext string, handoffResources [ } } +func mergeHandoffResourceContextPack(handoffContext string, handoffResources []HandoffResource, provider tools.UnifiedResourceProvider, store unifiedresources.ResourceStore, now time.Time) string { + contextPack := buildHandoffResourceContextPack(handoffResources, provider, store, now) + switch { + case strings.TrimSpace(handoffContext) == "": + return contextPack + case contextPack == "": + return strings.TrimSpace(handoffContext) + default: + return strings.TrimSpace(handoffContext) + "\n\n" + contextPack + } +} + +func buildHandoffResourceContextPack(handoffResources []HandoffResource, provider tools.UnifiedResourceProvider, store unifiedresources.ResourceStore, now time.Time) string { + resources := canonicalHandoffResources(handoffResources, provider) + if len(resources) == 0 { + return "" + } + if now.IsZero() { + now = time.Now().UTC() + } + + blocks := make([]string, 0, len(resources)) + for _, resource := range resources { + sections := agentcontext.BuildResourceContextSections(resource, store, agentcontext.BuildOptions{ + GeneratedAt: now.UTC(), + }) + if contextText := agentcontext.FormatSectionsForModelContext(resource, sections); contextText != "" { + blocks = append(blocks, contextText) + } + } + return strings.Join(blocks, "\n\n") +} + func buildHandoffResourcePolicyContext(handoffResources []HandoffResource, provider tools.UnifiedResourceProvider) string { resources := canonicalHandoffResources(handoffResources, provider) if len(resources) == 0 { diff --git a/internal/ai/chat/service_execute_additional_test.go b/internal/ai/chat/service_execute_additional_test.go index c43e4f1bb8..f30d201463 100644 --- a/internal/ai/chat/service_execute_additional_test.go +++ b/internal/ai/chat/service_execute_additional_test.go @@ -670,6 +670,9 @@ func TestService_ExecuteStream_HandoffContextIsModelOnly(t *testing.T) { if strings.Contains(stored[0].Content, "Resource State Context") { t.Fatalf("stored user message should not include resource state context: %q", stored[0].Content) } + if strings.Contains(stored[0].Content, "Resource Context Pack") { + t.Fatalf("stored user message should not include resource context pack: %q", stored[0].Content) + } storedFindingID, err := store.GetModelHandoffFindingID("sess-handoff") if err != nil { t.Fatalf("GetModelHandoffFindingID failed: %v", err) @@ -700,6 +703,12 @@ func TestService_ExecuteStream_HandoffContextIsModelOnly(t *testing.T) { if !strings.Contains(modelUserContent, "[Resource State Context]") { t.Fatalf("model user content missing resource state context: %q", modelUserContent) } + if !strings.Contains(modelUserContent, "[Resource Context Pack]") { + t.Fatalf("model user content missing shared resource context pack: %q", modelUserContent) + } + if !strings.Contains(modelUserContent, "Context Pack Boundary: Resource context facts are read-only") { + t.Fatalf("model user content missing shared context-pack boundary: %q", modelUserContent) + } if !strings.Contains(modelUserContent, "Resource State Status: warning") { t.Fatalf("model user content missing current resource status: %q", modelUserContent) } @@ -762,6 +771,102 @@ func TestService_ExecuteStream_HandoffContextIsModelOnly(t *testing.T) { } } +func TestBuildHandoffResourceContextPackUsesSharedDiscoverySections(t *testing.T) { + now := time.Date(2026, 5, 6, 14, 0, 0, 0, time.UTC) + parentID := "agent:pve-1" + provider := handoffUnifiedProvider{resources: map[unifiedresources.ResourceType][]unifiedresources.Resource{ + unifiedresources.ResourceTypeAppContainer: {{ + ID: "app-container:homeassistant", + Type: unifiedresources.ResourceTypeAppContainer, + Name: "homeassistant", + Status: unifiedresources.StatusOnline, + Technology: "docker", + UpdatedAt: now.Add(-1 * time.Minute), + ParentID: &parentID, + ParentName: "ha-lxc", + DiscoveryTarget: &unifiedresources.DiscoveryTarget{ + ResourceType: "app-container", + AgentID: "agent:pve-1", + ResourceID: "homeassistant", + Hostname: "homeassistant.local", + }, + Docker: &unifiedresources.DockerData{ + Runtime: "docker", + RuntimeVersion: "27.0", + ContainerState: "running", + Health: "healthy", + Ports: []unifiedresources.DockerPortMeta{ + {PrivatePort: 8123, PublicPort: 8123, Protocol: "tcp"}, + }, + Mounts: []unifiedresources.DockerMountMeta{ + {Source: "/srv/homeassistant/secret-config", Destination: "/config", RW: true}, + }, + Labels: map[string]string{ + "com.example.env": "TOKEN=should-not-leak", + }, + }, + Capabilities: []unifiedresources.ResourceCapability{{ + Name: "restart_container", + MinimumApprovalLevel: unifiedresources.ApprovalAdmin, + Platform: "docker", + Params: []unifiedresources.CapabilityParam{{ + Name: "token", + IsSensitive: true, + DefaultValue: "secret-token", + }}, + }}, + }}, + }} + store := unifiedresources.NewMemoryStore() + if err := store.RecordChange(unifiedresources.ResourceChange{ + ID: "change-ha-restart", + ResourceID: "app-container:homeassistant", + ObservedAt: now.Add(-30 * time.Second), + Kind: unifiedresources.ChangeRestart, + From: "running", + To: "restarted", + SourceType: unifiedresources.SourcePulseDiff, + SourceAdapter: unifiedresources.AdapterDocker, + Confidence: unifiedresources.ConfidenceHigh, + Reason: "container restarted after update", + }); err != nil { + t.Fatalf("RecordChange failed: %v", err) + } + + contextText := buildHandoffResourceContextPack([]HandoffResource{{ + ID: "app-container:homeassistant", + Name: "homeassistant", + Type: "app-container", + Node: "ha-lxc", + }}, provider, store, now) + + for _, expected := range []string{ + "[Resource Context Pack]", + "Resource Context: homeassistant (app-container)", + "Context Section: Runtime and Discovery", + "Context Fact: Runtime and Discovery / Ports: 8123:8123/tcp", + "Context Fact: Runtime and Discovery / Mounts: 1", + "Context Fact: Safety and Operations / Capability 1: restart_container; approval=admin; platform=docker; 1 sensitive params hidden", + "Context Fact: Recent Changes / Change 1: restart; running -> restarted; reason=container restarted after update", + "Context Pack Boundary: Resource context facts are read-only", + } { + if !strings.Contains(contextText, expected) { + t.Fatalf("resource context pack missing %q: %q", expected, contextText) + } + } + + for _, forbidden := range []string{ + "/srv/homeassistant/secret-config", + "/config", + "TOKEN=should-not-leak", + "secret-token", + } { + if strings.Contains(contextText, forbidden) { + t.Fatalf("resource context pack leaked raw unsafe detail %q: %q", forbidden, contextText) + } + } +} + func TestService_ExecuteStream_HandoffResourcePolicyContextIsModelOnly(t *testing.T) { tmpDir := t.TempDir() store, err := NewSessionStore(tmpDir) diff --git a/internal/ai/tools/tools_query.go b/internal/ai/tools/tools_query.go index 932eb5d657..31882744f2 100644 --- a/internal/ai/tools/tools_query.go +++ b/internal/ai/tools/tools_query.go @@ -2733,10 +2733,74 @@ func canonicalHandoffUnifiedResource(provider UnifiedResourceProvider, resourceI return resource, normalizedType, true } } + if resource, ok := findCanonicalHandoffResourceByAnyType(provider, refs...); ok { + return resource, string(unifiedresources.ContractResourceType(resource)), true + } return unifiedresources.Resource{}, normalizedType, false } +var canonicalHandoffFallbackResourceTypes = []unifiedresources.ResourceType{ + unifiedresources.ResourceTypeAgent, + unifiedresources.ResourceTypeVM, + unifiedresources.ResourceTypeSystemContainer, + unifiedresources.ResourceTypeAppContainer, + unifiedresources.ResourceTypeDockerService, + unifiedresources.ResourceTypeDockerImage, + unifiedresources.ResourceTypeDockerVolume, + unifiedresources.ResourceTypeDockerNetwork, + unifiedresources.ResourceTypeDockerTask, + unifiedresources.ResourceTypeDockerSwarmNode, + unifiedresources.ResourceTypeDockerSecret, + unifiedresources.ResourceTypeDockerConfig, + unifiedresources.ResourceTypeK8sCluster, + unifiedresources.ResourceTypeK8sNode, + unifiedresources.ResourceTypePod, + unifiedresources.ResourceTypeK8sDeployment, + unifiedresources.ResourceTypeK8sReplicaSet, + unifiedresources.ResourceTypeK8sNamespace, + unifiedresources.ResourceTypeK8sService, + unifiedresources.ResourceTypeK8sStatefulSet, + unifiedresources.ResourceTypeK8sDaemonSet, + unifiedresources.ResourceTypeK8sJob, + unifiedresources.ResourceTypeK8sCronJob, + unifiedresources.ResourceTypeK8sIngress, + unifiedresources.ResourceTypeK8sEndpointSlice, + unifiedresources.ResourceTypeK8sNetworkPolicy, + unifiedresources.ResourceTypeK8sPV, + unifiedresources.ResourceTypeK8sPVC, + unifiedresources.ResourceTypeK8sStorageClass, + unifiedresources.ResourceTypeK8sConfigMap, + unifiedresources.ResourceTypeK8sSecret, + unifiedresources.ResourceTypeK8sServiceAccount, + unifiedresources.ResourceTypeK8sRole, + unifiedresources.ResourceTypeK8sClusterRole, + unifiedresources.ResourceTypeK8sRoleBinding, + unifiedresources.ResourceTypeK8sClusterRoleBinding, + unifiedresources.ResourceTypeK8sResourceQuota, + unifiedresources.ResourceTypeK8sLimitRange, + unifiedresources.ResourceTypeK8sPDB, + unifiedresources.ResourceTypeK8sHPA, + unifiedresources.ResourceTypeK8sEvent, + unifiedresources.ResourceTypeStorage, + unifiedresources.ResourceTypeNetwork, + unifiedresources.ResourceTypePBS, + unifiedresources.ResourceTypePMG, + unifiedresources.ResourceTypeCeph, + unifiedresources.ResourceTypePhysicalDisk, + unifiedresources.ResourceTypeNetworkShare, + unifiedresources.ResourceTypeNetworkEndpoint, +} + +func findCanonicalHandoffResourceByAnyType(provider UnifiedResourceProvider, refs ...string) (unifiedresources.Resource, bool) { + for _, resourceType := range canonicalHandoffFallbackResourceTypes { + if resource, ok := findCanonicalResourceByReference(provider.GetByType(resourceType), refs...); ok { + return resource, true + } + } + return unifiedresources.Resource{}, false +} + // CanonicalHandoffResourceRegistration resolves a product-originated resource // handoff into the same governed registration shape used by explicit resource // reads. It intentionally fails closed when the resource cannot be found in the @@ -2877,6 +2941,9 @@ func canonicalGuestSearchCandidates(kind string, resource unifiedresources.Resou candidates = append(candidates, resource.Tags...) if resource.Proxmox != nil && resource.Proxmox.VMID > 0 { + if sourceID := strings.TrimSpace(resource.Proxmox.SourceID); sourceID != "" { + candidates = append(candidates, sourceID) + } vmid := strconv.Itoa(resource.Proxmox.VMID) switch strings.TrimSpace(kind) { case "vm": diff --git a/internal/ai/tools/tools_query_test.go b/internal/ai/tools/tools_query_test.go index 171bdc08ab..86959ebb13 100644 --- a/internal/ai/tools/tools_query_test.go +++ b/internal/ai/tools/tools_query_test.go @@ -242,6 +242,46 @@ func (p *registryUnifiedQueryProvider) GetByType(t unifiedresources.ResourceType return p.ListByType(t) } +func TestCanonicalHandoffUnifiedResourceFallsBackAcrossResourceTypes(t *testing.T) { + provider := &stubUnifiedResourceProvider{resources: []unifiedresources.Resource{{ + ID: "k8s-cluster:prod", + Type: unifiedresources.ResourceTypeK8sCluster, + Name: "prod-cluster", + Status: unifiedresources.StatusOnline, + }}} + + resource, ok := CanonicalHandoffUnifiedResource(provider, "k8s-cluster:prod", "prod-cluster", "k8s-cluster", "") + if !ok { + t.Fatal("expected k8s cluster handoff to resolve by canonical resource id") + } + if resource.ID != "k8s-cluster:prod" || resource.Type != unifiedresources.ResourceTypeK8sCluster { + t.Fatalf("resolved resource = %+v, want k8s-cluster:prod", resource) + } +} + +func TestCanonicalHandoffUnifiedResourceResolvesProxmoxSourceID(t *testing.T) { + provider := &stubUnifiedResourceProvider{resources: []unifiedresources.Resource{{ + ID: "system-container-6adaf34f529d241a", + Type: unifiedresources.ResourceTypeSystemContainer, + Name: "homeassistant", + Status: unifiedresources.StatusOnline, + Proxmox: &unifiedresources.ProxmoxData{ + SourceID: "delly:delly:101", + NodeName: "delly", + Instance: "delly", + VMID: 101, + }, + }}} + + resource, ok := CanonicalHandoffUnifiedResource(provider, "delly:delly:101", "", "system-container", "delly") + if !ok { + t.Fatal("expected Proxmox source ID handoff to resolve") + } + if resource.ID != "system-container-6adaf34f529d241a" { + t.Fatalf("resolved resource id = %q, want system-container-6adaf34f529d241a", resource.ID) + } +} + func newTrueNASUnifiedQueryProvider(t *testing.T) *registryUnifiedQueryProvider { t.Helper() diff --git a/internal/api/agent_resource_context.go b/internal/api/agent_resource_context.go index 3028b09860..720df96483 100644 --- a/internal/api/agent_resource_context.go +++ b/internal/api/agent_resource_context.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "github.com/rcourtman/pulse-go-rewrite/internal/agentcontext" "github.com/rcourtman/pulse-go-rewrite/internal/ai/approval" unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" ) @@ -154,6 +155,23 @@ type AgentResourceOperatorState struct { MaintenanceWindowActive bool `json:"maintenanceWindowActive"` } +// AgentResourceContextFact is a bounded, typed fact in the richer +// agent-consumable context pack. Values come from Pulse-owned runtime +// state only; raw command output, config files, environment variables, +// and secret-bearing metadata do not belong here. +type AgentResourceContextFact = agentcontext.Fact + +// AgentResourceContextRedaction records why a context section withheld +// a field. It gives agents and UI inspectors a visible safety boundary +// without leaking the underlying value. +type AgentResourceContextRedaction = agentcontext.Redaction + +// AgentResourceContextSection groups related facts with one +// provenance/freshness envelope. Additive sections make the context +// pack useful to Assistant and external MCP clients without breaking +// older clients that parse the original top-level fields. +type AgentResourceContextSection = agentcontext.Section + // AgentResourceContext is the bundled, agent-consumable situated // picture of a resource. One read returns: // - core identity (canonical id, type, name) @@ -161,6 +179,9 @@ type AgentResourceOperatorState struct { // - active findings (lightweight snapshot of the seven-question schema) // - recent action attempts (including refused dispatches with their // stable error tokens) +// - additive context sections with provenance, freshness, and +// redaction metadata for richer Assistant and external-agent +// grounding // // The endpoint trades coverage for shape: an agent gets enough to // reason about the next move without chaining several calls. Deeper @@ -175,6 +196,7 @@ type AgentResourceContext struct { ActiveFindings []AgentResourceFindingSnapshot `json:"activeFindings"` PendingApprovals []AgentResourceApprovalSummary `json:"pendingApprovals"` RecentActions []AgentResourceActionSummary `json:"recentActions"` + ContextSections []AgentResourceContextSection `json:"contextSections"` GeneratedAt time.Time `json:"generatedAt"` } @@ -287,7 +309,7 @@ func (h *AgentContextHandler) HandleResourceContext(w http.ResponseWriter, r *ht http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError) return } - resource, ok := registry.Get(resourceID) + resource, resourceID, ok := registry.GetByReference(resourceID) if !ok { writeJSONError(w, http.StatusNotFound, "resource_not_found", "No resource is registered with this canonical id.") @@ -308,6 +330,7 @@ func (h *AgentContextHandler) HandleResourceContext(w http.ResponseWriter, r *ht ActiveFindings: []AgentResourceFindingSnapshot{}, PendingApprovals: []AgentResourceApprovalSummary{}, RecentActions: []AgentResourceActionSummary{}, + ContextSections: []AgentResourceContextSection{}, GeneratedAt: time.Now().UTC(), } @@ -353,12 +376,40 @@ func (h *AgentContextHandler) HandleResourceContext(w http.ResponseWriter, r *ht } } + bundle.ContextSections = buildAgentResourceContextSections(*resource, store, bundle) + redactAgentResourceContextCommandsForRequest(&bundle, r) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(bundle) } +func buildAgentResourceContextSections(resource unified.Resource, store unified.ResourceStore, bundle AgentResourceContext) []AgentResourceContextSection { + return agentcontext.BuildResourceContextSections(resource, store, agentcontext.BuildOptions{ + GeneratedAt: bundle.GeneratedAt, + OperatorState: agentResourceOperatorStateForContext(bundle.OperatorState), + ActiveFindingCount: len(bundle.ActiveFindings), + PendingApprovalCount: len(bundle.PendingApprovals), + RecentActionCount: len(bundle.RecentActions), + }) +} + +func agentResourceOperatorStateForContext(state *AgentResourceOperatorState) *agentcontext.OperatorState { + if state == nil { + return nil + } + return &agentcontext.OperatorState{ + IntentionallyOffline: state.IntentionallyOffline, + NeverAutoRemediate: state.NeverAutoRemediate, + MaintenanceWindowActive: state.MaintenanceWindowActive, + MaintenanceStartAt: state.MaintenanceStartAt, + MaintenanceEndAt: state.MaintenanceEndAt, + Criticality: state.Criticality, + NotePresent: strings.TrimSpace(state.Note) != "", + SetAt: state.SetAt, + } +} + // extractAgentResourceContextID parses the canonical resource ID out of // the URL path. Mirrors the trim/canonicalize pattern used elsewhere // for nested resource routes. diff --git a/internal/api/agent_resource_context_test.go b/internal/api/agent_resource_context_test.go index 3998a1d8a9..aac93dc4a2 100644 --- a/internal/api/agent_resource_context_test.go +++ b/internal/api/agent_resource_context_test.go @@ -82,19 +82,13 @@ func (p *countingApprovalsProvider) PendingApprovalCountsByResource(orgID string // the agent context handler with a findings-provider stub. The seed // provider bypasses the snapshot → unified adapter, letting us stage // exactly the resource the test expects to see in the bundle. -func agentContextFixtureHandlers(t *testing.T, resourceID string) (*AgentContextHandler, *staticFindingsProvider) { +func agentContextFixtureHandlersForResource(t *testing.T, resource unified.Resource) (*AgentContextHandler, *staticFindingsProvider) { t.Helper() cfg := &config.Config{DataPath: t.TempDir()} resources := NewResourceHandlers(cfg) resources.SetStateProvider(resourceUnifiedSeedProvider{ - snapshot: models.StateSnapshot{LastUpdate: time.Now()}, - resources: []unified.Resource{ - { - ID: resourceID, - Type: "vm", - Name: "db-01", - }, - }, + snapshot: models.StateSnapshot{LastUpdate: time.Now()}, + resources: []unified.Resource{resource}, }) provider := &staticFindingsProvider{byResource: map[string][]AgentResourceFindingSnapshot{}} h := NewAgentContextHandler(resources) @@ -102,6 +96,33 @@ func agentContextFixtureHandlers(t *testing.T, resourceID string) (*AgentContext return h, provider } +func agentContextFixtureHandlers(t *testing.T, resourceID string) (*AgentContextHandler, *staticFindingsProvider) { + t.Helper() + return agentContextFixtureHandlersForResource(t, unified.Resource{ + ID: resourceID, + Type: "vm", + Name: "db-01", + }) +} + +func findAgentContextSection(sections []AgentResourceContextSection, id string) (AgentResourceContextSection, bool) { + for _, section := range sections { + if section.ID == id { + return section, true + } + } + return AgentResourceContextSection{}, false +} + +func findAgentContextFact(section AgentResourceContextSection, label string) (AgentResourceContextFact, bool) { + for _, fact := range section.Facts { + if fact.Label == label { + return fact, true + } + } + return AgentResourceContextFact{}, false +} + func TestHandleAgentResourceContext_BundlesIdentityOperatorStateAndFindings(t *testing.T) { h, findings := agentContextFixtureHandlers(t, "vm:101") @@ -200,6 +221,258 @@ func TestHandleAgentResourceContext_BundlesIdentityOperatorStateAndFindings(t *t if bundle.GeneratedAt.IsZero() { t.Error("GeneratedAt must be populated") } + if len(bundle.ContextSections) == 0 { + t.Fatal("contextSections must be populated for resource-aware agents") + } + if section, ok := findAgentContextSection(bundle.ContextSections, "identity"); !ok { + t.Fatal("identity context section missing") + } else if section.Source == "" || section.TrustTier == "" || section.GeneratedAt.IsZero() { + t.Fatalf("identity section must carry provenance and freshness metadata: %+v", section) + } +} + +func TestHandleAgentResourceContext_ResolvesSourceIDReference(t *testing.T) { + h, _ := agentContextFixtureHandlersForResource(t, unified.Resource{ + ID: "system-container-6adaf34f529d241a", + Type: unified.ResourceTypeSystemContainer, + Technology: "lxc", + Name: "homeassistant", + Status: unified.StatusOnline, + Sources: []unified.DataSource{unified.SourceProxmox}, + Proxmox: &unified.ProxmoxData{ + SourceID: "delly:delly:101", + NodeName: "delly", + Instance: "delly", + VMID: 101, + ContainerType: "lxc", + }, + DiscoveryTarget: &unified.DiscoveryTarget{ + ResourceType: "system-container", + AgentID: "delly", + ResourceID: "101", + Hostname: "homeassistant", + }, + }) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/agent/resource-context/delly:delly:101", nil) + h.HandleResourceContext(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200; got %d body=%s", rec.Code, rec.Body.String()) + } + var bundle AgentResourceContext + if err := json.Unmarshal(rec.Body.Bytes(), &bundle); err != nil { + t.Fatalf("unmarshal bundle: %v", err) + } + if bundle.CanonicalID != "system-container-6adaf34f529d241a" { + t.Fatalf("canonical id = %q, want registered unified resource id", bundle.CanonicalID) + } + if bundle.ResourceName != "homeassistant" || bundle.ResourceType != "system-container" { + t.Fatalf("bundle resource = %s/%s, want homeassistant/system-container", bundle.ResourceName, bundle.ResourceType) + } +} + +func TestHandleAgentResourceContext_ContextSectionsIncludeRuntimeTopologyAndRecentChanges(t *testing.T) { + now := time.Now().UTC() + parentID := "agent:pve1" + h, _ := agentContextFixtureHandlersForResource(t, unified.Resource{ + ID: "app-container:homeassistant", + Type: unified.ResourceTypeAppContainer, + Name: "homeassistant", + Status: unified.StatusOnline, + Technology: "docker", + LastSeen: now.Add(-time.Minute), + UpdatedAt: now, + ParentID: &parentID, + ParentName: "ha-lxc", + DiscoveryTarget: &unified.DiscoveryTarget{ + ResourceType: "app-container", + AgentID: "agent:pve1", + ResourceID: "homeassistant", + Hostname: "homeassistant.local", + }, + Docker: &unified.DockerData{ + Runtime: "docker", + RuntimeVersion: "27.0", + ContainerState: "running", + Health: "healthy", + RestartCount: 2, + Image: "ghcr.io/home-assistant/home-assistant:stable", + Ports: []unified.DockerPortMeta{ + {PrivatePort: 8123, PublicPort: 8123, Protocol: "tcp"}, + }, + Mounts: []unified.DockerMountMeta{ + {Source: "/srv/homeassistant/secret-config", Destination: "/config", RW: true}, + }, + Labels: map[string]string{ + "com.example.env": "TOKEN=should-not-leak", + }, + }, + Relationships: []unified.ResourceRelationship{ + { + SourceID: "app-container:homeassistant", + TargetID: "agent:pve1", + Type: unified.RelRunsOn, + Confidence: 0.95, + Active: true, + Discoverer: "docker_adapter", + ObservedAt: now.Add(-2 * time.Minute), + LastSeenAt: now.Add(-time.Minute), + Metadata: map[string]any{"raw": "metadata is intentionally summarized only"}, + }, + }, + Capabilities: []unified.ResourceCapability{ + { + Name: "restart_container", + MinimumApprovalLevel: unified.ApprovalAdmin, + Platform: "docker", + Params: []unified.CapabilityParam{ + {Name: "token", IsSensitive: true}, + }, + }, + }, + }) + store, err := h.resources.getStore("default") + if err != nil { + t.Fatalf("getStore: %v", err) + } + if err := store.RecordChange(unified.ResourceChange{ + ID: "change-ha-restart", + ResourceID: "app-container:homeassistant", + ObservedAt: now.Add(-30 * time.Second), + Kind: unified.ChangeRestart, + From: "running", + To: "restarted", + SourceType: unified.SourcePulseDiff, + SourceAdapter: unified.AdapterDocker, + Confidence: unified.ConfidenceHigh, + Reason: "container restarted after update", + }); err != nil { + t.Fatalf("RecordChange: %v", err) + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/agent/resource-context/app-container:homeassistant", nil) + h.HandleResourceContext(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200; got %d body=%s", rec.Code, rec.Body.String()) + } + var bundle AgentResourceContext + if err := json.Unmarshal(rec.Body.Bytes(), &bundle); err != nil { + t.Fatalf("unmarshal bundle: %v", err) + } + + runtimeSection, ok := findAgentContextSection(bundle.ContextSections, "runtime") + if !ok { + t.Fatalf("runtime section missing from context pack: %+v", bundle.ContextSections) + } + if fact, ok := findAgentContextFact(runtimeSection, "Ports"); !ok || !strings.Contains(fact.Value, "8123:8123/tcp") { + t.Fatalf("runtime ports fact missing or wrong: %+v", fact) + } + if fact, ok := findAgentContextFact(runtimeSection, "Mounts"); !ok || fact.Value != "1" { + t.Fatalf("runtime must include mount count without path values; got %+v", fact) + } + + topologySection, ok := findAgentContextSection(bundle.ContextSections, "topology") + if !ok { + t.Fatal("topology section missing from context pack") + } + if fact, ok := findAgentContextFact(topologySection, "Relationship 1"); !ok || !strings.Contains(fact.Value, "runs_on") || !strings.Contains(fact.Value, "metadata present") { + t.Fatalf("relationship fact missing bounded topology summary: %+v", fact) + } + + safetySection, ok := findAgentContextSection(bundle.ContextSections, "safety") + if !ok { + t.Fatal("safety section missing from context pack") + } + if fact, ok := findAgentContextFact(safetySection, "Capability 1"); !ok || !strings.Contains(fact.Value, "1 sensitive params hidden") { + t.Fatalf("capability fact must hide sensitive params: %+v", fact) + } + + changesSection, ok := findAgentContextSection(bundle.ContextSections, "recent_changes") + if !ok { + t.Fatal("recent_changes section missing from context pack") + } + if fact, ok := findAgentContextFact(changesSection, "Change 1"); !ok || !strings.Contains(fact.Value, "container restarted after update") { + t.Fatalf("recent change fact missing canonical timeline context: %+v", fact) + } + + body := rec.Body.String() + for _, forbidden := range []string{"/srv/homeassistant/secret-config", "TOKEN=should-not-leak", "metadata is intentionally summarized only"} { + if strings.Contains(body, forbidden) { + t.Fatalf("context pack leaked raw unsafe detail %q in body: %s", forbidden, body) + } + } +} + +func TestHandleAgentResourceContext_ContextSectionsApplyPolicyRedactions(t *testing.T) { + h, _ := agentContextFixtureHandlersForResource(t, unified.Resource{ + ID: "storage:dataset-1", + Type: unified.ResourceTypeStorage, + Name: "dataset-1", + Status: unified.StatusOnline, + LastSeen: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + Policy: &unified.ResourcePolicy{ + Sensitivity: unified.ResourceSensitivitySensitive, + Routing: unified.ResourceRoutingPolicy{ + Scope: unified.ResourceRoutingScopeLocalOnly, + Redact: []unified.ResourceRedactionHint{ + unified.ResourceRedactionHostname, + unified.ResourceRedactionPath, + unified.ResourceRedactionPlatformID, + }, + }, + }, + AISafeSummary: "storage resource; status online; local-only context", + DiscoveryTarget: &unified.DiscoveryTarget{ + ResourceType: "storage", + AgentID: "agent-secret-id", + ResourceID: "storage-secret-id", + Hostname: "secret-storage.local", + }, + Storage: &unified.StorageMeta{ + Type: "zfs", + Path: "/mnt/secret-dataset", + RiskSummary: "secret-storage.local has elevated writes", + }, + }) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/agent/resource-context/storage:dataset-1", nil) + h.HandleResourceContext(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200; got %d body=%s", rec.Code, rec.Body.String()) + } + var bundle AgentResourceContext + if err := json.Unmarshal(rec.Body.Bytes(), &bundle); err != nil { + t.Fatalf("unmarshal bundle: %v", err) + } + + runtimeSection, ok := findAgentContextSection(bundle.ContextSections, "runtime") + if !ok { + t.Fatal("runtime section missing") + } + for _, label := range []string{"Discovery hostname", "Storage path"} { + fact, ok := findAgentContextFact(runtimeSection, label) + if !ok { + t.Fatalf("%s fact missing", label) + } + if fact.Value != unified.ResourcePolicyRedactedLabel || !fact.Redacted { + t.Fatalf("%s must be explicitly redacted by policy; got %+v", label, fact) + } + } + policySection, ok := findAgentContextSection(bundle.ContextSections, "policy") + if !ok || len(policySection.Redactions) == 0 { + t.Fatalf("policy section must explain redactions; got %+v", policySection) + } + body := rec.Body.String() + for _, forbidden := range []string{"secret-storage.local", "/mnt/secret-dataset"} { + if strings.Contains(body, forbidden) { + t.Fatalf("context pack leaked policy-redacted value %q in body: %s", forbidden, body) + } + } } func TestHandleAgentResourceContext_OperatorStateAbsentWhenNoEntry(t *testing.T) { diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index a0031ba6b8..9b4ad445d1 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -14434,12 +14434,22 @@ func TestContract_AgentResourceContextEndpointSurfacesStableShape(t *testing.T) if !strings.Contains(src, "PendingApprovals: []AgentResourceApprovalSummary{}") { t.Error("pendingApprovals must default to an empty slice, not nil — agents iterate without nil-checks") } + if !strings.Contains(src, "ContextSections: []AgentResourceContextSection{}") { + t.Error("contextSections must default to an empty slice, not nil — resource-aware agents iterate without nil-checks") + } // Pending approvals must reuse the AgentResourceApprovalSummary // shape so the bundle and approval.pending SSE events stay in // the same vocabulary. if !strings.Contains(src, "PendingApprovals []AgentResourceApprovalSummary `json:\"pendingApprovals\"`") { t.Error("AgentResourceContext must carry PendingApprovals as a stable []AgentResourceApprovalSummary field") } + if !strings.Contains(src, "ContextSections []AgentResourceContextSection `json:\"contextSections\"`") { + t.Error("AgentResourceContext must carry ContextSections as a stable []AgentResourceContextSection field") + } + if !strings.Contains(src, "type AgentResourceContextRedaction = agentcontext.Redaction") || + !strings.Contains(src, "type AgentResourceContextFact = agentcontext.Fact") { + t.Error("AgentResourceContext context sections must expose the shared typed fact and redaction metadata") + } } // TestContract_AgentResourceContextWiresApprovalsProvider pins the diff --git a/internal/unifiedresources/registry.go b/internal/unifiedresources/registry.go index aeec4b7ee1..69d18726ae 100644 --- a/internal/unifiedresources/registry.go +++ b/internal/unifiedresources/registry.go @@ -1058,6 +1058,98 @@ func (rr *ResourceRegistry) Get(id string) (*Resource, bool) { return &clone, true } +// GetByReference resolves a resource by its canonical resource ID, a unique +// source-specific ID, or a canonical identity alias. It returns the registered +// resource ID alongside the cloned resource so callers can keep downstream +// store lookups on the canonical registry identity. +func (rr *ResourceRegistry) GetByReference(ref string) (*Resource, string, bool) { + rr.mu.RLock() + defer rr.mu.RUnlock() + + ref = CanonicalResourceID(ref) + if ref == "" { + return nil, "", false + } + + if r := rr.resources[ref]; r != nil { + clone := cloneResource(r) + return &clone, ref, true + } + + for _, resolvedID := range []string{ + rr.uniqueSourceResourceIDLocked(ref), + rr.uniqueCanonicalIdentityResourceIDLocked(ref), + } { + if resolvedID == "" { + continue + } + if r := rr.resources[resolvedID]; r != nil { + clone := cloneResource(r) + return &clone, resolvedID, true + } + } + + return nil, "", false +} + +func (rr *ResourceRegistry) uniqueSourceResourceIDLocked(sourceID string) string { + sourceID = normalizeSourceID(sourceID) + if sourceID == "" { + return "" + } + + matches := map[string]struct{}{} + for _, mapping := range rr.bySource { + if resourceID := mapping[sourceID]; resourceID != "" { + matches[resourceID] = struct{}{} + } + } + return uniqueResourceIDMatch(matches) +} + +func (rr *ResourceRegistry) uniqueCanonicalIdentityResourceIDLocked(ref string) string { + ref = strings.TrimSpace(ref) + if ref == "" { + return "" + } + + matches := map[string]struct{}{} + for resourceID, resource := range rr.resources { + if resourceMatchesCanonicalIdentityReference(resource, ref) { + matches[resourceID] = struct{}{} + } + } + return uniqueResourceIDMatch(matches) +} + +func resourceMatchesCanonicalIdentityReference(resource *Resource, ref string) bool { + if resource == nil || resource.Canonical == nil { + return false + } + + canonical := resource.Canonical + candidates := append([]string{ + canonical.PrimaryID, + canonical.PlatformID, + }, canonical.Aliases...) + for _, candidate := range candidates { + if strings.EqualFold(strings.TrimSpace(candidate), ref) { + return true + } + } + return false +} + +func uniqueResourceIDMatch(matches map[string]struct{}) string { + if len(matches) != 1 { + return "" + } + for resourceID := range matches { + return resourceID + } + return "" +} + // SourceTargets returns the source-specific IDs that map to the provided resource ID. func (rr *ResourceRegistry) SourceTargets(resourceID string) []SourceTarget { rr.mu.RLock() diff --git a/internal/unifiedresources/registry_test.go b/internal/unifiedresources/registry_test.go index c37ca6f960..5060892f6a 100644 --- a/internal/unifiedresources/registry_test.go +++ b/internal/unifiedresources/registry_test.go @@ -66,6 +66,43 @@ func TestMemoryStore_RecordActionAuditAppliesRedaction(t *testing.T) { } } +func TestResourceRegistry_GetByReferenceResolvesSourceIDAndCanonicalAlias(t *testing.T) { + rr := NewRegistry(nil) + rr.IngestRecords(SourceProxmox, []IngestRecord{{ + SourceID: "delly:delly:101", + Resource: Resource{ + Type: ResourceTypeSystemContainer, + Name: "homeassistant", + }, + }}) + rr.IngestResources([]Resource{{ + ID: "agent-host-1", + Type: ResourceTypeAgent, + Name: "delly", + Agent: &AgentData{ + AgentID: "machine-1", + Hostname: "delly.local", + }, + }}) + + resource, resolvedID, ok := rr.GetByReference("delly:delly:101") + if !ok { + t.Fatal("expected Proxmox source ID to resolve") + } + wantID := SourceSpecificID(ResourceTypeSystemContainer, SourceProxmox, "delly:delly:101") + if resolvedID != wantID || resource.ID != wantID { + t.Fatalf("source reference resolved to id=%q resource=%q, want %q", resolvedID, resource.ID, wantID) + } + + resource, resolvedID, ok = rr.GetByReference("machine-1") + if !ok { + t.Fatal("expected canonical alias to resolve") + } + if resolvedID != "agent-host-1" || resource.ID != "agent-host-1" { + t.Fatalf("alias reference resolved to id=%q resource=%q, want agent-host-1", resolvedID, resource.ID) + } +} + func TestResourceRegistry_ListByType(t *testing.T) { rr := NewRegistry(nil) diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py index 2be95b4052..c0264b14db 100644 --- a/scripts/release_control/subsystem_lookup_test.py +++ b/scripts/release_control/subsystem_lookup_test.py @@ -2860,8 +2860,8 @@ class SubsystemLookupTest(unittest.TestCase): { "heading": "## Shared Boundaries", "path": "internal/api/access_control_handlers.go", - "line": 364, - "heading_line": 113, + "line": 365, + "heading_line": 114, } ], )