mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-26 13:39:47 +00:00
Show unclassified and interrupted Patrol investigation outcomes (#3132)
Since rcourtman/Pulse#3129, Patrol records two more investigation
outcomes:
- `unclassified`: a run that finished without a readable conclusion. It
stays retry-eligible and presents as needs-attention in the loop state.
- `interrupted`: a run that a restart or shutdown cut short. It has no
cooldown and runs again.
The frontend's outcome lists did not know either value, so the findings
store dropped both to "no outcome".
## What changes
- `stores/aiIntelligence.ts` keys the outcomes it accepts by the
`InvestigationOutcome` union in `api/patrol.ts`. `api/ai.ts` now reuses
that union, so the next backend outcome fails type-check instead of
being dropped. The import is type-only because several tests mock
`@/api/patrol` wholesale.
- Every outcome map in `aiFindingPresentation.ts` gains both values:
- `unclassified` reads "No clear conclusion" in the warning tone, joins
the Needs Attention queue and sorts beside `needs_attention`. Its
workflow step asks the operator to read the summary.
- `interrupted` reads "Interrupted — will retry" in the neutral tone and
stays out of the attention queue. Its collapsed row says "Will retry"
instead of "Needs review".
- Neither counts as a failed fix.
- `InvestigationSection` offers Re-investigate after both outcomes. An
interrupted run's note renders as information, not in the error style.
- The This week card names both outcomes with the digest email's wording
and order (`PatrolDigestOutcomeOrder`, `patrol_digest_email.go`).
- Two drawer defects the new labels exposed are fixed:
- The Patrol record showed a red "Failed" run status beside "will
retry". Like the investigation header, it now shows the run status only
until the run has an outcome.
- The record badge used `formatIdentifierLabel` ("Unclassified", "Fix
Executed", "Cannot Fix"). It now uses the shared outcome labels that the
header uses.
- On phones the longer badge broke "Re-investigate" across two lines.
The header row now wraps.
Telemetry is unchanged: the two outcomes already map to `in_progress`
and `other`.
A second commit lines the This week card up with the digest-count
backend change:
- The card names the digest's new `failed` bucket ("ended with an
error"), for a run that ended on an error with no outcome. It sits right
after timeouts, as in `PatrolDigestOutcomeOrder` and the email.
- When none of a week's investigations has a worded outcome, the
Investigated tile shows the count alone. It used to say "No issues
needed a closer look" under a non-zero count. The email already does
this.
- That commit has its own receipt (`196f770aad8a3198.json`) from a pass
over live and fulfilled digest responses at 1440 and 390.
## Before and after
Private Pulse Pro stack: pulse-enterprise#139 built against this tree on
`ec40662af4`, with a mock estate and seeded findings, investigation
sessions and a week of runs.
| Surface | main | this branch |
| --- | --- | --- |
| This week, Investigated tile | 5, "No issues needed a closer look." |
5, "3 finished without a clear conclusion, 2 interrupted by a restart."
|
| Needs Attention filter | 1 | 4 (three unclassified, one
needs_attention) |
| Interrupted finding row | Needs review | Will retry |
| Unclassified drawer | raw `unclassified`, no Re-investigate, green
"Completed" record badge | "No clear conclusion", Re-investigate |
| Interrupted drawer | raw `interrupted`, red "Failed" record badge, red
restart note | "Interrupted — will retry" (neutral), muted restart note
|
| 390px drawer header | Re-investigate splits over two lines | the row
wraps; the button stays whole |
## Verification
- Vitest on pulse-dev:
- The 9 touched test files pass, 447/447.
- The wider api/stores/utils/AI/patrol/pages/shared run passed 8,858
tests. The one failure was `agentInstallCommand.test.ts`, caused by the
script's umask; it passes 90/90 with the default umask.
- Negative control: 26 of the new assertions fail against main's
sources.
- The Frontend CI job was replayed on pulse-dev:
- Format check, headers, cpd, type-check, build and bundle size passed.
- The lint chain caught a semicolon in the new "Read summary" copy.
After the fix every audit passed.
- The full suite passed 22,156 tests. Three docs-reading files timed out
at 5 s under load (`shippedDocFragments`, `operationalUpgradeHelp`,
`externalAgentGuide`); they pass alone, 8/8.
- Browser receipt
`frontend-modern/browser-verification/f1f2d9ca0f32c251.json`: headless
Chromium at 1440 (dark and light) and 390 on the final bytes, exercising
the This week card, the finding list and the review drawers of an
unclassified, an interrupted and a needs_attention finding.
- Governance: the per-commit canonical completion guard (with the
Contract-Neutral trailer), the receipt range check, and the contract and
registry audits all pass.
The presentation is documented in `patrol-intelligence.md`. ai-runtime
and api-contracts already describe both outcomes from #3129, so the
commit carries a Contract-Neutral trailer for them and for
frontend-primitives.
This commit is contained in:
@@ -2765,6 +2765,36 @@ Issue explanation follows the [customer-journey qualification boundary](../../..
|
||||
which separates scripted interaction proof from provider reasoning, remediation
|
||||
and repeatable customer outcomes.
|
||||
|
||||
### Unclassified and interrupted investigations (23 October 2026)
|
||||
|
||||
The Patrol page presents the two outcomes the backend records since
|
||||
rcourtman/Pulse#3129 instead of dropping them as unknown.
|
||||
`frontend-modern/src/stores/aiIntelligence.ts` keys the outcomes it accepts by
|
||||
the `InvestigationOutcome` union in `frontend-modern/src/api/patrol.ts`, which
|
||||
`frontend-modern/src/api/ai.ts` now reuses, so a value the union gains fails
|
||||
type-check until the store accepts it.
|
||||
|
||||
`unclassified`, a finished run with no readable conclusion, reads "No clear
|
||||
conclusion" in the warning tone. It joins the Needs Attention queue and sorts
|
||||
beside `needs_attention`, keeps Re-investigate, and its workflow step asks the
|
||||
operator to read the summary. `interrupted`, a run a restart or shutdown cut
|
||||
short, reads "Interrupted — will retry" in the neutral tone. It stays out of
|
||||
the attention queue, its collapsed row says "Will retry" rather than "Needs
|
||||
review", and its run note renders as information, not as an error. Neither
|
||||
counts as a failed fix. The This week card names both with the digest email's
|
||||
wording and order, along with the digest's `failed` bucket for a run that
|
||||
ended on an error with no outcome ("ended with an error"). When a week's
|
||||
investigations have no worded outcome, the Investigated tile shows the count
|
||||
alone: "No issues needed a closer look" and the watch-only sentence are kept
|
||||
for a week with no investigations. The Patrol record's outcome badge uses the
|
||||
shared outcome label, and like the investigation header the record shows its
|
||||
run status only until the run has an outcome, so an interrupted run no longer
|
||||
shows "Failed" beside "will retry". Proofs:
|
||||
`frontend-modern/src/stores/__tests__/aiIntelligence.test.ts`,
|
||||
`frontend-modern/src/components/patrol/__tests__/InvestigationSection.test.tsx`,
|
||||
`frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts`
|
||||
and `frontend-modern/src/features/patrol/__tests__/PatrolWeeklyDigestCard.test.tsx`.
|
||||
|
||||
### Expired action continuity (2026-09-07)
|
||||
|
||||
An expired, unexecuted canonical action maps the investigation outcome to
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "5c7d2c6e677575d2894cda5147406957708767ce",
|
||||
"verified_at": "2026-10-23T10:15:43Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/features/patrol/PatrolWeeklyDigestCard.tsx"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/features/patrol/PatrolWeeklyDigestCard.tsx": "47d96594cd9597c299db948e9c057b55943bde65f4d714027c3446635b8aa7b0"
|
||||
},
|
||||
"routes": [
|
||||
"/patrol/activity"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 1000
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Private Pulse Pro stack (enterprise binary from pulse-enterprise#139 on this tree, Vite dev server of this change, seeded Patrol findings and runs); for the scenarios other than live, Playwright fulfilled the digest request with the live response and a replaced investigations block, the shape the digest-count backend change emits",
|
||||
"Live digest (3 unclassified, 2 interrupted): the Investigated tile reads 5 with \"3 finished without a clear conclusion,\" and \"2 interrupted by a restart.\"",
|
||||
"failed bucket beside unclassified and interrupted runs (total 6): \"2 ended with an error,\" then \"3 finished without a clear conclusion.\"",
|
||||
"failed after timeouts (total 3): \"2 timed out,\" then \"1 ended with an error.\"",
|
||||
"Only an outcome the card has no wording for (total 4): the tile shows 4 with no detail line, instead of \"No issues needed a closer look.\"",
|
||||
"Empty week (total 0): \"No issues needed a closer look.\" as before",
|
||||
"1440 dark and light and 390 dark for every scenario; no page or console errors"
|
||||
],
|
||||
"interactions": [
|
||||
"Opened the Patrol Activity tab for each scenario and viewport and read the This week card"
|
||||
],
|
||||
"browser": {
|
||||
"client": "playwright 1.62.1",
|
||||
"engine": "Chromium",
|
||||
"version": "151.0.7922.34 headless"
|
||||
},
|
||||
"scope": "Pulse Pro binary on a mock estate with seeded Patrol state; four of the five digest scenarios are fulfilled responses, not backend output."
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "ec40662af4ee76357db34ba9e2948aabce5de139",
|
||||
"verified_at": "2026-10-23T10:00:36Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/api/ai.ts",
|
||||
"frontend-modern/src/api/patrol.ts",
|
||||
"frontend-modern/src/components/patrol/InvestigationSection.tsx",
|
||||
"frontend-modern/src/features/patrol/PatrolWeeklyDigestCard.tsx",
|
||||
"frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts",
|
||||
"frontend-modern/src/stores/aiIntelligence.ts",
|
||||
"frontend-modern/src/utils/aiFindingPresentation.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/api/ai.ts": "f4dcc21f7c5f3beebd018b79497f8b28381dac0aa6fd77cee9228e6d80bc1482",
|
||||
"frontend-modern/src/api/patrol.ts": "7db97b440d0ce58b3b7c083e5ccc3f9b66cbd11c94c4bde3afe100909f252bbe",
|
||||
"frontend-modern/src/components/patrol/InvestigationSection.tsx": "4131510e8c04fc77f30b0e087ece959aa0e9eb7c4aaaf57379c9ae8dd44dbb47",
|
||||
"frontend-modern/src/features/patrol/PatrolWeeklyDigestCard.tsx": "4a052efde6be8533ba8d536981583b6b73054d4cfc3366ccc3481978af9ba0b5",
|
||||
"frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts": "695c66d153bd71b4a96ca4d12566442992cdf0a7bf11f40202a1e09b471cd34d",
|
||||
"frontend-modern/src/stores/aiIntelligence.ts": "3e1bd22947b5cec496c5a95c1f1716827ebf035e2de72a590763fda967bacde7",
|
||||
"frontend-modern/src/utils/aiFindingPresentation.ts": "cce327e538a9f47a9818af21858ed99d7471a8a5221d19fc1ab1efa844b1ac82"
|
||||
},
|
||||
"routes": [
|
||||
"/patrol/activity"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 1000
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Private Pulse Pro stack: the enterprise binary from pulse-enterprise#139 built against this tree on main ec40662af4, Vite dev server of this change, small mock estate, with seeded Patrol findings, orchestrator investigation sessions and a week of runs (three unclassified runs, two interrupted runs, one needs_attention run outside the digest window); compared with main's frontend sources on the same backend",
|
||||
"This week card at 1440 dark and light and 390 dark: the Investigated tile reads 5 with \"3 finished without a clear conclusion,\" and \"2 interrupted by a restart.\" (main: \"No issues needed a closer look.\")",
|
||||
"Open work list: Needs Attention (4) counts the three unclassified runs and the needs_attention run (main counted 1); unclassified rows show Needs review and interrupted rows show Will retry (main: Needs review for both)",
|
||||
"Review drawer for the unclassified run: the investigation badge reads \"No clear conclusion\" in the warning tone with Re-investigate (main: the raw key \"unclassified\" and no Re-investigate); the Patrol record shows \"No clear conclusion\" and Low confidence without a Completed status badge; the run summary renders below",
|
||||
"Review drawer for the interrupted run: investigation and record badges read \"Interrupted — will retry\" in the neutral tone, no red Failed status badge, and the restart note renders in the muted information style (main: raw \"interrupted\", a red Failed badge and the note in red)",
|
||||
"Review drawer for the needs_attention run: the record badge reads \"Needs attention\" from the shared labels (main: \"Needs Attention\")",
|
||||
"390px drawers: the investigation header wraps, so Re-investigate keeps its label on one line instead of breaking across two",
|
||||
"No page errors in the headless runs and no console errors in the app's browser pane"
|
||||
],
|
||||
"interactions": [
|
||||
"Opened the Activity tab and expanded Finding options and history",
|
||||
"Opened and closed the review drawers of the unclassified, interrupted and needs_attention findings at each viewport",
|
||||
"Scrolled the finding list and the open drawer"
|
||||
],
|
||||
"browser": {
|
||||
"client": "playwright 1.62.1",
|
||||
"engine": "Chromium",
|
||||
"version": "151.0.7922.34 headless"
|
||||
},
|
||||
"scope": "Pulse Pro binary on a mock estate with seeded Patrol findings and investigation sessions; mock data, not live provider connections."
|
||||
}
|
||||
@@ -103,6 +103,14 @@ describe('patrol utilities', () => {
|
||||
expect(investigationOutcomeLabels.timed_out).toBe('Timed Out — Will Retry');
|
||||
});
|
||||
|
||||
it('has correct label for unclassified', () => {
|
||||
expect(investigationOutcomeLabels.unclassified).toBe('No Clear Conclusion');
|
||||
});
|
||||
|
||||
it('has correct label for interrupted', () => {
|
||||
expect(investigationOutcomeLabels.interrupted).toBe('Interrupted — Will Retry');
|
||||
});
|
||||
|
||||
it('has correct label for fix_verified', () => {
|
||||
expect(investigationOutcomeLabels.fix_verified).toBe('Fix Verified');
|
||||
});
|
||||
@@ -140,6 +148,16 @@ describe('patrol utilities', () => {
|
||||
it('has slate colors for cannot_fix', () => {
|
||||
expect(investigationOutcomeColors.cannot_fix).toContain('slate');
|
||||
});
|
||||
|
||||
it('has amber colors for unclassified, which still needs a read', () => {
|
||||
expect(investigationOutcomeColors.unclassified).toContain('amber');
|
||||
});
|
||||
|
||||
it('has neutral colors for interrupted, which Patrol retries on its own', () => {
|
||||
expect(investigationOutcomeColors.interrupted).toBe(
|
||||
'border-border bg-surface-alt text-base-content',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('severityColors', () => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from './responseUtils';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { consumeJSONEventStream } from './streaming';
|
||||
import type { InvestigationOutcome } from './patrol';
|
||||
import type {
|
||||
AISettings,
|
||||
AISettingsUpdateRequest,
|
||||
@@ -521,18 +522,6 @@ export type ApprovalDecisionResult =
|
||||
|
||||
export type InvestigationStatus =
|
||||
'pending' | 'running' | 'completed' | 'failed' | 'needs_attention';
|
||||
export type InvestigationOutcome =
|
||||
| 'resolved'
|
||||
| 'fix_queued'
|
||||
| 'fix_executed'
|
||||
| 'fix_failed'
|
||||
| 'fix_rejected'
|
||||
| 'needs_attention'
|
||||
| 'cannot_fix'
|
||||
| 'timed_out'
|
||||
| 'fix_verified'
|
||||
| 'fix_verification_failed'
|
||||
| 'fix_verification_unknown';
|
||||
|
||||
export interface ProposedFix {
|
||||
id: string;
|
||||
|
||||
@@ -107,6 +107,9 @@ export interface CapacityForecast {
|
||||
|
||||
export type InvestigationStatus =
|
||||
'pending' | 'running' | 'completed' | 'failed' | 'needs_attention';
|
||||
// Every outcome the backend records (InvestigationOutcome in
|
||||
// internal/ai/findings.go). The findings store keys its accepted values by
|
||||
// this union, so a new outcome must be added here first.
|
||||
export type InvestigationOutcome =
|
||||
| 'resolved'
|
||||
| 'fix_queued'
|
||||
@@ -116,6 +119,8 @@ export type InvestigationOutcome =
|
||||
| 'needs_attention'
|
||||
| 'cannot_fix'
|
||||
| 'timed_out'
|
||||
| 'unclassified'
|
||||
| 'interrupted'
|
||||
| 'fix_verified'
|
||||
| 'fix_verification_failed'
|
||||
| 'fix_verification_unknown';
|
||||
@@ -794,6 +799,8 @@ export const investigationOutcomeLabels: Record<InvestigationOutcome, string> =
|
||||
needs_attention: 'Needs Attention',
|
||||
cannot_fix: 'Cannot Remediate',
|
||||
timed_out: 'Timed Out — Will Retry',
|
||||
unclassified: 'No Clear Conclusion',
|
||||
interrupted: 'Interrupted — Will Retry',
|
||||
fix_verified: 'Fix Verified',
|
||||
fix_verification_failed: 'Verification Failed',
|
||||
fix_verification_unknown: 'Verification Inconclusive',
|
||||
@@ -818,6 +825,9 @@ export const investigationOutcomeColors: Record<InvestigationOutcome, string> =
|
||||
cannot_fix: 'border-slate-200 bg-slate-50 text-slate-600',
|
||||
timed_out:
|
||||
'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-800 dark:bg-amber-900/25 dark:text-amber-300',
|
||||
unclassified:
|
||||
'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-800 dark:bg-amber-900/25 dark:text-amber-300',
|
||||
interrupted: 'border-border bg-surface-alt text-base-content',
|
||||
fix_verified:
|
||||
'border-green-200 bg-green-50 text-green-700 dark:border-green-800 dark:bg-green-900/25 dark:text-green-300',
|
||||
fix_verification_failed:
|
||||
|
||||
@@ -44,6 +44,13 @@ const INVESTIGATION_BADGE_PROPS = {
|
||||
const summaryClass =
|
||||
'text-sm prose prose-slate prose-sm dark:prose-invert max-w-none wrap-break-word prose-headings:my-2 prose-p:my-2 prose-pre:overflow-x-auto prose-code:break-all prose-code:before:content-none prose-code:after:content-none';
|
||||
|
||||
// A run cut short by a restart did not fail: Patrol runs it again, so its note
|
||||
// reads as information rather than as an error.
|
||||
const runNoteClass = (outcome: string | undefined): string =>
|
||||
outcome === 'interrupted'
|
||||
? 'border-border-subtle bg-surface text-muted'
|
||||
: 'border-red-200 bg-red-50 text-red-700 dark:border-red-800 dark:bg-red-900/25 dark:text-red-300';
|
||||
|
||||
interface InvestigationSectionProps {
|
||||
findingId: string;
|
||||
investigationStatus?: string;
|
||||
@@ -100,6 +107,8 @@ export const InvestigationSection: Component<InvestigationSectionProps> = (props
|
||||
inv.status === 'needs_attention' ||
|
||||
inv.outcome === 'cannot_fix' ||
|
||||
inv.outcome === 'timed_out' ||
|
||||
inv.outcome === 'unclassified' ||
|
||||
inv.outcome === 'interrupted' ||
|
||||
inv.outcome === 'fix_rejected' ||
|
||||
inv.outcome === 'fix_verification_failed' ||
|
||||
inv.outcome === 'fix_verification_unknown' ||
|
||||
@@ -125,8 +134,8 @@ export const InvestigationSection: Component<InvestigationSectionProps> = (props
|
||||
|
||||
return (
|
||||
<div class="mt-3 pt-3 border-t border-border-subtle">
|
||||
{/* Header */}
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
{/* Header; wraps so a long outcome label never squeezes the action */}
|
||||
<div class="flex flex-wrap items-center justify-between gap-x-2 gap-y-1 mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-base-content">Investigation</span>
|
||||
{/* Show outcome badge when available, otherwise show status badge */}
|
||||
@@ -159,7 +168,7 @@ export const InvestigationSection: Component<InvestigationSectionProps> = (props
|
||||
type="button"
|
||||
onClick={handleReinvestigate}
|
||||
disabled={reinvestigating()}
|
||||
class="flex items-center gap-1 px-2 py-1 text-xs font-medium text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/25 rounded-sm transition-colors disabled:opacity-50"
|
||||
class="flex items-center gap-1 whitespace-nowrap px-2 py-1 text-xs font-medium text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/25 rounded-sm transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RefreshCwIcon class={`w-3 h-3 ${reinvestigating() ? 'animate-spin' : ''}`} />
|
||||
Re-investigate
|
||||
@@ -187,13 +196,19 @@ export const InvestigationSection: Component<InvestigationSectionProps> = (props
|
||||
<div class="mb-2 rounded-sm border border-border bg-surface-alt p-2">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="text-xs font-medium text-base-content">Patrol record</span>
|
||||
<MetadataBadge
|
||||
{...INVESTIGATION_BADGE_PROPS}
|
||||
tone={getInvestigationStatusBadgeTone(props.investigationRecord!.status)}
|
||||
{/* As in the header, the outcome replaces the run status once there is
|
||||
one: an interrupted run's status is failed, which it is not. */}
|
||||
<Show
|
||||
when={investigationRecord().outcomeLabel}
|
||||
fallback={
|
||||
<MetadataBadge
|
||||
{...INVESTIGATION_BADGE_PROPS}
|
||||
tone={getInvestigationStatusBadgeTone(props.investigationRecord!.status)}
|
||||
>
|
||||
{investigationRecord().statusLabel}
|
||||
</MetadataBadge>
|
||||
}
|
||||
>
|
||||
{investigationRecord().statusLabel}
|
||||
</MetadataBadge>
|
||||
<Show when={investigationRecord().outcomeLabel}>
|
||||
<MetadataBadge
|
||||
{...INVESTIGATION_BADGE_PROPS}
|
||||
tone={getInvestigationOutcomeBadgeTone(props.investigationRecord!.outcome ?? '')}
|
||||
@@ -318,7 +333,9 @@ export const InvestigationSection: Component<InvestigationSectionProps> = (props
|
||||
</Show>
|
||||
|
||||
<Show when={investigationRecord().error}>
|
||||
<div class="mt-2 rounded-sm border border-red-200 bg-red-50 p-2 text-xs text-red-700 dark:border-red-800 dark:bg-red-900/25 dark:text-red-300">
|
||||
<div
|
||||
class={`mt-2 rounded-sm border p-2 text-xs ${runNoteClass(props.investigationRecord?.outcome)}`}
|
||||
>
|
||||
{investigationRecord().error}
|
||||
</div>
|
||||
</Show>
|
||||
@@ -336,6 +353,8 @@ export const InvestigationSection: Component<InvestigationSectionProps> = (props
|
||||
inv().error?.trim() !== investigationRecord().error &&
|
||||
(inv().status === 'failed' ||
|
||||
inv().outcome === 'timed_out' ||
|
||||
inv().outcome === 'interrupted' ||
|
||||
inv().outcome === 'unclassified' ||
|
||||
inv().outcome === 'fix_failed' ||
|
||||
inv().outcome === 'fix_rejected' ||
|
||||
inv().outcome === 'fix_verification_failed' ||
|
||||
@@ -344,7 +363,7 @@ export const InvestigationSection: Component<InvestigationSectionProps> = (props
|
||||
inv().outcome === 'cannot_fix')
|
||||
}
|
||||
>
|
||||
<div class="text-xs text-red-700 dark:text-red-300 bg-red-50 dark:bg-red-900/25 border border-red-200 dark:border-red-800 rounded-sm p-2">
|
||||
<div class={`rounded-sm border p-2 text-xs ${runNoteClass(inv().outcome)}`}>
|
||||
{inv().error}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
@@ -93,6 +93,95 @@ describe('InvestigationSection', () => {
|
||||
expect(outcomeBadge.className).toContain('border-amber-200');
|
||||
});
|
||||
|
||||
it('offers another run after a run that ended without a clear conclusion', async () => {
|
||||
getInvestigationMock.mockResolvedValue({
|
||||
id: 'inv-unclassified',
|
||||
finding_id: 'finding-unclassified',
|
||||
session_id: 'session-unclassified',
|
||||
status: 'completed',
|
||||
outcome: 'unclassified',
|
||||
started_at: '2026-10-22T10:00:00.000Z',
|
||||
turn_count: 6,
|
||||
summary: 'The service restarted twice; the logs do not say why.',
|
||||
} satisfies Investigation);
|
||||
|
||||
render(() => (
|
||||
<InvestigationSection findingId="finding-unclassified" investigationStatus="completed" />
|
||||
));
|
||||
|
||||
const outcomeBadge = await screen.findByText('No clear conclusion');
|
||||
expect(outcomeBadge.className).toContain('border-amber-200');
|
||||
expect(screen.getByText('The service restarted twice; the logs do not say why.')).toBeVisible();
|
||||
expect(screen.getByRole('button', { name: 'Re-investigate' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('presents a run cut short by a restart as information, not as an error', async () => {
|
||||
const note = 'Interrupted: Pulse restarted before this investigation finished.';
|
||||
getInvestigationMock.mockResolvedValue({
|
||||
id: 'inv-interrupted',
|
||||
finding_id: 'finding-interrupted',
|
||||
session_id: 'session-interrupted',
|
||||
status: 'failed',
|
||||
outcome: 'interrupted',
|
||||
started_at: '2026-10-22T10:00:00.000Z',
|
||||
turn_count: 2,
|
||||
error: note,
|
||||
} satisfies Investigation);
|
||||
|
||||
render(() => (
|
||||
<InvestigationSection
|
||||
findingId="finding-interrupted"
|
||||
investigationStatus="failed"
|
||||
investigationRecord={{
|
||||
id: 'record-interrupted',
|
||||
finding_id: 'finding-interrupted',
|
||||
subject: { resource_id: 'vm-102' },
|
||||
trigger: { title: 'Service keeps restarting', detected_at: '2026-10-22T10:00:00Z' },
|
||||
status: 'failed',
|
||||
outcome: 'interrupted',
|
||||
evidence: [],
|
||||
verification: [],
|
||||
rollback: [],
|
||||
tools_used: [],
|
||||
started_at: '2026-10-22T10:00:00Z',
|
||||
error: note,
|
||||
}}
|
||||
/>
|
||||
));
|
||||
|
||||
// Wait for the session, whose badge heads the section above the record's.
|
||||
await screen.findByRole('button', { name: 'Show investigation thread' });
|
||||
const badges = screen.getAllByText('Interrupted — will retry');
|
||||
expect(badges).toHaveLength(2);
|
||||
for (const badge of badges) expect(badge.className).toContain('border-border');
|
||||
// The record's failed run status would contradict "will retry".
|
||||
expect(screen.queryByText('Failed')).not.toBeInTheDocument();
|
||||
// The record and the session carry the same note, so it shows once.
|
||||
const noteBox = screen.getByText(note);
|
||||
expect(noteBox.className).toContain('text-muted');
|
||||
expect(noteBox.className).not.toContain('bg-red-50');
|
||||
expect(screen.getByRole('button', { name: 'Re-investigate' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps a failed run error in the error style', async () => {
|
||||
getInvestigationMock.mockResolvedValue({
|
||||
id: 'inv-failed',
|
||||
finding_id: 'finding-failed',
|
||||
session_id: 'session-failed',
|
||||
status: 'failed',
|
||||
outcome: 'timed_out',
|
||||
started_at: '2026-10-22T10:00:00.000Z',
|
||||
turn_count: 12,
|
||||
error: 'The provider stopped responding.',
|
||||
} satisfies Investigation);
|
||||
|
||||
render(() => <InvestigationSection findingId="finding-failed" investigationStatus="failed" />);
|
||||
|
||||
expect((await screen.findByText('The provider stopped responding.')).className).toContain(
|
||||
'bg-red-50',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the durable Patrol investigation record through the inline investigation surface', async () => {
|
||||
getInvestigationMock.mockResolvedValue(undefined as unknown as Investigation);
|
||||
|
||||
@@ -127,6 +216,8 @@ describe('InvestigationSection', () => {
|
||||
render(() => <InvestigationSection findingId="finding-1" investigationRecord={record} />);
|
||||
|
||||
expect(await screen.findByText('Patrol record')).toBeInTheDocument();
|
||||
expect(screen.getByText('Fix queued')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Completed')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Backup job saturated CPU.')).toBeInTheDocument();
|
||||
expect(screen.getByText('CPU stayed above 95% for 10 minutes')).toBeInTheDocument();
|
||||
expect(screen.getByText('1 command recorded for approval context')).toBeInTheDocument();
|
||||
@@ -134,6 +225,31 @@ describe('InvestigationSection', () => {
|
||||
expect(screen.queryByText('systemctl restart workload.service')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the record run status until the run has an outcome', async () => {
|
||||
getInvestigationMock.mockResolvedValue(undefined as unknown as Investigation);
|
||||
|
||||
render(() => (
|
||||
<InvestigationSection
|
||||
findingId="finding-running"
|
||||
investigationRecord={{
|
||||
id: 'record-running',
|
||||
finding_id: 'finding-running',
|
||||
subject: { resource_id: 'vm-103' },
|
||||
trigger: { title: 'Service keeps restarting', detected_at: '2026-10-22T10:00:00Z' },
|
||||
status: 'running',
|
||||
started_at: '2026-10-22T10:00:00Z',
|
||||
evidence: [],
|
||||
verification: [],
|
||||
rollback: [],
|
||||
tools_used: [],
|
||||
}}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(await screen.findByText('Patrol record')).toBeInTheDocument();
|
||||
expect(screen.getByText('Running')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'preserves readable evidence and distinct summaries (different: %s)',
|
||||
async (different) => {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { For, Show, createMemo, createSignal, onCleanup, onMount } from 'solid-js';
|
||||
import CalendarCheckIcon from 'lucide-solid/icons/calendar-check';
|
||||
import RefreshIcon from 'lucide-solid/icons/refresh-cw';
|
||||
import { getPatrolDigest, PATROL_DIGEST_DEFAULT_DAYS, type PatrolDigest } from '@/api/patrol';
|
||||
import {
|
||||
getPatrolDigest,
|
||||
PATROL_DIGEST_DEFAULT_DAYS,
|
||||
type InvestigationOutcome,
|
||||
type PatrolDigest,
|
||||
} from '@/api/patrol';
|
||||
import { Button, ButtonLink } from '@/components/shared/Button';
|
||||
import { formatRelativeTime } from '@/utils/format';
|
||||
|
||||
@@ -21,18 +26,27 @@ const usdFormatter = new Intl.NumberFormat(undefined, {
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
const INVESTIGATION_OUTCOME_COPY: Array<{ key: string; label: string }> = [
|
||||
// Besides the investigation outcomes, the digest counts a run that ended on an
|
||||
// error with no outcome as "failed", the bucket telemetry uses for that state.
|
||||
type DigestOutcomeKey = InvestigationOutcome | 'failed';
|
||||
|
||||
// Same order and wording as the digest email (PatrolDigestOutcomeOrder and
|
||||
// internal/api/patrol_digest_email.go), most actionable first.
|
||||
const INVESTIGATION_OUTCOME_COPY: Array<{ key: DigestOutcomeKey; label: string }> = [
|
||||
{ key: 'needs_attention', label: 'need you' },
|
||||
{ key: 'fix_failed', label: 'fix failed' },
|
||||
{ key: 'fix_verification_failed', label: 'fix not confirmed' },
|
||||
{ key: 'cannot_fix', label: 'could not fix' },
|
||||
{ key: 'timed_out', label: 'timed out' },
|
||||
{ key: 'failed', label: 'ended with an error' },
|
||||
{ key: 'unclassified', label: 'finished without a clear conclusion' },
|
||||
{ key: 'fix_queued', label: 'fix waiting for approval' },
|
||||
{ key: 'fix_executed', label: 'fix run' },
|
||||
{ key: 'fix_verification_unknown', label: 'fix run, result unknown' },
|
||||
{ key: 'fix_verified', label: 'fixed and verified' },
|
||||
{ key: 'resolved', label: 'resolved' },
|
||||
{ key: 'fix_rejected', label: 'fix declined' },
|
||||
{ key: 'interrupted', label: 'interrupted by a restart' },
|
||||
];
|
||||
|
||||
const plural = (count: number, singular: string, pluralForm = `${singular}s`): string =>
|
||||
@@ -103,15 +117,17 @@ export function buildDigestTiles(digest: PatrolDigest): DigestTile[] {
|
||||
}
|
||||
|
||||
const investigationDetails = describeDigestInvestigationOutcomes(investigations.by_outcome);
|
||||
if (investigationDetails.length === 0) {
|
||||
if (investigationDetails.length > 0) {
|
||||
investigationDetails[investigationDetails.length - 1] += '.';
|
||||
if (investigationDetails.length > 1) investigationDetails[0] += ',';
|
||||
} else if (investigations.total === 0) {
|
||||
// Runs whose outcome has no wording here still happened, so only an empty
|
||||
// week may say that nothing was investigated.
|
||||
investigationDetails.push(
|
||||
digest.mode === 'monitor'
|
||||
? 'Patrol is watch only, so it reports issues without investigating them.'
|
||||
: 'No issues needed a closer look.',
|
||||
);
|
||||
} else {
|
||||
investigationDetails[investigationDetails.length - 1] += '.';
|
||||
if (investigationDetails.length > 1) investigationDetails[0] += ',';
|
||||
}
|
||||
|
||||
const actionDetails: string[] = [];
|
||||
|
||||
@@ -202,4 +202,55 @@ describe('PatrolWeeklyDigestCard', () => {
|
||||
describeDigestInvestigationOutcomes({ resolved: 3, fix_failed: 1, cannot_fix: 2 }),
|
||||
).toEqual(['1 fix failed', '2 could not fix']);
|
||||
});
|
||||
|
||||
it('names unclassified and interrupted runs in the email order', () => {
|
||||
// A run with no readable conclusion still needs a read, so it ranks just
|
||||
// after timeouts; an interrupted run Patrol retries ranks last.
|
||||
expect(
|
||||
describeDigestInvestigationOutcomes({ interrupted: 2, unclassified: 3, resolved: 1 }),
|
||||
).toEqual(['3 finished without a clear conclusion', '1 resolved']);
|
||||
expect(
|
||||
describeDigestInvestigationOutcomes({ interrupted: 2, timed_out: 1, unclassified: 3 }),
|
||||
).toEqual(['1 timed out', '3 finished without a clear conclusion']);
|
||||
expect(describeDigestInvestigationOutcomes({ interrupted: 2 })).toEqual([
|
||||
'2 interrupted by a restart',
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows an unclassified week on the Investigated tile', async () => {
|
||||
apiMocks.getDigest.mockResolvedValue(
|
||||
digest({ investigations: { total: 5, by_outcome: { unclassified: 4, interrupted: 1 } } }),
|
||||
);
|
||||
render(() => <PatrolWeeklyDigestCard />);
|
||||
|
||||
expect(await screen.findByText('4 finished without a clear conclusion,')).toBeInTheDocument();
|
||||
expect(screen.getByText('1 interrupted by a restart.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('names runs that ended with an error between timeouts and unclear runs', () => {
|
||||
expect(
|
||||
describeDigestInvestigationOutcomes({ unclassified: 3, failed: 2, interrupted: 1 }),
|
||||
).toEqual(['2 ended with an error', '3 finished without a clear conclusion']);
|
||||
expect(describeDigestInvestigationOutcomes({ failed: 1, timed_out: 2 })).toEqual([
|
||||
'2 timed out',
|
||||
'1 ended with an error',
|
||||
]);
|
||||
});
|
||||
|
||||
it('never says nothing was investigated under a non-zero count', () => {
|
||||
const investigated = (
|
||||
investigations: PatrolDigest['investigations'],
|
||||
mode: PatrolDigest['mode'] = 'approval',
|
||||
) =>
|
||||
buildDigestTiles(digest({ mode, investigations })).find((tile) => tile.id === 'investigated');
|
||||
// A run whose outcome has no wording on the card still happened.
|
||||
expect(investigated({ total: 3, by_outcome: { unknown: 3 } })).toMatchObject({
|
||||
value: '3',
|
||||
details: [],
|
||||
});
|
||||
expect(investigated({ total: 2, by_outcome: { unknown: 2 } }, 'monitor')?.details).toEqual([]);
|
||||
expect(investigated({ total: 0, by_outcome: {} })?.details).toEqual([
|
||||
'No issues needed a closer look.',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
+27
-1
@@ -41,7 +41,7 @@ describe('patrolInvestigationContextModel', () => {
|
||||
expect(presentation).toMatchObject({
|
||||
hasRecord: true,
|
||||
statusLabel: 'Completed',
|
||||
outcomeLabel: 'Fix Queued',
|
||||
outcomeLabel: 'Fix queued',
|
||||
confidenceLabel: 'High confidence',
|
||||
conclusion: 'Backup job saturated CPU.',
|
||||
evidenceSummaries: ['CPU stayed above 95% for 10 minutes', 'Backup process held IO wait.'],
|
||||
@@ -138,6 +138,32 @@ describe('patrolInvestigationContextModel', () => {
|
||||
'Re-pin previous datastore mount',
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['unclassified', 'completed', 'No clear conclusion'],
|
||||
['interrupted', 'failed', 'Interrupted — will retry'],
|
||||
['cannot_fix', 'completed', 'Cannot remediate'],
|
||||
])(
|
||||
'labels a %s record with the investigation outcome wording',
|
||||
(outcome, status, outcomeLabel) => {
|
||||
// The record badge sits beside the investigation's own outcome badge, so
|
||||
// both must name the same outcome the same way.
|
||||
const presentation = buildPatrolInvestigationRecordPresentation({
|
||||
id: `record-${outcome}`,
|
||||
finding_id: `finding-${outcome}`,
|
||||
subject: { resource_id: 'vm-102' },
|
||||
trigger: { title: 'Service keeps restarting', detected_at: '2026-10-22T12:00:00Z' },
|
||||
status,
|
||||
outcome,
|
||||
evidence: [],
|
||||
verification: [],
|
||||
rollback: [],
|
||||
tools_used: [],
|
||||
started_at: '2026-10-22T12:00:00Z',
|
||||
});
|
||||
expect(presentation.outcomeLabel).toBe(outcomeLabel);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('Patrol page header IA framing', () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { InvestigationRecord, InvestigationRecordFix } from '@/api/ai';
|
||||
import { getInvestigationOutcomeLabel } from '@/utils/aiFindingPresentation';
|
||||
|
||||
export interface PatrolInvestigationRecordPresentation {
|
||||
hasRecord: boolean;
|
||||
@@ -42,7 +43,7 @@ export function buildPatrolInvestigationRecordPresentation(
|
||||
return {
|
||||
hasRecord: true,
|
||||
statusLabel: formatIdentifierLabel(record.status) || 'Investigation recorded',
|
||||
outcomeLabel: formatIdentifierLabel(record.outcome),
|
||||
outcomeLabel: formatOutcomeLabel(record.outcome),
|
||||
confidenceLabel: record.confidence
|
||||
? `${formatIdentifierLabel(record.confidence)} confidence`
|
||||
: undefined,
|
||||
@@ -113,6 +114,13 @@ function formatIdentifierLabel(value?: string | null): string | undefined {
|
||||
.replace(/\b\w/g, (char) => char.toUpperCase());
|
||||
}
|
||||
|
||||
// The record's outcome badge sits beside the investigation's own outcome
|
||||
// badge, so both use the shared outcome labels.
|
||||
function formatOutcomeLabel(value?: string | null): string | undefined {
|
||||
const normalized = normalizeText(value);
|
||||
return normalized ? getInvestigationOutcomeLabel(normalized) : undefined;
|
||||
}
|
||||
|
||||
function formatToolLabel(value?: string | null): string {
|
||||
const normalized = normalizeText(value);
|
||||
const knownLabel = PATROL_TOOL_LABELS[normalized];
|
||||
|
||||
@@ -174,6 +174,57 @@ describe('aiIntelligenceStore', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the unclassified and interrupted outcomes on both finding feeds', async () => {
|
||||
const outcomes = { 'run-unclassified': 'unclassified', 'run-interrupted': 'interrupted' };
|
||||
const finding = (id: string, outcome: string) => ({
|
||||
id,
|
||||
severity: 'warning',
|
||||
category: 'reliability',
|
||||
resource_id: `instance:node:${id}`,
|
||||
resource_name: id,
|
||||
resource_type: 'vm',
|
||||
title: 'Service keeps restarting',
|
||||
description: 'Patrol investigated the restarts.',
|
||||
detected_at: '2026-10-22T00:00:00Z',
|
||||
last_seen_at: '2026-10-22T00:05:00Z',
|
||||
auto_resolved: false,
|
||||
times_raised: 1,
|
||||
suppressed: false,
|
||||
investigation_attempts: 1,
|
||||
investigation_status: outcome === 'interrupted' ? 'failed' : 'completed',
|
||||
investigation_outcome: outcome,
|
||||
});
|
||||
const findings = [
|
||||
...Object.entries(outcomes).map(([id, outcome]) => finding(id, outcome)),
|
||||
finding('run-unknown', 'not_an_outcome'),
|
||||
];
|
||||
vi.mocked(AIAPI.getUnifiedFindings).mockResolvedValueOnce({
|
||||
findings: findings.map((item) => ({ ...item, source: 'ai-patrol', status: 'active' })),
|
||||
count: findings.length,
|
||||
} as never);
|
||||
vi.mocked(getPatrolFindings).mockResolvedValueOnce(findings as never);
|
||||
vi.mocked(AIAPI.getPendingApprovals).mockResolvedValueOnce([]);
|
||||
|
||||
await aiIntelligenceStore.loadFindings();
|
||||
await aiIntelligenceStore.loadPatrolFindings();
|
||||
await aiIntelligenceStore.loadPendingApprovals();
|
||||
|
||||
const expected = { ...outcomes, 'run-unknown': undefined };
|
||||
for (const feed of [aiIntelligenceStore.findings, aiIntelligenceStore.patrolFindings]) {
|
||||
expect(Object.fromEntries(feed.map((item) => [item.id, item.investigationOutcome]))).toEqual(
|
||||
expected,
|
||||
);
|
||||
}
|
||||
// An unclassified run left a summary to read; an interrupted run is
|
||||
// rerun by Patrol, so it never joins the attention queue.
|
||||
expect(aiIntelligenceStore.findingsNeedingAttention.map((item) => item.id)).toEqual([
|
||||
'run-unclassified',
|
||||
]);
|
||||
expect(aiIntelligenceStore.patrolFindingsNeedingAttention.map((item) => item.id)).toEqual([
|
||||
'run-unclassified',
|
||||
]);
|
||||
});
|
||||
|
||||
it('counts open Patrol work from active findings and live Patrol approvals once', async () => {
|
||||
vi.mocked(getPatrolFindings).mockResolvedValueOnce([
|
||||
{
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
type Finding as PatrolFinding,
|
||||
type CapacityForecast,
|
||||
type FindingFlapping,
|
||||
type InvestigationOutcome,
|
||||
} from '@/api/patrol';
|
||||
import type { ResourceCriticality } from '@/api/resourceOperatorState';
|
||||
import type {
|
||||
@@ -54,19 +55,24 @@ const VALID_INVESTIGATION_STATUSES = new Set<string>([
|
||||
'failed',
|
||||
'needs_attention',
|
||||
]);
|
||||
const VALID_INVESTIGATION_OUTCOMES = new Set<string>([
|
||||
'resolved',
|
||||
'fix_queued',
|
||||
'fix_executed',
|
||||
'fix_failed',
|
||||
'fix_rejected',
|
||||
'needs_attention',
|
||||
'cannot_fix',
|
||||
'timed_out',
|
||||
'fix_verified',
|
||||
'fix_verification_failed',
|
||||
'fix_verification_unknown',
|
||||
]);
|
||||
// Keyed by the API union so type-check fails here, rather than the value being
|
||||
// dropped as unknown, when the backend gains an outcome.
|
||||
const INVESTIGATION_OUTCOME_KEYS: Record<InvestigationOutcome, true> = {
|
||||
resolved: true,
|
||||
fix_queued: true,
|
||||
fix_executed: true,
|
||||
fix_failed: true,
|
||||
fix_rejected: true,
|
||||
needs_attention: true,
|
||||
cannot_fix: true,
|
||||
timed_out: true,
|
||||
unclassified: true,
|
||||
interrupted: true,
|
||||
fix_verified: true,
|
||||
fix_verification_failed: true,
|
||||
fix_verification_unknown: true,
|
||||
};
|
||||
const VALID_INVESTIGATION_OUTCOMES = new Set<string>(Object.keys(INVESTIGATION_OUTCOME_KEYS));
|
||||
const VALID_SEVERITIES = new Set<string>(['critical', 'warning', 'info', 'watch']);
|
||||
const VALID_RESOURCE_CRITICALITIES = new Set<string>(['high', 'medium', 'low', '']);
|
||||
const VALID_SOURCES = new Set<string>([
|
||||
@@ -185,18 +191,7 @@ export interface UnifiedFinding {
|
||||
// Investigation fields (Patrol mode)
|
||||
investigationSessionId?: string;
|
||||
investigationStatus?: 'pending' | 'running' | 'completed' | 'failed' | 'needs_attention';
|
||||
investigationOutcome?:
|
||||
| 'resolved'
|
||||
| 'fix_queued'
|
||||
| 'fix_executed'
|
||||
| 'fix_failed'
|
||||
| 'fix_rejected'
|
||||
| 'needs_attention'
|
||||
| 'cannot_fix'
|
||||
| 'timed_out'
|
||||
| 'fix_verified'
|
||||
| 'fix_verification_failed'
|
||||
| 'fix_verification_unknown';
|
||||
investigationOutcome?: InvestigationOutcome;
|
||||
lastInvestigatedAt?: string;
|
||||
investigationAttempts?: number;
|
||||
investigationRecord?: InvestigationRecord;
|
||||
|
||||
@@ -279,6 +279,8 @@ describe('aiFindingPresentation branch coverage (supplemental)', () => {
|
||||
needs_attention: AMBER_CLASSES,
|
||||
cannot_fix: DEFAULT_BADGE_CLASSES,
|
||||
timed_out: 'border-border bg-surface-alt text-base-content',
|
||||
unclassified: AMBER_CLASSES,
|
||||
interrupted: 'border-border bg-surface-alt text-base-content',
|
||||
fix_verified: GREEN_CLASSES,
|
||||
fix_verification_failed: RED_CLASSES,
|
||||
fix_verification_unknown: AMBER_CLASSES,
|
||||
@@ -303,6 +305,8 @@ describe('aiFindingPresentation branch coverage (supplemental)', () => {
|
||||
needs_attention: 'warning',
|
||||
cannot_fix: 'muted',
|
||||
timed_out: 'neutral',
|
||||
unclassified: 'warning',
|
||||
interrupted: 'neutral',
|
||||
fix_verified: 'success',
|
||||
fix_verification_failed: 'danger',
|
||||
fix_verification_unknown: 'warning',
|
||||
@@ -326,6 +330,8 @@ describe('aiFindingPresentation branch coverage (supplemental)', () => {
|
||||
needs_attention: 'Needs attention',
|
||||
cannot_fix: 'Cannot remediate',
|
||||
timed_out: 'Timed out',
|
||||
unclassified: 'No clear conclusion',
|
||||
interrupted: 'Interrupted — will retry',
|
||||
fix_verified: 'Fix verified',
|
||||
fix_verification_failed: 'Verification failed',
|
||||
fix_verification_unknown: 'Verification unknown',
|
||||
|
||||
@@ -70,6 +70,7 @@ describe('getInvestigationOutcomeSortOrder', () => {
|
||||
['timed_out', 1],
|
||||
['needs_attention', 1],
|
||||
['cannot_fix', 1],
|
||||
['unclassified', 1],
|
||||
['fix_queued', 2],
|
||||
] as const)('ranks outcome %s at %i', (outcome, expected) => {
|
||||
expect(getInvestigationOutcomeSortOrder(outcome)).toBe(expected);
|
||||
@@ -81,6 +82,12 @@ describe('getInvestigationOutcomeSortOrder', () => {
|
||||
expect(getInvestigationOutcomeSortOrder('fix_verified')).toBe(3);
|
||||
});
|
||||
|
||||
it('ranks an interrupted run with findings Patrol has not investigated yet', () => {
|
||||
expect(getInvestigationOutcomeSortOrder('interrupted')).toBe(
|
||||
getInvestigationOutcomeSortOrder(undefined),
|
||||
);
|
||||
});
|
||||
|
||||
it('defaults an unrecognised outcome to 3', () => {
|
||||
expect(getInvestigationOutcomeSortOrder('not-a-real-outcome')).toBe(3);
|
||||
});
|
||||
@@ -109,6 +116,7 @@ describe('doesFindingNeedAttention (ATTENTION_OUTCOMES set membership)', () => {
|
||||
'timed_out',
|
||||
'needs_attention',
|
||||
'cannot_fix',
|
||||
'unclassified',
|
||||
] as const;
|
||||
|
||||
it.each(attentionOutcomes)(
|
||||
@@ -124,6 +132,16 @@ describe('doesFindingNeedAttention (ATTENTION_OUTCOMES set membership)', () => {
|
||||
},
|
||||
);
|
||||
|
||||
it('does not flag an interrupted run, which Patrol reruns by itself', () => {
|
||||
expect(
|
||||
doesFindingNeedAttention({
|
||||
id: 'f-interrupted',
|
||||
status: 'active',
|
||||
investigationOutcome: 'interrupted',
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('does not flag a non-attention outcome that is not awaiting approval', () => {
|
||||
// fix_executed is positive progress, not in the attention set.
|
||||
expect(
|
||||
|
||||
@@ -807,6 +807,34 @@ describe('getFindingPatrolWorkflowPresentation', () => {
|
||||
expect(result?.stage).toBe('attention');
|
||||
});
|
||||
|
||||
it('asks the operator to read the summary of an unclassified run', () => {
|
||||
expect(
|
||||
getFindingPatrolWorkflowPresentation(
|
||||
makeFinding({
|
||||
source: 'ai-patrol',
|
||||
status: 'active',
|
||||
resourceId: 'vm:1',
|
||||
investigationStatus: 'completed',
|
||||
investigationOutcome: 'unclassified',
|
||||
}),
|
||||
),
|
||||
).toMatchObject({ stage: 'attention', label: 'Read summary', tone: 'warning' });
|
||||
});
|
||||
|
||||
it('leaves an interrupted run with Patrol, which runs it again', () => {
|
||||
expect(
|
||||
getFindingPatrolWorkflowPresentation(
|
||||
makeFinding({
|
||||
source: 'ai-patrol',
|
||||
status: 'active',
|
||||
resourceId: 'vm:1',
|
||||
investigationStatus: 'failed',
|
||||
investigationOutcome: 'interrupted',
|
||||
}),
|
||||
),
|
||||
).toMatchObject({ stage: 'investigating', label: 'Will retry', tone: 'neutral' });
|
||||
});
|
||||
|
||||
it('returns undefined for an active finding with an unrecognised outcome and no investigation status', () => {
|
||||
expect(
|
||||
getFindingPatrolWorkflowPresentation(
|
||||
@@ -1010,6 +1038,15 @@ describe('getFindingResolutionReason', () => {
|
||||
).toBe('Resolved now');
|
||||
});
|
||||
|
||||
it.each(['unclassified', 'interrupted'] as const)(
|
||||
'does not infer who resolved the finding from %s',
|
||||
(outcome) => {
|
||||
expect(
|
||||
getFindingResolutionReason({ ...patrolBase, investigationOutcome: outcome }, 'now'),
|
||||
).toBe('Resolved now');
|
||||
},
|
||||
);
|
||||
|
||||
it('returns "Fix applied by Patrol" for fix_executed even when autoResolved is false', () => {
|
||||
expect(
|
||||
getFindingResolutionReason(
|
||||
|
||||
@@ -41,6 +41,15 @@ describe('classifyPatrolFindingWorkType', () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['unclassified', 'interrupted'] as const)(
|
||||
'does not count %s as a failed fix, since no fix was tried',
|
||||
(outcome) => {
|
||||
expect(classifyPatrolFindingWorkType(makeFinding({ investigationOutcome: outcome }))).toBe(
|
||||
'new',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('classifies investigation running as in_progress', () => {
|
||||
expect(classifyPatrolFindingWorkType(makeFinding({ investigationStatus: 'running' }))).toBe(
|
||||
'in_progress',
|
||||
@@ -232,6 +241,22 @@ describe('getPatrolFindingActionableState', () => {
|
||||
).toEqual({ label: 'Verifying fix', tone: 'info' });
|
||||
});
|
||||
|
||||
it('tells the operator Patrol will retry an interrupted run', () => {
|
||||
expect(
|
||||
getPatrolFindingActionableState(
|
||||
makeFinding({ investigationStatus: 'failed', investigationOutcome: 'interrupted' }),
|
||||
),
|
||||
).toEqual({ label: 'Will retry', tone: 'neutral' });
|
||||
});
|
||||
|
||||
it('leaves an unclassified run on the default review state', () => {
|
||||
expect(
|
||||
getPatrolFindingActionableState(
|
||||
makeFinding({ investigationStatus: 'completed', investigationOutcome: 'unclassified' }),
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for non-active findings', () => {
|
||||
expect(
|
||||
getPatrolFindingActionableState(
|
||||
|
||||
@@ -182,6 +182,8 @@ const INVESTIGATION_OUTCOME_LABELS: Record<InvestigationOutcome, string> = {
|
||||
needs_attention: 'Needs attention',
|
||||
cannot_fix: 'Cannot remediate',
|
||||
timed_out: 'Timed out',
|
||||
unclassified: 'No clear conclusion',
|
||||
interrupted: 'Interrupted — will retry',
|
||||
fix_verified: 'Fix verified',
|
||||
fix_verification_failed: 'Verification failed',
|
||||
fix_verification_unknown: 'Verification unknown',
|
||||
@@ -202,6 +204,9 @@ const INVESTIGATION_OUTCOME_CLASSES: Record<InvestigationOutcome, string> = {
|
||||
'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-800 dark:bg-amber-900/25 dark:text-amber-300',
|
||||
cannot_fix: 'border-border bg-surface-alt text-muted',
|
||||
timed_out: 'border-border bg-surface-alt text-base-content',
|
||||
unclassified:
|
||||
'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-800 dark:bg-amber-900/25 dark:text-amber-300',
|
||||
interrupted: 'border-border bg-surface-alt text-base-content',
|
||||
fix_verified:
|
||||
'border-green-200 bg-green-50 text-green-700 dark:border-green-800 dark:bg-green-900/25 dark:text-green-300',
|
||||
fix_verification_failed:
|
||||
@@ -219,6 +224,8 @@ const INVESTIGATION_OUTCOME_TONES: Record<InvestigationOutcome, MetadataBadgeTon
|
||||
needs_attention: 'warning',
|
||||
cannot_fix: 'muted',
|
||||
timed_out: 'neutral',
|
||||
unclassified: 'warning',
|
||||
interrupted: 'neutral',
|
||||
fix_verified: 'success',
|
||||
fix_verification_failed: 'danger',
|
||||
fix_verification_unknown: 'warning',
|
||||
@@ -253,6 +260,7 @@ const INVESTIGATION_OUTCOME_SORT_ORDER: Record<string, number> = {
|
||||
timed_out: 1,
|
||||
needs_attention: 1,
|
||||
cannot_fix: 1,
|
||||
unclassified: 1,
|
||||
fix_queued: 2,
|
||||
};
|
||||
|
||||
@@ -811,6 +819,12 @@ export function getPatrolFindingActionableState(
|
||||
return { label: 'Verifying fix', tone: 'info' };
|
||||
}
|
||||
|
||||
// A restart cut the last run short. Patrol runs it again by itself, so the
|
||||
// row must not ask the operator to review anything.
|
||||
if (finding.investigationOutcome === 'interrupted') {
|
||||
return { label: 'Will retry', tone: 'neutral' };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -1039,6 +1053,7 @@ const ATTENTION_OUTCOMES = new Set([
|
||||
'timed_out',
|
||||
'needs_attention',
|
||||
'cannot_fix',
|
||||
'unclassified',
|
||||
]);
|
||||
|
||||
export const hasPendingInvestigationFixApproval = (
|
||||
@@ -1242,6 +1257,22 @@ export const getFindingPatrolWorkflowPresentation = (
|
||||
detail: 'Patrol needs operator context before continuing.',
|
||||
tone: 'warning',
|
||||
};
|
||||
case 'unclassified':
|
||||
return {
|
||||
stage: 'attention',
|
||||
label: 'Read summary',
|
||||
detail:
|
||||
'Patrol finished without a clear conclusion. Read its summary. Patrol looks again on a later run.',
|
||||
tone: 'warning',
|
||||
};
|
||||
case 'interrupted':
|
||||
return {
|
||||
stage: 'investigating',
|
||||
label: 'Will retry',
|
||||
detail:
|
||||
'The last run was cut short, usually by a Pulse restart. Patrol runs it again on its next pass.',
|
||||
tone: 'neutral',
|
||||
};
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
@@ -1321,6 +1352,8 @@ export const getFindingResolutionReason = (
|
||||
return `Resolved after investigation timeout ${resolvedTime}`;
|
||||
case 'cannot_fix':
|
||||
case 'needs_attention':
|
||||
case 'unclassified':
|
||||
case 'interrupted':
|
||||
// An investigation outcome does not identify who later resolved the
|
||||
// finding. Explicit operator resolution is handled above.
|
||||
return `Resolved ${resolvedTime}`;
|
||||
|
||||
Reference in New Issue
Block a user