mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-23 19:57:09 +00:00
Attach Patrol supporting evidence to Assistant
This commit is contained in:
@@ -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,
|
||||
|
||||
+57
-3
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user