Stop Patrol fetching the circuit-breaker status nobody reads (#2704)

The Patrol page fetched `GET /api/ai/circuit/status` on every load,
every 60 s poll, every return to the tab, and again in the background
after Retry. The store's breaker state had one production reader: the
`circuitBreakerStatus` memo in `usePatrolIntelligenceState`, which
nothing read and which #2695 removes. An open breaker already reaches
the page through `/api/ai/patrol/status` as the `circuit_open` blocked
cause, and the paused banner shows its reason.

## Change

- `stores/aiIntelligence.ts`: remove the breaker signal, getter,
`circuitBreakerStatusSignal`, `loadCircuitBreakerStatus()`, its
org-switch reset, and its `loadDashboardData()` entry.
- `features/patrol/usePatrolIntelligenceState.ts`: drop the read from
the Retry path's background supporting reads.
- `api/ai.ts`: remove `AIAPI.getCircuitBreakerStatus` and the
`CircuitBreakerStatus` type; nothing else used them. The backend route
stays.
- Tests: the client's unit tests and the store and page test mocks go.
The store bundle test still pins the exact AIAPI read set. The breaker
route stubs are removed from seven E2E specs.
- Contracts: patrol-intelligence, ai-runtime, api-contracts and
frontend-primitives say the browser reads no breaker status and how an
open breaker still reaches Patrol. Each note sits next to #2677's
correlation note.

## Evidence

- Normalized production-build compare: only the removed store loader,
signal, getter and client differ in `index.js`, plus the removed
supporting read in `AIIntelligence.js`. `check-bundle-size.mjs` passes.
- Private demo-mode stacks for the parent and this tree, driven by the
same Playwright script at 1440x900 and 390x844. Across Patrol load, tab
hide/show and stale-data Retry, the breaker read drops from 1-2 requests
per phase to none. Every other endpoint matches, the Open in Assistant
handoff is unchanged, and neither build logs a console error.
- A forced `circuit_open` Patrol status still shows "Patrol paused /
circuit breaker is open" on both builds.
- `tsc --noEmit`, eslint, and vitest for `src/stores`, `src/api`,
`src/features/patrol`, `src/components/AI`, the Patrol page test and the
boundaries test pass, as does the full pre-commit battery.

Stacked on #2695 (which stacks on #2687) with #2677 merged in, so until
those land this PR shows their commits too. The 2677 owner agreed this
read belongs in a follow-up. `git merge-tree` is clean against main,
#2677, #2690, #2691 and #2693.
This commit is contained in:
rcourtman
2026-10-08 03:05:15 +01:00
committed by GitHub
18 changed files with 107 additions and 205 deletions
@@ -6761,6 +6761,11 @@ The browser does not read the global learned-correlation list:
`/api/ai/intelligence/correlations`, and neither the shared AI intelligence
store nor the Patrol page loads it, because no page renders it. The resource
drawer reads per-resource correlations from the resource-intelligence payload.
The browser does not read `GET /api/ai/circuit/status` either:
`frontend-modern/src/api/ai.ts` carries no client for it and the shared AI
intelligence store holds no breaker state, because an open breaker already
reaches Patrol as the `circuit_open` runtime block cause. The route stays a
backend API surface.
AI-facing policy metadata must also be cloned through the shared unified-
resource policy helper so chat and tools consumers do not maintain their own
policy copy logic. Chat mention prefetch now calls that shared helper directly
@@ -7486,6 +7486,12 @@ no client for it, and neither the dashboard load bundle in
fetches it. The resource drawer's correlation evidence comes from the
resource-intelligence payload above. The route stays an API surface behind the
`ai:execute` scope.
The frontend does not load `GET /api/ai/circuit/status` either. No surface
renders provider breaker state, so `frontend-modern/src/api/ai.ts` carries no
client or `CircuitBreakerStatus` type for it, and neither that dashboard bundle
nor the Patrol refresh path fetches it. An open breaker reaches the Patrol page
through `/api/ai/patrol/status` as the `circuit_open` blocked cause. The route
stays a backend API surface.
That correlations route now reads through the canonical AI intelligence
facade first, so the handler and its payload keep the detector behind one
shared access layer instead of routing directly to Patrol-local correlation
@@ -6864,7 +6864,8 @@ Patrol stale-data Retry action spin indefinitely or stay disabled
while Patrol findings and status remain visible.
Neither the Patrol load and poll path (the store's `loadDashboardData` bundle)
nor the Retry path's background supporting reads fetch the global
learned-correlation list, which no Patrol surface shows.
learned-correlation list or the provider circuit-breaker status, neither of
which any Patrol surface shows.
That same Patrol shell should make scoped trigger policy legible without
another navigation step. `frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx`
should keep actionable scoped-trigger state legible without promoting
@@ -2197,10 +2197,13 @@ requester identity as safe metadata, without copying the approval command
payload into Assistant.
That same store now owns the Patrol dashboard load bundle as well, so the
page refresh path stays aligned on a single orchestrated AI bundle instead of
repeating the individual summary, findings, circuit-breaker, and approval
fetches inline. Neither that bundle nor the Patrol refresh path fetches the
global learned-correlation list: the Patrol page renders no correlations, so
the store holds no copy of that list.
repeating the individual summary, findings, and approval fetches inline.
Neither that bundle nor the Patrol refresh path fetches the global
learned-correlation list: the Patrol page renders no correlations, so
the store holds no copy of that list. Nor do they fetch the provider
circuit-breaker status: an open breaker already reaches the page as the
`circuit_open` Patrol runtime block on `/api/ai/patrol/status`, so the store
holds no separate breaker state.
The shared
`frontend-modern/src/components/Infrastructure/ResourceChangeSummary.tsx` and
`frontend-modern/src/components/Infrastructure/ResourceCorrelationSummary.tsx`
@@ -0,0 +1,41 @@
{
"version": 1,
"base_sha": "1b01ab6e1bbbb12996d39ea4c8b64cf379ce2e01",
"verified_at": "2026-10-07T23:54:00Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts"
],
"content_sha256": {
"frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts": "ef09be2978d90248ad165176d49b131939ea1231b02e4fe239bb01e9da11e4d0"
},
"routes": [
"/patrol",
"/patrol (Activity > Finding options and history)",
"/patrol (Pulse Assistant drawer)"
],
"viewports": [
{
"width": 1440,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"Follow-up receipt for merging the repaired #2695 head (32fad38aa4, which contains current main) into this branch: the hook's final content is that head's hook minus the circuit-breaker supporting read this branch removes.",
"Embedded production builds (vite build into internal/api/frontend-modern/dist, go build) of 32fad38aa4 and of this merged tree (1b01ab6e1b), run on pulse-dev because local memory pressure refused heavy work; each on a private DEMO_MODE mock stack with a fresh data dir; the same Playwright script ran against both at both widths.",
"Patrol load, tab hide/show, stale-data banner forced by failing GET /api/settings/ai and Retry: GET /api/ai/circuit/status drops from one request per phase on the parent build to none; the banner shows and Retry clears it on both builds; no console errors on either.",
"Served active finding, Activity > Finding options and history, Resolve or dismiss > Open in Assistant: the Pulse Assistant drawer opens with the finding attached on both builds.",
"Patrol status forced to runtime_state blocked with blocked_cause circuit_open (page.route): both builds show 'Patrol paused' with 'circuit breaker is open'.",
"Across the run the only other endpoint difference is one GET /api/updates/plan from the update banner, which fires once the server's background update check completes and does not depend on this change.",
"Dist proof on pulse-dev: 187 files each side; after normalising chunk hashes, SRI strings and short identifiers, only the removed store loader, signal, getter and API client differ in index.js and the removed supporting read in AIIntelligence.js."
],
"interactions": [
"Demo auto sign-in; on /patrol open the Activity tab and 'Finding options and history', expand the served finding, open 'Resolve or dismiss', click 'Open in Assistant', close the drawer.",
"Dispatch visibilitychange hidden then visible; reload with GET /api/settings/ai failing, wait for the banner, restore the route and click Retry, wait for the banner to clear; reload /patrol with a circuit_open Patrol status.",
"Desktop 1440x900 and phone 390x844 contexts; screenshots of the Patrol page, Finding options and history, the Assistant drawer, the stale-data banner and the paused banner inspected."
]
}
@@ -0,0 +1,43 @@
{
"version": 1,
"base_sha": "3e6e9c3ffb8762b032efab544fda2f339cdb3d96",
"verified_at": "2026-10-07T16:28:00Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/api/ai.ts",
"frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts",
"frontend-modern/src/stores/aiIntelligence.ts"
],
"content_sha256": {
"frontend-modern/src/api/ai.ts": "8ab01c7c5daac6c2088985423d3bb4584561b67ec306331ff92bf32cb9551738",
"frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts": "dba595d1677002a9c55a31b45b4a4b5c600df7b4866afa8eb6bbe6dafbb75503",
"frontend-modern/src/stores/aiIntelligence.ts": "d10124d374625cb4a6b2e5b17a357211f73b724473bba9b7a4d0d0d15ccaf6b8"
},
"routes": [
"/patrol",
"/patrol (Activity > Finding options and history)",
"/patrol (Pulse Assistant drawer)"
],
"viewports": [
{
"width": 1440,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"Embedded production builds (vite build into internal/api/frontend-modern/dist, go build) of the parent tree (3e6e9c3ffb, the merge of #2677 into #2695) and this tree, each on a private DEMO_MODE mock stack with a fresh data dir and its own port; the same Playwright script ran against both at both widths.",
"Patrol load, tab hide/show, forced stale-data banner and Retry: GET /api/ai/circuit/status goes from 1-2 requests per phase on the parent build to 0 on this build; every other /api endpoint per phase is identical, and neither build logs a console error.",
"Served active finding, Activity > Finding options and history, Resolve or dismiss > Open in Assistant: the Pulse Assistant drawer opens with the finding attached on both builds, with identical requests.",
"Patrol status forced to runtime_state blocked with blocked_cause circuit_open (page.route): both builds show 'Patrol paused' with 'circuit breaker is open', so breaker state still reaches the page through Patrol status alone.",
"Dist proof: 187 files each side; after normalising chunk hashes, SRI strings and short identifiers, only the removed store loader, signal, getter and API client differ in index.js and the removed supporting read in AIIntelligence.js; check-bundle-size.mjs passes."
],
"interactions": [
"Demo auto sign-in; on /patrol open the Activity tab and 'Finding options and history', expand the served finding, open 'Resolve or dismiss', click 'Open in Assistant', close the drawer.",
"Dispatch visibilitychange hidden then visible; reload with GET /api/settings/ai failing, then click Retry with the route restored; reload /patrol with a circuit_open Patrol status.",
"Desktop 1440x900 and phone 390x844 contexts; screenshots of the Patrol page, Assistant drawer, stale-data banner and paused banner inspected."
]
}
@@ -21,7 +21,7 @@ import { apiFetch, apiFetchJSON } from '@/utils/apiClient';
// response-handling of the currently-uncovered AIAPI request functions:
// testConnection, resetCostHistory, exportCostHistory, getAnomalies,
// getLearningStatus, analyzeKubernetesCluster, approveRemediationPlan,
// executeRemediationPlan, rollbackRemediationPlan, getCircuitBreakerStatus.
// executeRemediationPlan, rollbackRemediationPlan.
// Each test asserts the concrete request (final path + query string + method +
// body) and the concrete parsed result for happy paths and each optional /
// default-parameter branch arm.
@@ -421,58 +421,4 @@ describe('AIAPI branch coverage (request shaping + response handling)', () => {
expect(result).toStrictEqual({ success: false });
});
});
// --------------------------------------------
// getCircuitBreakerStatus
// --------------------------------------------
describe('getCircuitBreakerStatus', () => {
it('GETs /api/ai/circuit/status and returns a closed-breaker payload verbatim', async () => {
const payload = {
state: 'closed' as const,
can_patrol: true,
consecutive_failures: 0,
total_successes: 128,
total_failures: 1,
};
apiFetchJSONMock.mockResolvedValueOnce(payload as any);
const result = await AIAPI.getCircuitBreakerStatus();
expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/ai/circuit/status');
expect(result).toStrictEqual(payload);
});
it('round-trips an open-breaker payload that blocks patrol', async () => {
const payload = {
state: 'open' as const,
can_patrol: false,
consecutive_failures: 5,
total_successes: 100,
total_failures: 10,
};
apiFetchJSONMock.mockResolvedValueOnce(payload as any);
const result = await AIAPI.getCircuitBreakerStatus();
expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/ai/circuit/status');
expect(result).toStrictEqual(payload);
expect(result.can_patrol).toBe(false);
});
it('round-trips a half-open breaker probe state', async () => {
const payload = {
state: 'half-open' as const,
can_patrol: true,
consecutive_failures: 0,
total_successes: 100,
total_failures: 5,
};
apiFetchJSONMock.mockResolvedValueOnce(payload as any);
const result = await AIAPI.getCircuitBreakerStatus();
expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/ai/circuit/status');
expect(result.state).toBe('half-open');
});
});
});
-13
View File
@@ -276,11 +276,6 @@ export class AIAPI {
}) as Promise<{ success: boolean }>;
}
// Circuit breaker status
static async getCircuitBreakerStatus(): Promise<CircuitBreakerStatus> {
return apiFetchJSON(`${this.baseUrl}/ai/circuit/status`) as Promise<CircuitBreakerStatus>;
}
// ============================================
// Investigation Fix Approvals
// ============================================
@@ -567,14 +562,6 @@ export interface RemediationExecutionResult {
completed_at?: string;
}
export interface CircuitBreakerStatus {
state: 'closed' | 'open' | 'half-open';
can_patrol: boolean;
consecutive_failures: number;
total_successes: number;
total_failures: number;
}
// ============================================
// Investigation Fix Approval Types
// ============================================
@@ -646,7 +646,6 @@ export function usePatrolIntelligenceState() {
void Promise.allSettled([
aiIntelligenceStore.loadIntelligenceSummary(),
aiIntelligenceStore.loadFindings(),
aiIntelligenceStore.loadCircuitBreakerStatus(),
]);
}
@@ -41,7 +41,6 @@ const { findingsPanelState, runHistoryState, intelligenceState } = vi.hoisted(()
},
intelligenceState: {
findings: [] as Array<Record<string, unknown>>,
circuitBreakerStatus: null as { state: string; consecutive_failures: number } | null,
summary: null as {
timestamp: string;
overall_health: {
@@ -254,13 +253,11 @@ vi.mock('@/stores/aiIntelligence', () => {
loadFindings: vi.fn().mockResolvedValue(undefined),
loadPatrolFindings: vi.fn().mockResolvedValue(undefined),
loadIntelligenceSummary: vi.fn().mockResolvedValue(undefined),
loadCircuitBreakerStatus: vi.fn().mockResolvedValue(undefined),
loadPendingApprovals: vi.fn().mockResolvedValue(undefined),
loadDashboardData: vi.fn().mockImplementation(async () => {
await Promise.all([
store.loadFindings(),
store.loadIntelligenceSummary(),
store.loadCircuitBreakerStatus(),
store.loadPendingApprovals(),
]);
}),
@@ -273,9 +270,6 @@ vi.mock('@/stores/aiIntelligence', () => {
get intelligenceSummary() {
return intelligenceState.summary;
},
get circuitBreakerStatus() {
return intelligenceState.circuitBreakerStatus;
},
get patrolPendingApprovals() {
return [];
},
@@ -631,7 +625,6 @@ describe('AIIntelligence entitlement gating', () => {
findingsPanelState.latestProps = null;
runHistoryState.selection = null;
intelligenceState.findings = [];
intelligenceState.circuitBreakerStatus = null;
intelligenceState.summary = null;
getPatrolStatusMock.mockResolvedValue(defaultPatrolStatus());
@@ -6,7 +6,6 @@ vi.mock('@/api/ai', () => ({
getRemediationPlans: vi.fn(),
getPendingApprovals: vi.fn(),
getIntelligenceSummary: vi.fn(),
getCircuitBreakerStatus: vi.fn(),
},
}));
@@ -622,8 +621,6 @@ describe('aiIntelligenceStore', () => {
incidents_tracked: 0,
},
});
vi.mocked(AIAPI.getCircuitBreakerStatus).mockResolvedValueOnce(null as never);
await aiIntelligenceStore.loadDashboardData();
expect(AIAPI.getIntelligenceSummary).toHaveBeenCalledTimes(1);
@@ -631,13 +628,13 @@ describe('aiIntelligenceStore', () => {
// These are every AIAPI read the bundle makes (Patrol findings come from
// the Patrol API module). It does not load the global learned-correlation
// list: no page renders it, and the resource drawer reads per-resource
// correlations from resource intelligence instead.
// correlations from resource intelligence instead. Nor does it load the
// provider circuit-breaker status, which no surface renders.
const aiReads = Object.entries(AIAPI)
.filter(([, read]) => vi.isMockFunction(read) && read.mock.calls.length > 0)
.map(([name]) => name)
.sort();
expect(aiReads).toEqual([
'getCircuitBreakerStatus',
'getIntelligenceSummary',
'getPendingApprovals',
'getUnifiedFindings',
@@ -5,7 +5,6 @@
* - Unified findings (alerts + AI findings)
* - Canonical intelligence summary
* - Remediation plans
* - Circuit breaker status
*/
import { createSignal } from 'solid-js';
@@ -25,7 +24,6 @@ import {
import type { ResourceCriticality } from '@/api/resourceOperatorState';
import type {
RemediationPlan,
CircuitBreakerStatus,
UnifiedFindingRecord,
InvestigationRecord,
ApprovalRequest,
@@ -449,14 +447,6 @@ function getPatrolOpenWorkCount(): number {
return activeFindingIds.size;
}
// ============================================
// Circuit Breaker
// ============================================
const [circuitBreakerStatus, setCircuitBreakerStatus] = createSignal<CircuitBreakerStatus | null>(
null,
);
// ============================================
// Canonical Intelligence Summary
// ============================================
@@ -482,7 +472,6 @@ eventBus.on('org_switched', () => {
setPlansError(null);
setPendingApprovalsWithExpiryTracking([]);
setApprovalsError(null);
setCircuitBreakerStatus(null);
setIntelligenceSummary(null);
});
@@ -796,12 +785,6 @@ export const aiIntelligenceStore = {
}
},
// Circuit Breaker
get circuitBreakerStatus() {
return circuitBreakerStatus();
},
circuitBreakerStatusSignal: circuitBreakerStatus,
// Canonical Intelligence Summary
get intelligenceSummary() {
return intelligenceSummary();
@@ -813,7 +796,6 @@ export const aiIntelligenceStore = {
this.loadIntelligenceSummary(),
this.loadFindings(),
this.loadPatrolFindings(),
this.loadCircuitBreakerStatus(),
this.loadPendingApprovals(),
]);
},
@@ -830,15 +812,6 @@ export const aiIntelligenceStore = {
}
},
async loadCircuitBreakerStatus() {
try {
const status = await AIAPI.getCircuitBreakerStatus();
setCircuitBreakerStatus(status);
} catch (e) {
logger.error('Failed to load circuit breaker status:', e);
}
},
// Initialize - load all data
async initialize() {
await Promise.all([this.loadDashboardData(), this.loadRemediationPlans()]);
@@ -349,20 +349,6 @@ async function mockBlockedPatrolRuntimeState(
});
});
await page.route("**/api/ai/circuit/status", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
state: "closed",
can_patrol: true,
consecutive_failures: 0,
total_successes: 42,
total_failures: 0,
}),
});
});
await page.route("**/api/ai/approvals", async (route) => {
await route.fulfill({
status: 200,
@@ -523,20 +509,6 @@ async function mockScopedTriggerPatrolRuntimeState(
});
});
await page.route("**/api/ai/circuit/status", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
state: "closed",
can_patrol: true,
consecutive_failures: 0,
total_successes: 42,
total_failures: 0,
}),
});
});
await page.route("**/api/ai/approvals", async (route) => {
await route.fulfill({
status: 200,
@@ -285,20 +285,6 @@ async function mockRetiredQuickstartSurface(
});
});
await page.route("**/api/ai/circuit/status", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
state: "closed",
can_patrol: false,
consecutive_failures: 0,
total_successes: 0,
total_failures: 0,
}),
});
});
await page.route("**/api/ai/approvals", async (route) => {
await route.fulfill({
status: 200,
@@ -541,20 +541,6 @@ test.describe("Patrol Assistant operator briefing", () => {
});
});
await page.route("**/api/ai/circuit/status", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
state: "closed",
can_patrol: true,
consecutive_failures: 0,
total_successes: 42,
total_failures: 0,
}),
});
});
await page.route("**/api/ai/approvals", async (route) => {
await route.fulfill({
status: 200,
@@ -476,20 +476,6 @@ async function mockMonitorFirstPatrolWorkbench(
});
});
await page.route("**/api/ai/circuit/status", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
state: "closed",
can_patrol: true,
consecutive_failures: 0,
total_successes: 4,
total_failures: 0,
}),
});
});
await page.route("**/api/ai/approvals", async (route) => {
const approvals =
resolved.pendingApprovalCount > 0
@@ -493,19 +493,6 @@ async function mockAttention(
}),
});
});
await page.route("**/api/ai/circuit/status", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
state: "closed",
can_patrol: true,
consecutive_failures: 0,
total_successes: 1,
total_failures: 0,
}),
});
});
await page.route("**/api/ai/approvals", async (route) => {
await route.fulfill({
status: 200,
@@ -730,15 +730,6 @@ async function routePatrolSupport(
},
});
});
await page.route("**/api/ai/circuit/status", async (route) => {
await fulfillJSON(route, {
state: "closed",
can_patrol: true,
consecutive_failures: 0,
total_successes: 1,
total_failures: 0,
});
});
await page.route("**/api/ai/approvals", async (route) => {
await fulfillJSON(route, { approvals: [] });
});