Add Patrol assessment Assistant handoff

This commit is contained in:
rcourtman 2026-05-07 08:52:38 +01:00
parent 812c86692d
commit 73b4cf25c3
7 changed files with 769 additions and 10 deletions

View file

@ -431,6 +431,11 @@ the canonical monitored-system blocked payload.
and the shared dashboard-load bundle inside `frontend-modern/src/stores/aiIntelligence.ts`, so the page orchestration stays on the store-owned bundle instead of enumerating the AI fetches inline
and the shared `frontend-modern/src/components/Infrastructure/ResourcePolicySummary.tsx` card, so the AI summary page renders the governed policy-posture counts while the resource drawer stays on per-resource policy lines instead of carrying duplicate posture UI loops
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
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,

View file

@ -809,7 +809,12 @@ frontend primitive boundary.
investigation-record framing must derive that prompt copy through
`frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts`
so shared drawer primitives stay shell-owned rather than becoming a
Patrol-specific prompt formatter. The drawer may render a generic
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
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.

View file

@ -188,6 +188,16 @@ Patrol-specific presentation helpers.
structured `investigationRecord` as investigation data, even when the legacy
investigation-detail endpoint has no separate session payload, so it must not
render empty-state copy above a durable Patrol record.
The primary Patrol assessment summary may open Assistant as a whole-surface
review handoff, but that handoff must also flow through
`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.
## Current State
@ -235,6 +245,14 @@ render contract: the header chip, primary summary card, and status bar must
all route through the shared `frontend-modern/src/utils/patrolRuntimePresentation.ts`
helper plus the backend `runtime_state` payload instead of inferring operator
state from the last healthy summary snapshot or run history alone.
The primary summary card now also has a Patrol-owned Assistant assessment
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.
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.

View file

@ -4,6 +4,7 @@ import ShieldAlertIcon from 'lucide-solid/icons/shield-alert';
import CheckCircleIcon from 'lucide-solid/icons/check-circle';
import AlertCircleIcon from 'lucide-solid/icons/alert-circle';
import AlertTriangleIcon from 'lucide-solid/icons/alert-triangle';
import MessageSquareIcon from 'lucide-solid/icons/message-square';
import {
getPatrolAssessmentAction,
getPatrolAssessmentShellPresentation,
@ -20,6 +21,8 @@ import {
} from '@/utils/patrolRunPresentation';
import { getPatrolRuntimePresentation } from '@/utils/patrolRuntimePresentation';
import { formatRelativeTime } from '@/utils/format';
import { aiChatStore } from '@/stores/aiChat';
import { buildPatrolAssessmentAssistantHandoff } from './patrolInvestigationContextModel';
import type { PatrolIntelligenceState } from './usePatrolIntelligenceState';
function PatrolAssessmentLoadingShell() {
@ -212,6 +215,30 @@ export function PatrolIntelligenceSummary(props: { state: PatrolIntelligenceStat
return metrics;
});
const assessmentAssistantHandoff = createMemo(() =>
buildPatrolAssessmentAssistantHandoff({
assessment: assessment(),
overallHealth: state.intelligenceSummary()?.overall_health,
scoreChipLabel: scoreChipLabel(),
metricState: metricState(),
verification: verification(),
recency: recency(),
latestRun: latestRun(),
investigationContext: {
recentChangeCount: state.recentChangeCount(),
correlationCount: state.correlationTotal(),
governedResourceCount: state.policyPosture()?.total_resources ?? 0,
hasContext: state.hasInvestigationContext(),
summaryText: state.investigationContextSummary(),
},
activeFindings: state.activePatrolFindings(),
}),
);
const handleDiscussAssessment = () => {
const handoff = assessmentAssistantHandoff();
aiChatStore.openWithPrompt(handoff.prompt, handoff.context);
};
const renderAssessmentIcon = () => {
const iconClass = `w-5 h-5 ${assessmentShellPresentation().iconClass}`;
@ -307,18 +334,28 @@ export function PatrolIntelligenceSummary(props: { state: PatrolIntelligenceStat
<p class="mt-1.5 max-w-3xl text-sm leading-6 text-muted">
{assessment().description}
</p>
<Show when={assessmentAction()}>
{(action) => (
<div class="mt-4">
<div class="mt-4 flex flex-wrap items-center gap-2">
<Show when={assessmentAction()}>
{(action) => (
<a
href={action().href}
class="inline-flex items-center rounded-md border border-border bg-surface px-3 py-1.5 text-xs font-semibold text-base-content shadow-sm transition-colors hover:bg-surface-hover"
>
{action().label}
</a>
</div>
)}
</Show>
)}
</Show>
<button
type="button"
data-testid="patrol-assessment-assistant-button"
class="inline-flex items-center gap-1.5 rounded-md border border-border bg-surface px-3 py-1.5 text-xs font-semibold text-base-content shadow-sm transition-colors hover:bg-surface-hover focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
title="Discuss current Patrol assessment"
onClick={handleDiscussAssessment}
>
<MessageSquareIcon class="h-4 w-4" aria-hidden="true" />
<span>Discuss with Assistant</span>
</button>
</div>
</div>
</div>
@ -364,7 +401,9 @@ export function PatrolIntelligenceSummary(props: { state: PatrolIntelligenceStat
{(latest) => (
<>
<div class="mt-1 flex flex-wrap items-center gap-2 text-sm">
<span class="font-medium text-base-content">{latest().kindLabel}</span>
<span class="font-medium text-base-content">
{latest().kindLabel}
</span>
<span
class={`inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium ${latest().status.badgeClass}`}
>
@ -414,7 +453,9 @@ export function PatrolIntelligenceSummary(props: { state: PatrolIntelligenceStat
</Show>
</Show>
<Show when={!showRuntimeSummary() && state.intelligenceSummary() && visibleMetrics().length > 0}>
<Show
when={!showRuntimeSummary() && state.intelligenceSummary() && visibleMetrics().length > 0}
>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
<For each={visibleMetrics()}>
{(metric) => {

View file

@ -0,0 +1,141 @@
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { aiChatStore } from '@/stores/aiChat';
import { PatrolIntelligenceSummary } from '../PatrolIntelligenceSummary';
import type { PatrolIntelligenceState } from '../usePatrolIntelligenceState';
describe('PatrolIntelligenceSummary', () => {
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
it('opens Assistant with model-only context for the current Patrol assessment', () => {
const openWithPrompt = vi.spyOn(aiChatStore, 'openWithPrompt').mockImplementation(() => {});
render(() => <PatrolIntelligenceSummary state={createPatrolState()} />);
fireEvent.click(screen.getByTestId('patrol-assessment-assistant-button'));
expect(openWithPrompt).toHaveBeenCalledTimes(1);
const [prompt, context] = openWithPrompt.mock.calls[0] as [string, Record<string, unknown>];
expect(prompt).toContain('Discuss the current Pulse Patrol assessment');
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('Finding 1: High CPU usage');
expect(context.handoffResources).toEqual([
{ id: 'vm-100', name: 'web-server', type: 'vm', node: 'pve-1' },
]);
expect(JSON.stringify(context)).not.toContain('systemctl restart workload.service');
});
});
function createPatrolState(): PatrolIntelligenceState {
return {
activePatrolFindings: () => [
{
id: 'finding-1',
source: 'ai-patrol',
resourceId: 'vm-100',
resourceName: 'web-server',
resourceType: 'vm',
category: 'performance',
severity: 'critical',
title: 'High CPU usage',
description: 'CPU stayed above 95%.',
detectedAt: '2026-05-06T12:00:00Z',
lastSeenAt: '2026-05-06T12:10:00Z',
status: 'active',
investigationStatus: 'completed',
investigationOutcome: 'fix_queued',
loopState: 'awaiting_approval',
timesRaised: 3,
regressionCount: 1,
investigationRecord: {
id: 'record-1',
finding_id: 'finding-1',
subject: {
resource_id: 'vm-100',
resource_name: 'web-server',
resource_type: 'vm',
node: 'pve-1',
},
trigger: { detected_at: '2026-05-06T12:00:00Z' },
status: 'completed',
outcome: 'fix_queued',
confidence: 'high',
conclusion: 'Backup job saturated CPU.',
recommended_action: 'Approve a controlled restart after the backup completes.',
evidence: [{ kind: 'metrics', summary: 'CPU stayed above 95% for 10 minutes' }],
proposed_fix: {
id: 'fix-1',
description: 'Restart the workload service',
commands: ['systemctl restart workload.service'],
risk_level: 'medium',
destructive: false,
},
verification: ['CPU returned below 50%'],
tools_used: ['ssh.exec'],
started_at: '2026-05-06T12:00:00Z',
},
},
],
blockedReason: () => undefined,
circuitBreakerStatus: () => undefined,
correlationTotal: () => 2,
hasInvestigationContext: () => true,
initialSurfaceReady: () => true,
intelligenceSummary: () => ({
overall_health: {
grade: 'B',
score: 84,
factors: [],
prediction: 'Patrol surfaced one active critical finding.',
},
recent_changes_count: 1,
policy_posture: {
total_resources: 4,
sensitivity_counts: {},
routing_counts: {},
},
}),
investigationContextSummary: () =>
'1 recent change · 2 correlations · 4 policy-covered resources',
patrolRunHistory: {
value: () => [
{
id: 'run-1',
started_at: '2026-05-06T12:00:00Z',
completed_at: '2026-05-06T12:10:00Z',
type: 'full',
status: 'issues_found',
resources_checked: 12,
new_findings: 1,
error_count: 0,
finding_ids: ['finding-1'],
},
],
},
patrolStatus: () => ({
last_patrol_at: '2026-05-06T12:10:00Z',
last_activity_at: '2026-05-06T12:10:00Z',
}),
policyPosture: () => ({
total_resources: 4,
sensitivity_counts: {},
routing_counts: {},
}),
recentChangeCount: () => 1,
runtimeState: () => 'active',
summaryStats: () => ({
criticalFindings: 1,
warningFindings: 0,
totalActive: 1,
fixedCount: 0,
hasAnyPatrolFindings: true,
}),
} as unknown as PatrolIntelligenceState;
}

View file

@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import type { RemediationPlan } from '@/api/ai';
import {
buildPatrolAssessmentAssistantHandoff,
buildPatrolAssistantFindingBriefing,
buildPatrolAssistantFindingPrompt,
buildPatrolInvestigationContextSummary,
@ -89,6 +90,128 @@ describe('patrolInvestigationContextModel', () => {
});
});
it('builds a model-only Assistant handoff for the current Patrol assessment', () => {
const handoff = buildPatrolAssessmentAssistantHandoff({
assessment: {
title: 'Issues detected',
description:
'Patrol surfaced one active critical finding and recent coverage is incomplete.',
eyebrow: 'Current assessment',
},
overallHealth: { grade: 'B', score: 84 },
scoreChipLabel: 'Health',
metricState: {
primaryLabel: 'Infrastructure findings',
primaryValue: 1,
secondaryLabel: 'Runtime issues',
secondaryValue: 1,
fixedLabel: 'Fixed',
fixedValue: 2,
},
verification: {
title: 'Full patrol verified recently',
description: 'Latest full run completed with findings.',
lastFullRunAt: '2026-05-06T12:00:00Z',
activityMixLabel: '1 full patrol · 2 scoped runs',
},
recency: {
label: 'Last patrol',
timestamp: '2026-05-06T12:10:00Z',
},
latestRun: {
kindLabel: 'Full patrol',
status: { label: 'issues found' },
timestamp: '2026-05-06T12:10:00Z',
coverageSummary: '12 resources checked',
findingsSnapshotAvailable: true,
},
investigationContext: {
recentChangeCount: 1,
correlationCount: 2,
governedResourceCount: 4,
hasContext: true,
summaryText: '1 recent change · 2 correlations · 4 policy-covered resources',
},
activeFindings: [
{
id: 'finding-1',
title: 'High CPU usage',
description: 'CPU stayed above 95% during backup.',
severity: 'critical',
status: 'active',
resourceId: 'vm-100',
resourceName: 'web-server',
resourceType: 'vm',
detectedAt: '2026-05-06T12:00:00Z',
lastSeenAt: '2026-05-06T12:10:00Z',
investigationStatus: 'completed',
investigationOutcome: 'fix_queued',
loopState: 'awaiting_approval',
timesRaised: 3,
regressionCount: 1,
lastRegressionAt: '2026-05-06T12:06:00Z',
investigationRecord: {
id: 'record-1',
finding_id: 'finding-1',
subject: {
resource_id: 'vm-100',
resource_name: 'web-server',
resource_type: 'vm',
node: 'pve-1',
},
trigger: { detected_at: '2026-05-06T12:00:00Z' },
status: 'completed',
outcome: 'fix_queued',
confidence: 'high',
conclusion: 'Backup job saturated CPU.',
recommended_action: 'Approve a controlled restart after the backup completes.',
evidence: [{ kind: 'metrics', summary: 'CPU stayed above 95% for 10 minutes' }],
proposed_fix: {
id: 'fix-1',
description: 'Restart the workload service',
commands: ['systemctl restart workload.service'],
risk_level: 'medium',
destructive: false,
},
verification: ['CPU returned below 50%'],
tools_used: ['ssh.exec'],
started_at: '2026-05-06T12:00:00Z',
},
},
{
id: 'finding-2',
title: 'Patrol provider warning',
severity: 'warning',
status: 'active',
resourceId: 'vm-100',
resourceName: 'web-server',
resourceType: 'vm',
},
],
});
expect(handoff.prompt).toContain('Discuss the current Pulse Patrol assessment');
expect(handoff.context.autonomousMode).toBe(false);
expect(handoff.context.handoffContext).toContain('[Patrol Assessment Context]');
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',
);
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' },
]);
expect(handoff.context.briefing).toMatchObject({
sourceLabel: 'Pulse Patrol',
title: 'Patrol assessment attached',
subject: 'Issues detected',
safetyNote: 'Diagnostics and remediation require governed approval.',
});
expect(JSON.stringify(handoff)).not.toContain('systemctl restart workload.service');
});
it('builds operator-facing Patrol record presentation without exposing raw commands', () => {
const presentation = buildPatrolInvestigationRecordPresentation({
id: 'record-1',

View file

@ -3,7 +3,7 @@ import type {
IntelligencePolicyPostureSummary,
} from '@/types/aiIntelligence';
import type { InvestigationRecord, RemediationPlan } from '@/api/ai';
import type { AIChatContextBriefing } from '@/stores/aiChat';
import type { AIChatContext, AIChatContextBriefing, AIChatHandoffResource } from '@/stores/aiChat';
export interface PatrolInvestigationContextSummaryInput {
recentChangesCount?: number | null;
@ -83,6 +83,76 @@ export interface PatrolRemediationPlanAssistantInput {
plan: RemediationPlan;
}
export interface PatrolAssessmentAssistantFindingInput {
id?: string | null;
title?: string | null;
description?: string | null;
severity?: string | null;
status?: string | null;
resourceId?: string | null;
resourceName?: string | null;
resourceType?: string | null;
detectedAt?: string | null;
lastSeenAt?: string | null;
investigationStatus?: string | null;
investigationOutcome?: string | null;
loopState?: string | null;
timesRaised?: number | null;
regressionCount?: number | null;
lastRegressionAt?: string | null;
investigationRecord?: InvestigationRecord | null;
}
export interface PatrolAssessmentAssistantHandoffInput {
assessment?: {
title?: string | null;
description?: string | null;
eyebrow?: string | null;
} | null;
overallHealth?: {
grade?: string | null;
score?: number | null;
} | null;
scoreChipLabel?: string | null;
metricState?: {
primaryLabel?: string | null;
primaryValue?: number | null;
secondaryLabel?: string | null;
secondaryValue?: number | null;
fixedLabel?: string | null;
fixedValue?: number | null;
} | null;
verification?: {
title?: string | null;
description?: string | null;
lastFullRunAt?: string | null;
activityMixLabel?: string | null;
} | null;
recency?: {
label?: string | null;
timestamp?: string | null;
} | null;
latestRun?: {
kindLabel?: string | null;
status?: {
label?: string | null;
} | null;
timestamp?: string | null;
coverageSummary?: string | null;
findingsSnapshotAvailable?: boolean | null;
} | null;
investigationContext?: PatrolInvestigationContextSummary | null;
activeFindings?: PatrolAssessmentAssistantFindingInput[] | null;
}
export interface PatrolAssessmentAssistantHandoff {
prompt: string;
context: Omit<AIChatContext, 'initialPrompt'>;
}
const MAX_ASSESSMENT_FINDINGS = 5;
const MAX_ASSESSMENT_RESOURCES = 8;
export function buildPatrolInvestigationContextSummary(
input: PatrolInvestigationContextSummaryInput,
): PatrolInvestigationContextSummary {
@ -183,6 +253,348 @@ export function buildPatrolAssistantFindingPrompt(
return prompt;
}
export function buildPatrolAssessmentAssistantHandoff(
input: PatrolAssessmentAssistantHandoffInput,
): PatrolAssessmentAssistantHandoff {
const title = normalizeText(input.assessment?.title) || 'Pulse Patrol assessment';
const description = normalizeText(input.assessment?.description);
const handoffContext = buildPatrolAssessmentAssistantModelContext(input);
return {
prompt: [
`Discuss the current Pulse Patrol assessment: ${title}.`,
description,
'Use the attached model-only Patrol assessment context before suggesting next actions. Help me understand priority, risk, and safe next steps.',
'Do not infer, repeat, or execute raw command text from this handoff.',
]
.filter(isNonEmptyString)
.join('\n\n'),
context: {
targetType: 'dashboard',
targetId: 'pulse-patrol-assessment',
autonomousMode: false,
handoffContext,
handoffResources: buildPatrolAssessmentHandoffResources(input.activeFindings ?? []),
briefing: buildPatrolAssessmentAssistantBriefing(input),
context: {
source: 'pulse-patrol-assessment',
activeFindingCount: normalizeNonNegativeCount(input.activeFindings?.length),
recentChangeCount: input.investigationContext?.recentChangeCount ?? 0,
correlationCount: input.investigationContext?.correlationCount ?? 0,
governedResourceCount: input.investigationContext?.governedResourceCount ?? 0,
},
},
};
}
function buildPatrolAssessmentAssistantBriefing(
input: PatrolAssessmentAssistantHandoffInput,
): AIChatContextBriefing {
const title = normalizeText(input.assessment?.title) || 'Pulse Patrol assessment';
const description = normalizeText(input.assessment?.description);
const health = formatAssessmentHealth(input);
const attentionSummary = formatAssessmentAttentionSummary(input);
const verification = formatAssessmentVerification(input);
const latestRun = formatAssessmentLatestRun(input);
const contextSummary = normalizeText(input.investigationContext?.summaryText);
const findings = normalizeAssessmentFindings(input.activeFindings);
return {
sourceLabel: 'Pulse Patrol',
title: 'Patrol assessment attached',
subject: title,
statusLabel: [health, attentionSummary].filter(isNonEmptyString).join(' · ') || undefined,
detailLines: [description, verification, latestRun, contextSummary]
.filter(isNonEmptyString)
.slice(0, 4),
evidence: findings.map(formatAssessmentFindingEvidence).filter(isNonEmptyString).slice(0, 4),
actionLabel: 'Discuss Patrol assessment',
safetyNote: 'Diagnostics and remediation require governed approval.',
};
}
function buildPatrolAssessmentAssistantModelContext(
input: PatrolAssessmentAssistantHandoffInput,
): string {
const findings = normalizeAssessmentFindings(input.activeFindings);
const totalFindingCount = normalizeNonNegativeCount(input.activeFindings?.length);
const omittedFindingCount = Math.max(0, totalFindingCount - findings.length);
return [
'[Patrol Assessment Context]',
'Source: Pulse Patrol current assessment',
formatContextLine(
'Assessment',
normalizeText(input.assessment?.title) || 'Pulse Patrol assessment',
),
formatContextLine('Assessment Description', input.assessment?.description),
formatContextLine('Assessment Scope', input.assessment?.eyebrow),
formatContextLine('Health', formatAssessmentHealth(input)),
formatContextLine('Attention', formatAssessmentAttentionSummary(input)),
formatContextLine('Verification', formatAssessmentVerification(input)),
formatContextLine('Last Patrol', formatAssessmentRecency(input)),
formatContextLine('Latest Run', formatAssessmentLatestRun(input)),
formatContextLine('Supporting Context', input.investigationContext?.summaryText),
...findings.map((finding, index) => formatAssessmentFindingContextLine(finding, index + 1)),
omittedFindingCount > 0
? `${omittedFindingCount} additional Patrol finding${omittedFindingCount === 1 ? '' : 's'} omitted from this bounded handoff summary.`
: undefined,
'Operator Boundary: This Patrol assessment handoff is model-only context for explanation and review. Diagnostics, remediation, and command execution require explicit governed approval.',
]
.filter(isNonEmptyString)
.join('\n');
}
function buildPatrolAssessmentHandoffResources(
findings: PatrolAssessmentAssistantFindingInput[],
): AIChatHandoffResource[] {
const resources = new Map<string, AIChatHandoffResource>();
for (const finding of findings) {
const resource = getAssessmentFindingResource(finding);
if (!resource.id) continue;
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,
});
continue;
}
resources.set(key, resource);
if (resources.size >= MAX_ASSESSMENT_RESOURCES) break;
}
return Array.from(resources.values());
}
function normalizeAssessmentFindings(
findings?: PatrolAssessmentAssistantFindingInput[] | null,
): PatrolAssessmentAssistantFindingInput[] {
return (findings ?? [])
.filter((finding) =>
Boolean(
normalizeText(finding.id) ||
normalizeText(finding.title) ||
normalizeText(finding.resourceId) ||
normalizeText(finding.investigationRecord?.id),
),
)
.slice(0, MAX_ASSESSMENT_FINDINGS);
}
function formatAssessmentHealth(input: PatrolAssessmentAssistantHandoffInput): string | undefined {
const label = normalizeText(input.scoreChipLabel) || 'Health';
const grade = normalizeText(input.overallHealth?.grade);
const score = input.overallHealth?.score;
const scoreLabel =
typeof score === 'number' && Number.isFinite(score) ? `${Math.round(score)}/100` : '';
return [label, grade, scoreLabel].filter(isNonEmptyString).join(' ') || undefined;
}
function formatAssessmentAttentionSummary(
input: PatrolAssessmentAssistantHandoffInput,
): string | undefined {
const metricState = input.metricState;
const parts: string[] = [];
const primaryValue = normalizeNonNegativeCount(metricState?.primaryValue);
const secondaryValue = normalizeNonNegativeCount(metricState?.secondaryValue);
const fixedValue = normalizeNonNegativeCount(metricState?.fixedValue);
if (primaryValue > 0) {
parts.push(
formatAssessmentMetricCount(metricState?.primaryLabel || 'Active findings', primaryValue),
);
}
if (
secondaryValue > 0 &&
normalizeText(metricState?.secondaryLabel) !== normalizeText(metricState?.primaryLabel)
) {
parts.push(
formatAssessmentMetricCount(metricState?.secondaryLabel || 'Warnings', secondaryValue),
);
}
if (fixedValue > 0) {
parts.push(formatAssessmentMetricCount(metricState?.fixedLabel || 'Fixed', fixedValue));
}
if (parts.length === 0) {
const activeFindingCount = normalizeNonNegativeCount(input.activeFindings?.length);
return activeFindingCount > 0
? formatAssessmentMetricCount('Active findings', activeFindingCount)
: 'No active Patrol findings';
}
return parts.join(' · ');
}
function formatAssessmentMetricCount(label: string | null | undefined, value: number): string {
const normalizedLabel = normalizeText(label) || 'Items';
const displayLabel = value === 1 ? singularizeMetricLabel(normalizedLabel) : normalizedLabel;
return `${value} ${displayLabel.toLowerCase()}`;
}
function singularizeMetricLabel(label: string): string {
if (label.toLowerCase().endsWith('ies')) {
return `${label.slice(0, -3)}y`;
}
if (label.toLowerCase().endsWith('s')) {
return label.slice(0, -1);
}
return label;
}
function formatAssessmentVerification(
input: PatrolAssessmentAssistantHandoffInput,
): string | undefined {
const verification = input.verification;
if (!verification) return undefined;
return [
normalizeText(verification.title),
normalizeText(verification.description),
normalizeText(verification.lastFullRunAt)
? `last full run ${normalizeText(verification.lastFullRunAt)}`
: undefined,
normalizeText(verification.activityMixLabel)
? `recent activity mix ${normalizeText(verification.activityMixLabel)}`
: undefined,
]
.filter(isNonEmptyString)
.join('; ');
}
function formatAssessmentRecency(input: PatrolAssessmentAssistantHandoffInput): string | undefined {
const label = normalizeText(input.recency?.label);
const timestamp = normalizeText(input.recency?.timestamp);
if (!label && !timestamp) return undefined;
return [label || 'Last Patrol', timestamp].filter(isNonEmptyString).join(' ');
}
function formatAssessmentLatestRun(
input: PatrolAssessmentAssistantHandoffInput,
): string | undefined {
const latestRun = input.latestRun;
if (!latestRun) return undefined;
return [
normalizeText(latestRun.kindLabel),
normalizeText(latestRun.status?.label),
normalizeText(latestRun.timestamp),
normalizeText(latestRun.coverageSummary),
latestRun.findingsSnapshotAvailable === false ? 'findings snapshot unavailable' : undefined,
]
.filter(isNonEmptyString)
.join('; ');
}
function formatAssessmentFindingEvidence(
finding: PatrolAssessmentAssistantFindingInput,
): string | undefined {
const title = normalizeText(finding.title) || 'Patrol finding';
const resource = getAssessmentFindingResource(finding);
const severityStatus = [
formatIdentifierLabel(finding.severity),
formatIdentifierLabel(finding.status),
]
.filter(isNonEmptyString)
.join(' ');
const resourceLabel = formatAssessmentResourceLabel(resource);
return [title, resourceLabel, severityStatus].filter(isNonEmptyString).join(' · ');
}
function formatAssessmentFindingContextLine(
finding: PatrolAssessmentAssistantFindingInput,
index: number,
): string {
const title = truncateContextText(normalizeText(finding.title) || 'Patrol finding', 120);
const resource = getAssessmentFindingResource(finding);
const record = buildPatrolInvestigationRecordPresentation(finding.investigationRecord);
const statusParts = [
formatIdentifierLabel(finding.severity),
formatIdentifierLabel(finding.status),
formatIdentifierLabel(finding.investigationStatus),
formatIdentifierLabel(finding.investigationOutcome),
formatIdentifierLabel(finding.loopState),
].filter(isNonEmptyString);
const raisedParts = [
normalizeNonNegativeCount(finding.timesRaised) > 1
? `raised ${normalizeNonNegativeCount(finding.timesRaised)} times`
: undefined,
normalizeNonNegativeCount(finding.regressionCount) > 0
? `regressed ${normalizeNonNegativeCount(finding.regressionCount)} time${
normalizeNonNegativeCount(finding.regressionCount) === 1 ? '' : 's'
}`
: undefined,
normalizeText(finding.lastRegressionAt)
? `last regression ${normalizeText(finding.lastRegressionAt)}`
: undefined,
];
const recordParts = [
record.statusLabel,
record.outcomeLabel,
record.confidenceLabel,
record.conclusion ? `conclusion ${truncateContextText(record.conclusion, 180)}` : undefined,
record.recommendedAction
? `recommended ${truncateContextText(record.recommendedAction, 180)}`
: undefined,
record.proposedFix?.description
? `proposed fix ${truncateContextText(record.proposedFix.description, 160)}`
: undefined,
record.proposedFix?.commandSummary,
record.proposedFix?.destructive ? 'destructive proposed fix' : undefined,
record.error ? `investigation error ${truncateContextText(record.error, 160)}` : undefined,
];
const parts = [
`${title} on ${formatAssessmentResourceLabel(resource) || 'affected resource'}`,
normalizeText(finding.id) ? `finding ${normalizeText(finding.id)}` : undefined,
statusParts.length > 0 ? statusParts.join(' ') : undefined,
normalizeText(finding.description)
? `description ${truncateContextText(finding.description, 180)}`
: undefined,
normalizeText(finding.detectedAt) ? `detected ${normalizeText(finding.detectedAt)}` : undefined,
normalizeText(finding.lastSeenAt)
? `last seen ${normalizeText(finding.lastSeenAt)}`
: undefined,
...raisedParts,
...recordParts,
].filter(isNonEmptyString);
return `Finding ${index}: ${parts.join('; ')}`;
}
function getAssessmentFindingResource(
finding: PatrolAssessmentAssistantFindingInput,
): AIChatHandoffResource {
const subject = finding.investigationRecord?.subject;
return {
id: normalizeText(finding.resourceId) || normalizeText(subject?.resource_id),
name: normalizeText(finding.resourceName) || normalizeText(subject?.resource_name) || undefined,
type: normalizeText(finding.resourceType) || normalizeText(subject?.resource_type) || undefined,
node: normalizeText(subject?.node) || undefined,
};
}
function formatAssessmentResourceLabel(resource: AIChatHandoffResource): string | undefined {
const name = normalizeText(resource.name);
const id = normalizeText(resource.id);
const type = normalizeText(resource.type);
const node = normalizeText(resource.node);
const label = name || id;
if (!label) return undefined;
const qualifiers = [type, id && id !== label ? id : undefined, node ? `node ${node}` : undefined]
.filter(isNonEmptyString)
.join(' ');
return qualifiers ? `${label} (${qualifiers})` : label;
}
export function patrolAssistantFindingHandoffRequiresApprovalMode(
input: PatrolAssistantFindingModeInput,
): boolean {
@ -665,6 +1077,20 @@ function normalizeText(value?: string | null): string {
return value.trim();
}
function truncateContextText(value?: string | null, limit: number = 240): string {
const normalized = normalizeText(value).replace(/\s+/g, ' ');
if (!normalized || normalized.length <= limit) {
return normalized;
}
return `${normalized.slice(0, Math.max(0, limit - 3)).trim()}...`;
}
function formatContextLine(label: string, value?: string | null): string | undefined {
const normalized = truncateContextText(value, 500);
if (!normalized) return undefined;
return `${label}: ${normalized}`;
}
function isNonEmptyString(value: string | undefined): value is string {
return typeof value === 'string' && value.trim().length > 0;
}