From 7c1efd02ead936e342d784a4d819dcc57c3deb95 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 7 May 2026 09:10:57 +0100 Subject: [PATCH] Attach Patrol supporting evidence to Assistant --- .../v6/internal/subsystems/api-contracts.md | 6 +- .../subsystems/frontend-primitives.md | 8 +- .../subsystems/patrol-intelligence.md | 19 +- .../patrol/PatrolIntelligenceSummary.tsx | 4 + .../PatrolIntelligenceSummary.test.tsx | 53 +++- .../patrolInvestigationContextModel.test.ts | 60 +++- .../patrol/patrolInvestigationContextModel.ts | 290 +++++++++++++++++- 7 files changed, 401 insertions(+), 39 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 049947412..ea31d8db8 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -433,9 +433,9 @@ the canonical monitored-system blocked payload. and the dedicated `frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts` owner, so recent-change, learned-correlation, and policy-coverage summary text stays derived from the canonical AI payload in one place instead of as hook-local count and pluralization logic and that same Patrol investigation-context owner, so the current Patrol assessment summary may open Assistant with bounded model-only assessment, - verification, latest-run, supporting-context, active-finding, and resource - reference context instead of pasting page-local UI text or raw command - payloads into chat + verification, latest-run, supporting-context evidence, active-finding, and + resource reference context instead of pasting page-local UI text or raw + command payloads into chat and that same Patrol investigation-context owner, so visible Assistant drawer handoffs may include live pending-approval metadata only as safe operator context: approval ID, status, risk, requested/expiry timestamps, diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 8cfcf9fe8..b5e6321a4 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -811,10 +811,10 @@ frontend primitive boundary. so shared drawer primitives stay shell-owned rather than becoming a Patrol-specific prompt formatter. Patrol assessment-level handoffs must use that same feature helper to attach bounded model-only assessment, - verification, latest-run, supporting-context, active-finding, and resource - reference context while forcing request-local approval-required mode, so the - shared drawer stays a generic shell rather than a Patrol summary prompt - builder. The drawer may render a generic + verification, latest-run, supporting-context evidence, active-finding, and + resource reference context while forcing request-local approval-required + mode, so the shared drawer stays a generic shell rather than a Patrol + summary prompt builder. The drawer may render a generic context-briefing band from `frontend-modern/src/stores/aiChat.ts`, but feature-owned helpers must provide the source labels, attention reason, evidence summaries, operator-decision copy, action copy, and safety note. diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index c9a60a835..10a4ab358 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -193,11 +193,12 @@ Patrol-specific presentation helpers. `frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts`. The summary action may pass the current assessment title, health score, verification recency, latest run, secondary investigation context, bounded - active-finding summaries, and structured resource references as model-only - context. It must force request-local approval-required mode, keep raw command - and approval payloads out of prompt and drawer copy, and frame Assistant as - explanation, prioritization, and safe next-step review rather than a generic - reactive chat box. + recent-change and learned-correlation evidence, active-finding summaries, + and structured resource references as model-only context. It must force + request-local approval-required mode, keep raw command and approval payloads + out of prompt and drawer copy, and frame Assistant as explanation, + prioritization, and safe next-step review rather than a generic reactive chat + box. ## Current State @@ -250,9 +251,11 @@ handoff. `frontend-modern/src/features/patrol/PatrolIntelligenceSummary.tsx` opens Assistant through `frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts`, which packages the current Patrol assessment, verification posture, latest run, -secondary investigation context, bounded active-finding summaries, and deduped -resource references as model-only context while forcing `autonomousMode:false` -and summarizing proposed-fix command-bearing records by count only. +secondary investigation context, bounded recent-change and learned-correlation +evidence, bounded active-finding summaries, and deduped resource references as +model-only context while forcing `autonomousMode:false` and summarizing +proposed-fix command-bearing records and command-bearing change events without +raw command text. That active-runtime label must stay operational rather than verdict-like: the header chip should communicate that Patrol is enabled or available, not imply that infrastructure health is currently good merely because the runtime is on. diff --git a/frontend-modern/src/features/patrol/PatrolIntelligenceSummary.tsx b/frontend-modern/src/features/patrol/PatrolIntelligenceSummary.tsx index 7bbe3dab8..05471d397 100644 --- a/frontend-modern/src/features/patrol/PatrolIntelligenceSummary.tsx +++ b/frontend-modern/src/features/patrol/PatrolIntelligenceSummary.tsx @@ -231,6 +231,10 @@ export function PatrolIntelligenceSummary(props: { state: PatrolIntelligenceStat hasContext: state.hasInvestigationContext(), summaryText: state.investigationContextSummary(), }, + supportingEvidence: { + recentChanges: state.intelligenceSummary()?.recent_changes, + correlations: state.correlations(), + }, activeFindings: state.activePatrolFindings(), }), ); diff --git a/frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceSummary.test.tsx b/frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceSummary.test.tsx index e48aa5022..ba8f02427 100644 --- a/frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceSummary.test.tsx +++ b/frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceSummary.test.tsx @@ -24,10 +24,13 @@ describe('PatrolIntelligenceSummary', () => { expect(context.autonomousMode).toBe(false); expect(context.handoffContext).toContain('[Patrol Assessment Context]'); expect(context.handoffContext).toContain('Source: Pulse Patrol current assessment'); - expect(context.handoffContext).toContain('Supporting Context: 1 recent change'); + expect(context.handoffContext).toContain('Supporting Context: 2 recent changes'); + expect(context.handoffContext).toContain('Recent Change 1: Metric anomaly'); + expect(context.handoffContext).toContain('Correlation 1: Nightly backup job'); expect(context.handoffContext).toContain('Finding 1: High CPU usage'); expect(context.handoffResources).toEqual([ { id: 'vm-100', name: 'web-server', type: 'vm', node: 'pve-1' }, + { id: 'backup-job', name: 'Nightly backup job', type: 'job', node: undefined }, ]); expect(JSON.stringify(context)).not.toContain('systemctl restart workload.service'); }); @@ -86,6 +89,22 @@ function createPatrolState(): PatrolIntelligenceState { blockedReason: () => undefined, circuitBreakerStatus: () => undefined, correlationTotal: () => 2, + correlations: () => [ + { + source_id: 'backup-job', + source_name: 'Nightly backup job', + source_type: 'job', + target_id: 'vm-100', + target_name: 'web-server', + target_type: 'vm', + event_pattern: 'backup_started -> cpu_spike', + occurrences: 4, + avg_delay: 120000000000, + confidence: 0.92, + last_seen: '2026-05-06T12:08:00Z', + description: 'CPU pressure usually follows this backup job.', + }, + ], hasInvestigationContext: () => true, initialSurfaceReady: () => true, intelligenceSummary: () => ({ @@ -95,7 +114,33 @@ function createPatrolState(): PatrolIntelligenceState { factors: [], prediction: 'Patrol surfaced one active critical finding.', }, - recent_changes_count: 1, + recent_changes_count: 2, + recent_changes: [ + { + id: 'change-1', + observedAt: '2026-05-06T12:08:00Z', + resourceId: 'vm-100', + kind: 'metric_anomaly', + sourceType: 'heuristic', + sourceAdapter: 'proxmox_adapter', + confidence: 'high', + relatedResources: ['backup-job'], + reason: 'CPU spike after backup job', + }, + { + id: 'change-2', + observedAt: '2026-05-06T12:07:00Z', + resourceId: 'vm-100', + kind: 'command_executed', + sourceType: 'agent_action', + sourceAdapter: 'agent:ops-helper', + confidence: 'medium', + reason: 'systemctl restart workload.service', + metadata: { + command: 'systemctl restart workload.service', + }, + }, + ], policy_posture: { total_resources: 4, sensitivity_counts: {}, @@ -103,7 +148,7 @@ function createPatrolState(): PatrolIntelligenceState { }, }), investigationContextSummary: () => - '1 recent change · 2 correlations · 4 policy-covered resources', + '2 recent changes · 2 correlations · 4 policy-covered resources', patrolRunHistory: { value: () => [ { @@ -128,7 +173,7 @@ function createPatrolState(): PatrolIntelligenceState { sensitivity_counts: {}, routing_counts: {}, }), - recentChangeCount: () => 1, + recentChangeCount: () => 2, runtimeState: () => 'active', summaryStats: () => ({ criticalFindings: 1, diff --git a/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts b/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts index b14e5a355..5c81750c0 100644 --- a/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts +++ b/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts @@ -126,11 +126,57 @@ describe('patrolInvestigationContextModel', () => { findingsSnapshotAvailable: true, }, investigationContext: { - recentChangeCount: 1, + recentChangeCount: 2, correlationCount: 2, governedResourceCount: 4, hasContext: true, - summaryText: '1 recent change · 2 correlations · 4 policy-covered resources', + summaryText: '2 recent changes · 2 correlations · 4 policy-covered resources', + }, + supportingEvidence: { + recentChanges: [ + { + id: 'change-1', + observedAt: '2026-05-06T12:08:00Z', + occurredAt: '2026-05-06T12:07:30Z', + resourceId: 'vm-100', + kind: 'metric_anomaly', + sourceType: 'heuristic', + sourceAdapter: 'proxmox_adapter', + confidence: 'high', + actor: 'Pulse Patrol', + relatedResources: ['backup-job'], + reason: 'CPU spike after backup job', + }, + { + id: 'change-2', + observedAt: '2026-05-06T12:07:00Z', + resourceId: 'vm-100', + kind: 'command_executed', + sourceType: 'agent_action', + sourceAdapter: 'agent:ops-helper', + confidence: 'medium', + reason: 'systemctl restart workload.service', + metadata: { + command: 'systemctl restart workload.service', + }, + }, + ], + correlations: [ + { + source_id: 'backup-job', + source_name: 'Nightly backup job', + source_type: 'job', + target_id: 'vm-100', + target_name: 'web-server', + target_type: 'vm', + event_pattern: 'backup_started -> cpu_spike', + occurrences: 4, + avg_delay: 120000000000, + confidence: 0.92, + last_seen: '2026-05-06T12:08:00Z', + description: 'CPU pressure usually follows this backup job.', + }, + ], }, activeFindings: [ { @@ -196,12 +242,20 @@ describe('patrolInvestigationContextModel', () => { expect(handoff.context.handoffContext).toContain('Source: Pulse Patrol current assessment'); expect(handoff.context.handoffContext).toContain('Health: Health B 84/100'); expect(handoff.context.handoffContext).toContain( - 'Supporting Context: 1 recent change · 2 correlations · 4 policy-covered resources', + 'Supporting Context: 2 recent changes · 2 correlations · 4 policy-covered resources', ); + expect(handoff.context.handoffContext).toContain( + 'Recent Change 1: Metric anomaly: CPU spike after backup job', + ); + expect(handoff.context.handoffContext).toContain( + 'Recent Change 2: Command executed: execution event recorded', + ); + expect(handoff.context.handoffContext).toContain('Correlation 1: Nightly backup job'); expect(handoff.context.handoffContext).toContain('Finding 1: High CPU usage'); expect(handoff.context.handoffContext).toContain('1 command recorded for approval context'); expect(handoff.context.handoffResources).toEqual([ { id: 'vm-100', name: 'web-server', type: 'vm', node: 'pve-1' }, + { id: 'backup-job', name: 'Nightly backup job', type: 'job', node: undefined }, ]); expect(handoff.context.briefing).toMatchObject({ sourceLabel: 'Pulse Patrol', diff --git a/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts b/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts index 6e669e089..0afedc84b 100644 --- a/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts +++ b/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts @@ -1,9 +1,21 @@ import type { CorrelationsResponse, IntelligencePolicyPostureSummary, + ResourceCorrelation, } from '@/types/aiIntelligence'; import type { InvestigationRecord, RemediationPlan } from '@/api/ai'; import type { AIChatContext, AIChatContextBriefing, AIChatHandoffResource } from '@/stores/aiChat'; +import type { ResourceChange } from '@/types/resource'; +import { + formatResourceChangeKind, + sortResourceChangesByObservedAt, +} from '@/utils/resourceChangePresentation'; +import { + formatResourceCorrelationEndpoint, + formatResourceCorrelationPattern, + formatResourceCorrelationSummary, + sortResourceCorrelations, +} from '@/utils/resourceCorrelationPresentation'; export interface PatrolInvestigationContextSummaryInput { recentChangesCount?: number | null; @@ -142,6 +154,10 @@ export interface PatrolAssessmentAssistantHandoffInput { findingsSnapshotAvailable?: boolean | null; } | null; investigationContext?: PatrolInvestigationContextSummary | null; + supportingEvidence?: { + recentChanges?: ResourceChange[] | null; + correlations?: ResourceCorrelation[] | null; + } | null; activeFindings?: PatrolAssessmentAssistantFindingInput[] | null; } @@ -151,6 +167,8 @@ export interface PatrolAssessmentAssistantHandoff { } const MAX_ASSESSMENT_FINDINGS = 5; +const MAX_ASSESSMENT_RECENT_CHANGES = 3; +const MAX_ASSESSMENT_CORRELATIONS = 3; const MAX_ASSESSMENT_RESOURCES = 8; export function buildPatrolInvestigationContextSummary( @@ -259,6 +277,8 @@ export function buildPatrolAssessmentAssistantHandoff( const title = normalizeText(input.assessment?.title) || 'Pulse Patrol assessment'; const description = normalizeText(input.assessment?.description); const handoffContext = buildPatrolAssessmentAssistantModelContext(input); + const recentChanges = normalizeAssessmentRecentChanges(input.supportingEvidence?.recentChanges); + const correlations = normalizeAssessmentCorrelations(input.supportingEvidence?.correlations); return { prompt: [ @@ -274,13 +294,15 @@ export function buildPatrolAssessmentAssistantHandoff( targetId: 'pulse-patrol-assessment', autonomousMode: false, handoffContext, - handoffResources: buildPatrolAssessmentHandoffResources(input.activeFindings ?? []), + handoffResources: buildPatrolAssessmentHandoffResources(input), briefing: buildPatrolAssessmentAssistantBriefing(input), context: { source: 'pulse-patrol-assessment', activeFindingCount: normalizeNonNegativeCount(input.activeFindings?.length), recentChangeCount: input.investigationContext?.recentChangeCount ?? 0, correlationCount: input.investigationContext?.correlationCount ?? 0, + recentChangeDetailCount: recentChanges.length, + correlationDetailCount: correlations.length, governedResourceCount: input.investigationContext?.governedResourceCount ?? 0, }, }, @@ -298,6 +320,15 @@ function buildPatrolAssessmentAssistantBriefing( const latestRun = formatAssessmentLatestRun(input); const contextSummary = normalizeText(input.investigationContext?.summaryText); const findings = normalizeAssessmentFindings(input.activeFindings); + const recentChanges = normalizeAssessmentRecentChanges(input.supportingEvidence?.recentChanges); + const correlations = normalizeAssessmentCorrelations(input.supportingEvidence?.correlations); + const findingEvidence = findings.map(formatAssessmentFindingEvidence).filter(isNonEmptyString); + const supportingEvidence = [ + ...recentChanges.map(formatAssessmentRecentChangeEvidence), + ...correlations.map(formatAssessmentCorrelationEvidence), + ] + .filter(isNonEmptyString) + .slice(0, 2); return { sourceLabel: 'Pulse Patrol', @@ -307,7 +338,7 @@ function buildPatrolAssessmentAssistantBriefing( detailLines: [description, verification, latestRun, contextSummary] .filter(isNonEmptyString) .slice(0, 4), - evidence: findings.map(formatAssessmentFindingEvidence).filter(isNonEmptyString).slice(0, 4), + evidence: [...findingEvidence.slice(0, 3), ...supportingEvidence].slice(0, 5), actionLabel: 'Discuss Patrol assessment', safetyNote: 'Diagnostics and remediation require governed approval.', }; @@ -317,8 +348,20 @@ function buildPatrolAssessmentAssistantModelContext( input: PatrolAssessmentAssistantHandoffInput, ): string { const findings = normalizeAssessmentFindings(input.activeFindings); + const recentChanges = normalizeAssessmentRecentChanges(input.supportingEvidence?.recentChanges); + const correlations = normalizeAssessmentCorrelations(input.supportingEvidence?.correlations); const totalFindingCount = normalizeNonNegativeCount(input.activeFindings?.length); const omittedFindingCount = Math.max(0, totalFindingCount - findings.length); + const totalRecentChangeCount = Math.max( + normalizeNonNegativeCount(input.investigationContext?.recentChangeCount), + (input.supportingEvidence?.recentChanges ?? []).length, + ); + const omittedRecentChangeCount = Math.max(0, totalRecentChangeCount - recentChanges.length); + const totalCorrelationCount = Math.max( + normalizeNonNegativeCount(input.investigationContext?.correlationCount), + (input.supportingEvidence?.correlations ?? []).length, + ); + const omittedCorrelationCount = Math.max(0, totalCorrelationCount - correlations.length); return [ '[Patrol Assessment Context]', @@ -335,6 +378,22 @@ function buildPatrolAssessmentAssistantModelContext( formatContextLine('Last Patrol', formatAssessmentRecency(input)), formatContextLine('Latest Run', formatAssessmentLatestRun(input)), formatContextLine('Supporting Context', input.investigationContext?.summaryText), + ...recentChanges.map((change, index) => + formatAssessmentRecentChangeContextLine(change, index + 1), + ), + omittedRecentChangeCount > 0 + ? `${omittedRecentChangeCount} additional recent change${ + omittedRecentChangeCount === 1 ? '' : 's' + } omitted from this bounded handoff summary.` + : undefined, + ...correlations.map((correlation, index) => + formatAssessmentCorrelationContextLine(correlation, index + 1), + ), + omittedCorrelationCount > 0 + ? `${omittedCorrelationCount} additional correlation${ + omittedCorrelationCount === 1 ? '' : 's' + } omitted from this bounded handoff summary.` + : undefined, ...findings.map((finding, index) => formatAssessmentFindingContextLine(finding, index + 1)), omittedFindingCount > 0 ? `${omittedFindingCount} additional Patrol finding${omittedFindingCount === 1 ? '' : 's'} omitted from this bounded handoff summary.` @@ -346,33 +405,77 @@ function buildPatrolAssessmentAssistantModelContext( } function buildPatrolAssessmentHandoffResources( - findings: PatrolAssessmentAssistantFindingInput[], + input: PatrolAssessmentAssistantHandoffInput, ): AIChatHandoffResource[] { const resources = new Map(); + const recentChanges = normalizeAssessmentRecentChanges(input.supportingEvidence?.recentChanges); + const correlations = normalizeAssessmentCorrelations(input.supportingEvidence?.correlations); - for (const finding of findings) { - const resource = getAssessmentFindingResource(finding); - if (!resource.id) continue; + for (const finding of normalizeAssessmentFindings(input.activeFindings)) { + addAssessmentHandoffResource(resources, getAssessmentFindingResource(finding)); + } - const key = [resource.type, resource.id].filter(isNonEmptyString).join(':'); - const existing = resources.get(key); - if (existing) { - resources.set(key, { - ...existing, - name: existing.name || resource.name, - type: existing.type || resource.type, - node: existing.node || resource.node, + for (const change of recentChanges) { + addAssessmentHandoffResource(resources, { + id: normalizeText(change.resourceId), + }); + for (const relatedResource of change.relatedResources ?? []) { + addAssessmentHandoffResource(resources, { + id: normalizeText(relatedResource), }); - continue; } - resources.set(key, resource); + } - if (resources.size >= MAX_ASSESSMENT_RESOURCES) break; + for (const correlation of correlations) { + addAssessmentHandoffResource(resources, { + id: normalizeText(correlation.source_id), + name: normalizeText(correlation.source_name) || undefined, + type: normalizeText(correlation.source_type) || undefined, + }); + addAssessmentHandoffResource(resources, { + id: normalizeText(correlation.target_id), + name: normalizeText(correlation.target_name) || undefined, + type: normalizeText(correlation.target_type) || undefined, + }); } return Array.from(resources.values()); } +function addAssessmentHandoffResource( + resources: Map, + resource: AIChatHandoffResource, +): void { + const id = normalizeText(resource.id); + if (!id) return; + + const normalizedResource: AIChatHandoffResource = { + id, + name: normalizeText(resource.name) || undefined, + type: normalizeText(resource.type) || undefined, + node: normalizeText(resource.node) || undefined, + }; + const existingEntry = Array.from(resources.entries()).find(([, existing]) => { + if (existing.id !== id) return false; + if (!normalizedResource.type || !existing.type) return true; + return existing.type === normalizedResource.type; + }); + const key = + existingEntry?.[0] || + [normalizedResource.type, normalizedResource.id].filter(isNonEmptyString).join(':') || + id; + const existing = existingEntry?.[1] || resources.get(key); + + if (!existing && resources.size >= MAX_ASSESSMENT_RESOURCES) return; + + resources.set(key, { + id, + name: existing?.name || normalizedResource.name, + type: existing?.type || normalizedResource.type, + node: existing?.node || normalizedResource.node, + }); +} + function normalizeAssessmentFindings( findings?: PatrolAssessmentAssistantFindingInput[] | null, ): PatrolAssessmentAssistantFindingInput[] { @@ -388,6 +491,32 @@ function normalizeAssessmentFindings( .slice(0, MAX_ASSESSMENT_FINDINGS); } +function normalizeAssessmentRecentChanges(changes?: ResourceChange[] | null): ResourceChange[] { + return sortResourceChangesByObservedAt( + (changes ?? []).filter((change) => + Boolean( + normalizeText(change.id) || normalizeText(change.resourceId) || normalizeText(change.kind), + ), + ), + ).slice(0, MAX_ASSESSMENT_RECENT_CHANGES); +} + +function normalizeAssessmentCorrelations( + correlations?: ResourceCorrelation[] | null, +): ResourceCorrelation[] { + return sortResourceCorrelations( + (correlations ?? []).filter((correlation) => + Boolean( + normalizeText(correlation.source_id) || + normalizeText(correlation.source_name) || + normalizeText(correlation.target_id) || + normalizeText(correlation.target_name) || + normalizeText(correlation.event_pattern), + ), + ), + ).slice(0, MAX_ASSESSMENT_CORRELATIONS); +} + function formatAssessmentHealth(input: PatrolAssessmentAssistantHandoffInput): string | undefined { const label = normalizeText(input.scoreChipLabel) || 'Health'; const grade = normalizeText(input.overallHealth?.grade); @@ -508,6 +637,133 @@ function formatAssessmentFindingEvidence( return [title, resourceLabel, severityStatus].filter(isNonEmptyString).join(' · '); } +function formatAssessmentRecentChangeEvidence(change: ResourceChange): string | undefined { + const summary = formatAssessmentRecentChangeSummary(change); + const resource = normalizeText(change.resourceId); + const observedAt = normalizeText(change.observedAt); + return [ + summary, + resource ? `resource ${resource}` : undefined, + observedAt ? `observed ${observedAt}` : undefined, + ] + .filter(isNonEmptyString) + .join(' · '); +} + +function formatAssessmentCorrelationEvidence(correlation: ResourceCorrelation): string | undefined { + const source = formatAssessmentCorrelationEndpoint(correlation, 'source'); + const target = formatAssessmentCorrelationEndpoint(correlation, 'target'); + const pattern = truncateContextText(formatResourceCorrelationPattern(correlation), 120); + return [ + source && target ? `${source} to ${target}` : source || target, + pattern ? `pattern ${pattern}` : undefined, + formatResourceCorrelationSummary(correlation), + ] + .filter(isNonEmptyString) + .join(' · '); +} + +function formatAssessmentRecentChangeContextLine(change: ResourceChange, index: number): string { + const relatedResources = (change.relatedResources ?? []) + .map(normalizeText) + .filter(isNonEmptyString) + .slice(0, 4); + const parts = [ + formatAssessmentRecentChangeSummary(change), + normalizeText(change.id) ? `change ${normalizeText(change.id)}` : undefined, + normalizeText(change.resourceId) ? `resource ${normalizeText(change.resourceId)}` : undefined, + normalizeText(change.observedAt) ? `observed ${normalizeText(change.observedAt)}` : undefined, + normalizeText(change.occurredAt) ? `occurred ${normalizeText(change.occurredAt)}` : undefined, + normalizeText(change.sourceType) + ? `source ${formatIdentifierLabel(change.sourceType)?.toLowerCase()}` + : undefined, + normalizeText(change.sourceAdapter) + ? `adapter ${formatIdentifierLabel(change.sourceAdapter)}` + : undefined, + normalizeText(change.confidence) + ? `${formatIdentifierLabel(change.confidence)?.toLowerCase()} confidence` + : undefined, + normalizeText(change.actor) ? `actor ${truncateContextText(change.actor, 80)}` : undefined, + relatedResources.length > 0 ? `related ${relatedResources.join(', ')}` : undefined, + ].filter(isNonEmptyString); + + return `Recent Change ${index}: ${parts.join('; ')}`; +} + +function formatAssessmentCorrelationContextLine( + correlation: ResourceCorrelation, + index: number, +): string { + const source = formatAssessmentCorrelationEndpoint(correlation, 'source'); + const target = formatAssessmentCorrelationEndpoint(correlation, 'target'); + const parts = [ + source && target ? `${source} to ${target}` : source || target, + normalizeText(correlation.event_pattern) + ? `pattern ${truncateContextText(formatResourceCorrelationPattern(correlation), 140)}` + : undefined, + formatResourceCorrelationSummary(correlation), + normalizeText(correlation.last_seen) + ? `last seen ${normalizeText(correlation.last_seen)}` + : undefined, + normalizeText(correlation.description) + ? `description ${truncateContextText(correlation.description, 180)}` + : undefined, + ].filter(isNonEmptyString); + + return `Correlation ${index}: ${parts.join('; ')}`; +} + +function formatAssessmentRecentChangeSummary(change: ResourceChange): string { + const kind = formatResourceChangeKind(change.kind); + if (isCommandBearingResourceChange(change)) { + return `${kind}: execution event recorded`; + } + + if ( + (change.kind === 'state_transition' || change.kind === 'restart') && + normalizeText(change.from) && + normalizeText(change.to) + ) { + return `${kind}: ${truncateContextText(change.from, 80)} to ${truncateContextText( + change.to, + 80, + )}`; + } + + if (normalizeText(change.reason)) { + return `${kind}: ${truncateContextText(change.reason, 160)}`; + } + + return `${kind}: ${normalizeText(change.resourceId) || normalizeText(change.id) || 'resource'}`; +} + +function isCommandBearingResourceChange(change: ResourceChange): boolean { + return change.kind === 'command_executed' || change.kind === 'runbook_executed'; +} + +function formatAssessmentCorrelationEndpoint( + correlation: ResourceCorrelation, + role: 'source' | 'target', +): string | undefined { + const label = normalizeText(formatResourceCorrelationEndpoint(correlation, role)); + const id = + role === 'source' ? normalizeText(correlation.source_id) : normalizeText(correlation.target_id); + const type = + role === 'source' + ? normalizeText(correlation.source_type) + : normalizeText(correlation.target_type); + const displayLabel = label || id; + if (!displayLabel) return undefined; + + const qualifiers = [ + type ? formatIdentifierLabel(type)?.toLowerCase() : undefined, + id && id !== displayLabel ? id : undefined, + ] + .filter(isNonEmptyString) + .join(' '); + return qualifiers ? `${displayLabel} (${qualifiers})` : displayLabel; +} + function formatAssessmentFindingContextLine( finding: PatrolAssessmentAssistantFindingInput, index: number,