From 370017b48dc5bd72cd9738d6ef130e5aff7ac3ec Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 25 Jun 2026 23:19:06 +0100 Subject: [PATCH] patrol: surface work-type composition in the Open work workspace description Classify active Patrol findings by actionable work type (needs approval, failed fix, in progress, recurring, new) and weave a compact composition clause into the workspace description so the operator sees the nature of their open work at a glance. The clause is empty when all findings are plain new issues, so the single-finding experience is unchanged. Advances the protection-posture-attention-queue strong-version checklist item: Patrol groups operator work across findings, approvals, failed checks, recurring issues, and unresolved incidents. --- docs/release-control/v6/internal/status.json | 16 +- .../AI/__tests__/FindingsPanel.test.ts | 14 ++ .../patrol/PatrolIntelligenceWorkspace.tsx | 5 + .../patrolControlPresentation.test.ts | 40 ++++ .../patrol/patrolControlPresentation.ts | 12 +- .../utils/__tests__/aiFindingWorkType.test.ts | 180 ++++++++++++++++++ .../src/utils/aiFindingPresentation.ts | 99 ++++++++++ 7 files changed, 363 insertions(+), 3 deletions(-) create mode 100644 frontend-modern/src/utils/__tests__/aiFindingWorkType.test.ts diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json index 2cfc2a674..ee9a817e2 100644 --- a/docs/release-control/v6/internal/status.json +++ b/docs/release-control/v6/internal/status.json @@ -7332,7 +7332,21 @@ ] } ], - "work_claims": [], + "work_claims": [ + { + "id": "opencode-coverage-gap-protection-posture-attention-queue", + "agent_id": "opencode", + "summary": "Patrol workspace work-type grouping: classify findings by actionable type and surface composition in the workspace header", + "target_id": "v6-product-lane-expansion", + "claimed_at": "2026-06-25T22:11:19Z", + "heartbeat_at": "2026-06-25T22:11:19Z", + "expires_at": "2026-06-26T00:11:19Z", + "work_item": { + "kind": "coverage-gap", + "id": "protection-posture-attention-queue" + } + } + ], "open_decisions": [], "source_of_truth_file": "docs/release-control/v6/internal/SOURCE_OF_TRUTH.md", "resolved_decisions": [ diff --git a/frontend-modern/src/components/AI/__tests__/FindingsPanel.test.ts b/frontend-modern/src/components/AI/__tests__/FindingsPanel.test.ts index f546f8c62..951711330 100644 --- a/frontend-modern/src/components/AI/__tests__/FindingsPanel.test.ts +++ b/frontend-modern/src/components/AI/__tests__/FindingsPanel.test.ts @@ -946,6 +946,20 @@ describe('aiFindingPresentation', () => { expect(patrolWorkspaceSource).not.toContain('findingsBadgePresentation().toneClasses'); }); + it('surfaces work-type composition in the workspace description', () => { + expect(aiFindingPresentationSource).toContain( + 'export function classifyPatrolFindingWorkType', + ); + expect(aiFindingPresentationSource).toContain( + 'export function getPatrolWorkTypeComposition', + ); + expect(aiFindingPresentationSource).toContain( + 'getPatrolWorkTypeCompositionClause', + ); + expect(patrolWorkspaceSource).toContain('getPatrolWorkTypeComposition'); + expect(patrolWorkspaceSource).toContain('workTypeComposition'); + }); + it('does not stack a default detected loop-state badge on active findings', () => { expect(findingsPanelSource).toContain('const shouldShowLoopStateBadge = () =>'); expect(findingsPanelSource).toContain('!isPatrolFindingsSource()'); diff --git a/frontend-modern/src/features/patrol/PatrolIntelligenceWorkspace.tsx b/frontend-modern/src/features/patrol/PatrolIntelligenceWorkspace.tsx index a4756f09a..2f9067712 100644 --- a/frontend-modern/src/features/patrol/PatrolIntelligenceWorkspace.tsx +++ b/frontend-modern/src/features/patrol/PatrolIntelligenceWorkspace.tsx @@ -7,6 +7,7 @@ import { getFindingTitlePresentation, buildPatrolFindingDisplayGroups, getPatrolFindingsBadgePresentation, + getPatrolWorkTypeComposition, isPatrolRuntimeFinding, } from '@/utils/aiFindingPresentation'; import { formatRelativeTime } from '@/utils/format'; @@ -50,6 +51,9 @@ export function PatrolIntelligenceWorkspace(props: { state: PatrolIntelligenceSt () => state.findingsTabBadgeCount() ?? state.findingsTabBadgeFindings().length, ); const queueAffectedResourceCount = createMemo(() => queueDisplayGroups().length); + const workTypeComposition = createMemo(() => + getPatrolWorkTypeComposition(state.findingsTabBadgeFindings()), + ); const queueBadgeLabel = createMemo(() => getPatrolQueueBadgeLabel({ affectedResourceCount: queueAffectedResourceCount(), @@ -90,6 +94,7 @@ export function PatrolIntelligenceWorkspace(props: { state: PatrolIntelligenceSt autonomyLocked: state.autoFixLocked(), affectedResourceCount: queueAffectedResourceCount(), findingCount: queueIssueCount(), + workTypeComposition: workTypeComposition(), }); const openHistory = () => { state.setActiveTab('history'); diff --git a/frontend-modern/src/features/patrol/__tests__/patrolControlPresentation.test.ts b/frontend-modern/src/features/patrol/__tests__/patrolControlPresentation.test.ts index d5a35ba64..b8cf8ee37 100644 --- a/frontend-modern/src/features/patrol/__tests__/patrolControlPresentation.test.ts +++ b/frontend-modern/src/features/patrol/__tests__/patrolControlPresentation.test.ts @@ -130,6 +130,46 @@ describe('patrolControlPresentation', () => { ); }); + it('includes work-type composition in the description when notable types exist', () => { + expect( + getPatrolQueueWorkspaceDescription({ + autonomyLevel: 'monitor', + findingCount: 3, + affectedResourceCount: 2, + workTypeComposition: { + total: 3, + approval: 1, + failed: 0, + inProgress: 0, + recurring: 1, + newIssues: 1, + }, + }), + ).toBe( + 'Patrol found 3 issues on 2 affected resources — 1 needs approval, 1 recurring. Open a row to review evidence and record the outcome.', + ); + }); + + it('omits the composition clause when all findings are new', () => { + expect( + getPatrolQueueWorkspaceDescription({ + autonomyLevel: 'monitor', + findingCount: 2, + affectedResourceCount: 2, + workTypeComposition: { + total: 2, + approval: 0, + failed: 0, + inProgress: 0, + recurring: 0, + newIssues: 2, + }, + }), + ).toBe( + 'Patrol found 2 issues on 2 affected resources. Open a row to review evidence and record the outcome.', + ); + }); + it('keeps setup-only issue reasons short and actionable', () => { expect( getPatrolSetupIssueReason({ diff --git a/frontend-modern/src/features/patrol/patrolControlPresentation.ts b/frontend-modern/src/features/patrol/patrolControlPresentation.ts index dd15040b9..8fe1f8279 100644 --- a/frontend-modern/src/features/patrol/patrolControlPresentation.ts +++ b/frontend-modern/src/features/patrol/patrolControlPresentation.ts @@ -1,5 +1,9 @@ import type { PatrolAutonomyLevel } from '@/api/patrol'; -import { getPatrolFindingIssueCountLabel } from '@/utils/aiFindingPresentation'; +import { + getPatrolFindingIssueCountLabel, + getPatrolWorkTypeCompositionClause, +} from '@/utils/aiFindingPresentation'; +import type { PatrolWorkTypeComposition } from '@/utils/aiFindingPresentation'; import type { UpgradeDestination } from '@/utils/upgradeNavigation'; export const PATROL_AUTONOMY_POLICY_PRESENTATION: Record< @@ -50,6 +54,7 @@ interface PatrolControlCopyInput { interface PatrolQueueCountInput { affectedResourceCount?: number; findingCount?: number; + workTypeComposition?: PatrolWorkTypeComposition; } interface PatrolSetupIssueReasonInput { @@ -146,10 +151,13 @@ export function getPatrolQueueWorkspaceDescription( const findingCount = normalizeCount(input.findingCount); const affectedResourceCount = normalizeCount(input.affectedResourceCount); if (findingCount > 0 && affectedResourceCount > 0) { + const compositionClause = input.workTypeComposition + ? getPatrolWorkTypeCompositionClause(input.workTypeComposition) + : ''; return `Patrol found ${getPatrolFindingIssueCountLabel(findingCount)} on ${formatCount( affectedResourceCount, 'affected resource', - )}. ${getPatrolQueueActionDetail(input)}`; + )}${compositionClause}. ${getPatrolQueueActionDetail(input)}`; } if (input.autonomyLocked) { diff --git a/frontend-modern/src/utils/__tests__/aiFindingWorkType.test.ts b/frontend-modern/src/utils/__tests__/aiFindingWorkType.test.ts new file mode 100644 index 000000000..ce398cb63 --- /dev/null +++ b/frontend-modern/src/utils/__tests__/aiFindingWorkType.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from 'vitest'; + +import { + classifyPatrolFindingWorkType, + getPatrolWorkTypeComposition, + getPatrolWorkTypeCompositionClause, +} from '@/utils/aiFindingPresentation'; +import type { UnifiedFinding } from '@/stores/aiIntelligence'; + +type ClassifyInput = Parameters[0]; + +function makeFinding(overrides: Partial = {}): ClassifyInput { + return { + status: 'active', + investigationStatus: undefined, + investigationOutcome: undefined, + regressionCount: undefined, + timesRaised: undefined, + ...overrides, + }; +} + +describe('classifyPatrolFindingWorkType', () => { + it('classifies a plain active finding as new', () => { + expect(classifyPatrolFindingWorkType(makeFinding())).toBe('new'); + }); + + it('classifies fix_queued as approval', () => { + expect( + classifyPatrolFindingWorkType(makeFinding({ investigationOutcome: 'fix_queued' })), + ).toBe('approval'); + }); + + it.each([ + 'fix_failed', + 'fix_verification_failed', + 'cannot_fix', + 'timed_out', + ] as const)('classifies %s as failed', (outcome) => { + expect(classifyPatrolFindingWorkType(makeFinding({ investigationOutcome: outcome }))).toBe( + 'failed', + ); + }); + + it('classifies investigation running as in_progress', () => { + expect( + classifyPatrolFindingWorkType(makeFinding({ investigationStatus: 'running' })), + ).toBe('in_progress'); + }); + + it('classifies fix_executed as in_progress (verification pending)', () => { + expect( + classifyPatrolFindingWorkType(makeFinding({ investigationOutcome: 'fix_executed' })), + ).toBe('in_progress'); + }); + + it('classifies regression as recurring', () => { + expect( + classifyPatrolFindingWorkType(makeFinding({ regressionCount: 2 })), + ).toBe('recurring'); + }); + + it('classifies multiple raises as recurring', () => { + expect(classifyPatrolFindingWorkType(makeFinding({ timesRaised: 3 }))).toBe('recurring'); + }); + + it('does not classify a first-raise finding as recurring', () => { + expect(classifyPatrolFindingWorkType(makeFinding({ timesRaised: 1 }))).toBe('new'); + }); + + it('treats non-active findings as new regardless of investigation state', () => { + expect( + classifyPatrolFindingWorkType( + makeFinding({ status: 'resolved', investigationOutcome: 'fix_queued' }), + ), + ).toBe('new'); + }); + + it('prioritises approval over failed when both conditions could apply', () => { + expect( + classifyPatrolFindingWorkType( + makeFinding({ investigationOutcome: 'fix_queued', regressionCount: 5 }), + ), + ).toBe('approval'); + }); + + it('prioritises failed over recurring', () => { + expect( + classifyPatrolFindingWorkType( + makeFinding({ investigationOutcome: 'fix_failed', regressionCount: 5 }), + ), + ).toBe('failed'); + }); +}); + +describe('getPatrolWorkTypeComposition', () => { + it('returns zero counts for an empty list', () => { + expect(getPatrolWorkTypeComposition([])).toEqual({ + total: 0, + approval: 0, + failed: 0, + inProgress: 0, + recurring: 0, + newIssues: 0, + }); + }); + + it('classifies and counts a mixed set of findings', () => { + const findings: ClassifyInput[] = [ + makeFinding({ investigationOutcome: 'fix_queued' }), + makeFinding({ investigationOutcome: 'fix_failed' }), + makeFinding({ investigationStatus: 'running' }), + makeFinding({ regressionCount: 1 }), + makeFinding({}), + makeFinding({}), + ]; + expect(getPatrolWorkTypeComposition(findings)).toEqual({ + total: 6, + approval: 1, + failed: 1, + inProgress: 1, + recurring: 1, + newIssues: 2, + }); + }); +}); + +describe('getPatrolWorkTypeCompositionClause', () => { + it('returns empty string when all findings are new', () => { + expect( + getPatrolWorkTypeCompositionClause({ + total: 2, + approval: 0, + failed: 0, + inProgress: 0, + recurring: 0, + newIssues: 2, + }), + ).toBe(''); + }); + + it('returns a single-type clause', () => { + expect( + getPatrolWorkTypeCompositionClause({ + total: 3, + approval: 1, + failed: 0, + inProgress: 0, + recurring: 0, + newIssues: 2, + }), + ).toBe(' — 1 needs approval'); + }); + + it('pluralises correctly', () => { + expect( + getPatrolWorkTypeCompositionClause({ + total: 4, + approval: 0, + failed: 2, + inProgress: 0, + recurring: 0, + newIssues: 2, + }), + ).toBe(' — 2 failed fixes'); + }); + + it('joins multiple notable types in priority order', () => { + expect( + getPatrolWorkTypeCompositionClause({ + total: 5, + approval: 1, + failed: 1, + inProgress: 0, + recurring: 2, + newIssues: 1, + }), + ).toBe(' — 1 needs approval, 1 failed fix, 2 recurring'); + }); +}); diff --git a/frontend-modern/src/utils/aiFindingPresentation.ts b/frontend-modern/src/utils/aiFindingPresentation.ts index a98c32158..2a372ef08 100644 --- a/frontend-modern/src/utils/aiFindingPresentation.ts +++ b/frontend-modern/src/utils/aiFindingPresentation.ts @@ -559,6 +559,105 @@ export function buildPatrolFindingDisplayGroups< return groups; } +export type PatrolFindingWorkType = 'approval' | 'failed' | 'in_progress' | 'recurring' | 'new'; + +const isFailedFixOutcome = (outcome: string | undefined): boolean => + outcome === 'fix_failed' || + outcome === 'fix_verification_failed' || + outcome === 'cannot_fix' || + outcome === 'timed_out'; + +export function classifyPatrolFindingWorkType( + finding: Pick< + UnifiedFinding, + 'status' | 'investigationStatus' | 'investigationOutcome' | 'regressionCount' | 'timesRaised' + >, +): PatrolFindingWorkType { + if (finding.status !== 'active') return 'new'; + + if (finding.investigationOutcome === 'fix_queued') return 'approval'; + + if (isFailedFixOutcome(finding.investigationOutcome)) return 'failed'; + + if ( + finding.investigationStatus === 'running' || + finding.investigationOutcome === 'fix_executed' + ) { + return 'in_progress'; + } + + if ((finding.regressionCount ?? 0) > 0 || (finding.timesRaised ?? 0) > 1) { + return 'recurring'; + } + + return 'new'; +} + +export interface PatrolWorkTypeComposition { + total: number; + approval: number; + failed: number; + inProgress: number; + recurring: number; + newIssues: number; +} + +export function getPatrolWorkTypeComposition< + TFinding extends Pick< + UnifiedFinding, + 'status' | 'investigationStatus' | 'investigationOutcome' | 'regressionCount' | 'timesRaised' + >, +>(findings: readonly TFinding[]): PatrolWorkTypeComposition { + const composition: PatrolWorkTypeComposition = { + total: findings.length, + approval: 0, + failed: 0, + inProgress: 0, + recurring: 0, + newIssues: 0, + }; + for (const finding of findings) { + switch (classifyPatrolFindingWorkType(finding)) { + case 'approval': + composition.approval++; + break; + case 'failed': + composition.failed++; + break; + case 'in_progress': + composition.inProgress++; + break; + case 'recurring': + composition.recurring++; + break; + case 'new': + composition.newIssues++; + break; + } + } + return composition; +} + +export function getPatrolWorkTypeCompositionClause( + composition: PatrolWorkTypeComposition, +): string { + const parts: string[] = []; + if (composition.approval > 0) { + parts.push(`${composition.approval} need${composition.approval === 1 ? 's' : ''} approval`); + } + if (composition.failed > 0) { + parts.push(`${composition.failed} failed fix${composition.failed === 1 ? '' : 'es'}`); + } + if (composition.inProgress > 0) { + parts.push(`${composition.inProgress} in progress`); + } + if (composition.recurring > 0) { + parts.push(`${composition.recurring} recurring`); + } + if (parts.length === 0) return ''; + return ` — ${parts.join(', ')}`; +} + export const getPatrolFindingIssueCountLabel = (count: number): string => { const normalized = Number.isFinite(count) ? Math.max(0, Math.trunc(count)) : 0; if (normalized === 1) {