mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
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.
This commit is contained in:
@@ -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": [
|
||||
|
||||
@@ -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()');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<typeof classifyPatrolFindingWorkType>[0];
|
||||
|
||||
function makeFinding(overrides: Partial<ClassifyInput> = {}): 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');
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user