Attach Patrol supporting evidence to Assistant

This commit is contained in:
rcourtman
2026-05-07 09:10:57 +01:00
parent 73b4cf25c3
commit 7c1efd02ea
7 changed files with 401 additions and 39 deletions
@@ -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,
@@ -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.
@@ -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.
@@ -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(),
}),
);
@@ -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,
@@ -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',
@@ -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<string, AIChatHandoffResource>();
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<string, AIChatHandoffResource>,
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,