Stop Patrol fetching the circuit-breaker status nobody reads

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. Its only reader was the circuitBreakerStatus memo in
usePatrolIntelligenceState, which nothing read and which the previous
commit removed. Nothing else renders breaker state: 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.

Remove the store's breaker signal, getter, signal export, loader,
org-switch reset and dashboard-bundle entry, the hook's background read,
and AIAPI.getCircuitBreakerStatus with its CircuitBreakerStatus type.
The client's unit tests, the store and page test mocks, and seven E2E
route stubs go with them. The store bundle test still pins the exact
AIAPI read set. The backend route stays for API users and diagnostics.

Production builds before and after differ only in the removed code
(check-bundle-size passes). A Playwright pass on private demo stacks at
1440 and 390 px shows the breaker read going from 1-2 requests per
phase to none across Patrol load, tab hide/show and stale-data Retry,
with every other endpoint and the Assistant handoff unchanged. A forced
circuit_open Patrol status still shows "Patrol paused / circuit breaker
is open" on both builds.
This commit is contained in:
rcourtman
2026-10-07 17:50:51 +01:00
parent 3e6e9c3ffb
commit 8b18ec2efb
17 changed files with 66 additions and 205 deletions
@@ -6728,6 +6728,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
@@ -7305,6 +7305,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
@@ -6822,7 +6822,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
@@ -2181,10 +2181,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,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: [] });
});