diff --git a/README.md b/README.md index fcd572fd..bb1bd786 100644 --- a/README.md +++ b/README.md @@ -598,23 +598,20 @@ To make your version of a tool usable with a one-line `npx` command: # Health checks -`src/lib/health-checks/` checks external status pages and PostHog-owned -services before the wizard runs to decide whether it can proceed. The entry -point is `evaluateWizardReadiness()`, which returns one of three values: +`src/lib/health-checks/` checks skills download origins before the wizard runs. +The entry point is `evaluateWizardReadiness()`, which only blocks on skill downloads: | Decision | Meaning | | ------------------- | --------------------------------------------------------------- | -| `yes` | All services healthy — proceed normally. | -| `yes_with_warnings` | Some services degraded but no critical dependency is down. | -| `no` | A critical dependency is down or degraded — do not run. | +| `yes` | Skills are reachable — proceed without outage warnings. | +| `no` | Neither skills origin is reachable — do not run. | ### Module layout | File | Responsibility | | --- | --- | | `types.ts` | Enums, interfaces (`ServiceHealthStatus`, `AllServicesHealth`, etc.) | -| `statuspage.ts` | Statuspage.io v2 API helpers + checks for Anthropic, PostHog, GitHub, npm, Cloudflare | -| `endpoints.ts` | Direct endpoint checks for MCP (`/`) and the skills origins (`skill-menu.json` on GitHub Releases + the AWS mirror) | +| `endpoints.ts` | Direct gateway (`/readyz`) and skills origin (`skill-menu.json`) checks | | `readiness.ts` | `checkAllExternalServices`, `evaluateWizardReadiness`, readiness config | | `index.ts` | Barrel re-export | | `testme.md` | Test running instructions and endpoint reference | @@ -632,10 +629,13 @@ two arrays: ### Current defaults ```ts -downBlocksRun: ['anthropic', 'npmOverall', 'mcp', 'skillsOrigin'], -degradedBlocksRun: ['anthropic'], +downBlocksRun: ['skillsOrigin'], ``` +The same policy applies during signup. Third-party status pages are not queried. +After minting a token, `gateway-session.ts` checks `/readyz` on the returned +gateway URL and reports an unavailable gateway through the existing error path. + `skillsOrigin` is one entry covering two origins: skills are published to GitHub Releases and an AWS mirror under the same filenames, and downloads fail over between them (`src/lib/fetch-retry.ts`). Both are probed in parallel, so diff --git a/src/lib/__tests__/gateway-session.test.ts b/src/lib/__tests__/gateway-session.test.ts index 1a7a6c3b..8b02b913 100644 --- a/src/lib/__tests__/gateway-session.test.ts +++ b/src/lib/__tests__/gateway-session.test.ts @@ -14,6 +14,12 @@ import { setLegacyGatewayFallback } from '@lib/legacy-gateway'; import { WizardError } from '@utils/wizard-abort'; import { analytics } from '@utils/analytics'; import { logToFile } from '@utils/debug'; +import { checkLlmGatewayHealth } from '@lib/health-checks/endpoints'; +import { ServiceHealthStatus } from '@lib/health-checks/types'; + +vi.mock('@lib/health-checks/endpoints', () => ({ + checkLlmGatewayHealth: vi.fn(), +})); vi.mock('@utils/analytics', () => ({ analytics: { wizardCapture: vi.fn(), captureException: vi.fn() }, @@ -49,6 +55,9 @@ describe('gatewayAuth', () => { beforeEach(() => { resetGatewaySession(); fetchMock.mockReset(); + vi.mocked(checkLlmGatewayHealth) + .mockReset() + .mockResolvedValue({ status: ServiceHealthStatus.Healthy }); vi.mocked(analytics.wizardCapture).mockClear(); vi.mocked(logToFile).mockClear(); vi.stubGlobal('fetch', fetchMock); @@ -90,8 +99,41 @@ describe('gatewayAuth', () => { // Second resolve inside the TTL reuses the cache, so no second mint. await gatewayAuth(host, 'pha_oauth', 'integration'); expect(fetchMock).toHaveBeenCalledTimes(1); + expect(checkLlmGatewayHealth).toHaveBeenCalledExactlyOnceWith( + 'https://gateway.us.posthog.com', + ); }); + it.each([ServiceHealthStatus.Down, ServiceHealthStatus.NoConnection])( + 'reports gateway %s without exposing diagnostics or caching auth', + async (status) => { + fetchMock.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + token: 'phe_minted', + expires_at: new Date(Date.now() + 3600_000).toISOString(), + gateway_url: 'https://ai-gateway.us.posthog.com', + }), + }); + vi.mocked(checkLlmGatewayHealth).mockResolvedValueOnce({ + status, + error: 'private dependency details', + }); + await expect( + gatewayAuth(host, 'pha_oauth', 'integration'), + ).rejects.toMatchObject({ + name: 'WizardError', + code: ErrorCodes.EnvServiceOutage, + message: + 'The PostHog AI gateway is unavailable. Please try again later.', + }); + await gatewayAuth(host, 'pha_oauth', 'integration'); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(checkLlmGatewayHealth).toHaveBeenCalledTimes(2); + }, + ); + it('records a successful mint without ever logging the token', async () => { fetchMock.mockResolvedValue({ ok: true, diff --git a/src/lib/gateway-session.ts b/src/lib/gateway-session.ts index 04a707b2..d42e38c5 100644 --- a/src/lib/gateway-session.ts +++ b/src/lib/gateway-session.ts @@ -12,6 +12,8 @@ import { analytics } from '@utils/analytics'; import { WizardError } from '@utils/wizard-abort'; import { ErrorCodes } from '@lib/errors'; import type { HostResolution } from '@lib/host-resolution'; +import { checkLlmGatewayHealth } from '@lib/health-checks/endpoints'; +import { ServiceHealthStatus } from '@lib/health-checks/types'; import { legacyGatewayAuth } from '@lib/legacy-gateway'; export interface GatewayAuth { @@ -109,6 +111,14 @@ async function resolveGatewayAuth( cached = { key, auth: legacy, staleAtMs: legacy.refreshAtMs }; return legacy; } + const health = await checkLlmGatewayHealth(minted.gatewayUrl); + if (health.status !== ServiceHealthStatus.Healthy) { + throw new WizardError( + 'The PostHog AI gateway is unavailable. Please try again later.', + undefined, + ErrorCodes.EnvServiceOutage, + ); + } const expiresAtMs = Date.parse(minted.expiresAt); const ttlMs = expiresAtMs - Date.now(); if (!Number.isFinite(expiresAtMs) || ttlMs < MIN_USABLE_TTL_MS) { diff --git a/src/lib/health-checks/__tests__/health-checks.test.ts b/src/lib/health-checks/__tests__/health-checks.test.ts index cbafb207..b23b7e2c 100644 --- a/src/lib/health-checks/__tests__/health-checks.test.ts +++ b/src/lib/health-checks/__tests__/health-checks.test.ts @@ -1,254 +1,25 @@ -/** - * Tests for health-checks.ts - * - * Mock data is modelled on live Statuspage.io v2 API responses. - * Statuspage docs: https://metastatuspage.com/api - * - * status.json – page-level rollup with indicator (none | minor | major | critical) - * summary.json – same rollup plus component list; component statuses: - * operational | degraded_performance | partial_outage | major_outage | under_maintenance - * https://support.atlassian.com/statuspage/docs/show-service-status-with-components - - * - * MCP – Cloudflare Worker, GET / returns an HTML landing page (200) - * Source: posthog/services/mcp/src/index.ts - */ - import { checkAllExternalServices, - checkAnthropicHealth, - checkCloudflareComponentHealth, - checkCloudflareOverallHealth, - checkGithubHealth, checkSkillsOriginHealth, - checkMcpHealth, - checkNpmComponentHealth, - checkNpmOverallHealth, - checkPosthogComponentHealth, - checkPosthogOverallHealth, - resetPosthogHealthCache, DEFAULT_WIZARD_READINESS_CONFIG, evaluateWizardReadiness, ServiceHealthStatus, WizardReadiness, } from '@lib/health-checks/index'; -import { fetchEndpointHealth } from '@lib/health-checks/endpoints'; - -// --------------------------------------------------------------------------- -// Real-world Statuspage.io v2 response factories -// https://metastatuspage.com/api -// --------------------------------------------------------------------------- - -function makeStatuspageStatus(opts: { - pageId: string; - pageName: string; - pageUrl: string; - indicator: 'none' | 'minor' | 'major' | 'critical'; - description: string; -}) { - return { - page: { - id: opts.pageId, - name: opts.pageName, - url: opts.pageUrl, - time_zone: 'Etc/UTC', - updated_at: '2026-03-05T16:03:38.861Z', - }, - status: { - indicator: opts.indicator, - description: opts.description, - }, - }; -} - -function makeStatuspageSummary(opts: { - pageId: string; - pageName: string; - pageUrl: string; - indicator: 'none' | 'minor' | 'major' | 'critical'; - description: string; - components: { - id: string; - name: string; - status: string; - position: number; - description: string | null; - }[]; -}) { - return { - page: { - id: opts.pageId, - name: opts.pageName, - url: opts.pageUrl, - time_zone: 'Etc/UTC', - updated_at: '2026-03-05T16:03:38.861Z', - }, - status: { - indicator: opts.indicator, - description: opts.description, - }, - components: opts.components.map((c) => ({ - ...c, - page_id: opts.pageId, - created_at: '2023-07-11T17:52:24.275Z', - updated_at: '2026-03-04T17:01:29.960Z', - showcase: true, - start_date: '2023-07-11', - group_id: null, - group: false, - only_show_if_degraded: false, - })), - incidents: [], - scheduled_maintenances: [], - }; -} - -// Shapes taken from live GET on 2026-03-05 -const ANTHROPIC_STATUS_HEALTHY = makeStatuspageStatus({ - pageId: 'tymt9n04zgry', - pageName: 'Claude', - pageUrl: 'https://status.claude.com', - indicator: 'none', - description: 'All Systems Operational', -}); - -const GITHUB_STATUS_HEALTHY = makeStatuspageStatus({ - pageId: 'kctbh9vrtdwd', - pageName: 'GitHub', - pageUrl: 'https://www.githubstatus.com', - indicator: 'none', - description: 'All Systems Operational', -}); - -const NPM_STATUS_HEALTHY = makeStatuspageStatus({ - pageId: 'wyvgptkd90hm', - pageName: 'npm', - pageUrl: 'https://status.npmjs.org', - indicator: 'none', - description: 'All Systems Operational', -}); - -const NPM_SUMMARY_HEALTHY = makeStatuspageSummary({ - pageId: 'wyvgptkd90hm', - pageName: 'npm', - pageUrl: 'https://status.npmjs.org', - indicator: 'none', - description: 'All Systems Operational', - components: [ - { - id: 'mvm98gtxvb9b', - name: 'www.npmjs.com website', - status: 'operational', - position: 1, - description: - 'The ability for users to navigate to or interact with the npm website.', - }, - { - id: 'k1wj10x6gmph', - name: 'Package installation', - status: 'operational', - position: 2, - description: - 'The ability for users to read from the registry so that they can install packages.', - }, - ], -}); - -const CLOUDFLARE_STATUS_HEALTHY = makeStatuspageStatus({ - pageId: 'yh6f0r4529hb', - pageName: 'Cloudflare', - pageUrl: 'https://www.cloudflarestatus.com', - indicator: 'none', - description: 'All Systems Operational', -}); - -const CLOUDFLARE_SUMMARY_HEALTHY = makeStatuspageSummary({ - pageId: 'yh6f0r4529hb', - pageName: 'Cloudflare', - pageUrl: 'https://www.cloudflarestatus.com', - indicator: 'none', - description: 'All Systems Operational', - components: [ - { - id: '1km35smx8p41', - name: 'Cloudflare Sites and Services', - status: 'operational', - position: 1, - description: - 'Sites and services that Cloudflare customers use to interact with the Cloudflare Network', - }, - ], -}); - -// PostHog incident.io v1 API mock data -const POSTHOG_INCIDENTIO_HEALTHY = { - page_title: 'PostHog', - page_url: 'https://www.posthogstatus.com/', - ongoing_incidents: [], - in_progress_maintenances: [], - scheduled_maintenances: [], -}; - -// MCP / landing page (from posthog/services/mcp/src/index.ts + src/static/landing.html) -const MCP_LANDING_HTML = - 'PostHog MCP Server'; - -// --------------------------------------------------------------------------- -// URL constants (must match health-checks.ts) -// --------------------------------------------------------------------------- +import { + checkLlmGatewayHealth, + fetchEndpointHealth, +} from '@lib/health-checks/endpoints'; +import { SIGNUP_WIZARD_READINESS_CONFIG } from '@lib/health-checks/readiness'; const URLS = { - anthropicStatus: 'https://status.claude.com/api/v2/status.json', - posthogIncidentIo: 'https://www.posthogstatus.com/api/v1/summary', - githubStatus: 'https://www.githubstatus.com/api/v2/status.json', - npmStatus: 'https://status.npmjs.org/api/v2/status.json', - npmSummary: 'https://status.npmjs.org/api/v2/summary.json', - cloudflareStatus: 'https://www.cloudflarestatus.com/api/v2/status.json', - cloudflareSummary: 'https://www.cloudflarestatus.com/api/v2/summary.json', - mcpLanding: 'https://mcp.posthog.com/', githubSkillMenu: 'https://github.com/PostHog/context-mill/releases/latest/download/skill-menu.json', awsSkillMenu: 'https://context-mill.posthog.com/latest/skill-menu.json', } as const; -// --------------------------------------------------------------------------- -// Helper to build a default "all healthy" fetch mock -// --------------------------------------------------------------------------- - const HEALTHY_RESPONSES: Record = { - [URLS.anthropicStatus]: { - body: JSON.stringify(ANTHROPIC_STATUS_HEALTHY), - contentType: 'application/json', - }, - [URLS.posthogIncidentIo]: { - body: JSON.stringify(POSTHOG_INCIDENTIO_HEALTHY), - contentType: 'application/json', - }, - [URLS.githubStatus]: { - body: JSON.stringify(GITHUB_STATUS_HEALTHY), - contentType: 'application/json', - }, - [URLS.npmStatus]: { - body: JSON.stringify(NPM_STATUS_HEALTHY), - contentType: 'application/json', - }, - [URLS.npmSummary]: { - body: JSON.stringify(NPM_SUMMARY_HEALTHY), - contentType: 'application/json', - }, - [URLS.cloudflareStatus]: { - body: JSON.stringify(CLOUDFLARE_STATUS_HEALTHY), - contentType: 'application/json', - }, - [URLS.cloudflareSummary]: { - body: JSON.stringify(CLOUDFLARE_SUMMARY_HEALTHY), - contentType: 'application/json', - }, - [URLS.mcpLanding]: { - body: MCP_LANDING_HTML, - contentType: 'text/html; charset=utf-8', - }, [URLS.githubSkillMenu]: { body: JSON.stringify({ categories: { integration: [] } }), contentType: 'application/json', @@ -291,16 +62,11 @@ function overrideFetch(overrides: Record Promise>) { }; } -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - describe('health-checks', () => { const originalFetch = global.fetch; beforeEach(() => { vi.restoreAllMocks(); - resetPosthogHealthCache(); (global as any).fetch = vi.fn(allHealthyFetchMock); }); @@ -308,415 +74,6 @@ describe('health-checks', () => { (global as any).fetch = originalFetch; }); - // ----------------------------------------------------------------------- - // Statuspage status.json checks (indicator-based) - // ----------------------------------------------------------------------- - - describe('checkAnthropicHealth', () => { - it('returns healthy for indicator=none ("All Systems Operational")', async () => { - const result = await checkAnthropicHealth(); - expect(result.status).toBe(ServiceHealthStatus.Healthy); - expect(result.rawIndicator).toBe('none'); - }); - - it('returns degraded for indicator=minor ("Minor Service Outage")', async () => { - const body = makeStatuspageStatus({ - pageId: 'tymt9n04zgry', - pageName: 'Claude', - pageUrl: 'https://status.claude.com', - indicator: 'minor', - description: 'Minor Service Outage', - }); - (global.fetch as Mock).mockImplementation( - overrideFetch({ - [URLS.anthropicStatus]: () => - Promise.resolve( - new Response(JSON.stringify(body), { status: 200 }), - ), - }), - ); - const result = await checkAnthropicHealth(); - expect(result.status).toBe(ServiceHealthStatus.Degraded); - expect(result.rawIndicator).toBe('minor'); - }); - - it('returns down for indicator=major ("Partial System Outage")', async () => { - const body = makeStatuspageStatus({ - pageId: 'tymt9n04zgry', - pageName: 'Claude', - pageUrl: 'https://status.claude.com', - indicator: 'major', - description: 'Partial System Outage', - }); - (global.fetch as Mock).mockImplementation( - overrideFetch({ - [URLS.anthropicStatus]: () => - Promise.resolve( - new Response(JSON.stringify(body), { status: 200 }), - ), - }), - ); - const result = await checkAnthropicHealth(); - expect(result.status).toBe(ServiceHealthStatus.Down); - }); - - it('returns down for indicator=critical ("Major Service Outage")', async () => { - const body = makeStatuspageStatus({ - pageId: 'tymt9n04zgry', - pageName: 'Claude', - pageUrl: 'https://status.claude.com', - indicator: 'critical', - description: 'Major Service Outage', - }); - (global.fetch as Mock).mockImplementation( - overrideFetch({ - [URLS.anthropicStatus]: () => - Promise.resolve( - new Response(JSON.stringify(body), { status: 200 }), - ), - }), - ); - const result = await checkAnthropicHealth(); - expect(result.status).toBe(ServiceHealthStatus.Down); - }); - - it('returns degraded when statuspage returns HTTP 500', async () => { - (global.fetch as Mock).mockImplementation( - overrideFetch({ - [URLS.anthropicStatus]: () => - Promise.resolve( - new Response('Internal Server Error', { status: 500 }), - ), - }), - ); - const result = await checkAnthropicHealth(); - expect(result.status).toBe(ServiceHealthStatus.Degraded); - expect(result.error).toBe('HTTP 500'); - }); - - it('returns degraded when fetch throws (network failure)', async () => { - (global.fetch as Mock).mockImplementation( - overrideFetch({ - [URLS.anthropicStatus]: () => - Promise.reject( - new Error('getaddrinfo ENOTFOUND status.claude.com'), - ), - }), - ); - const result = await checkAnthropicHealth(); - expect(result.status).toBe(ServiceHealthStatus.Degraded); - expect(result.error).toBe('getaddrinfo ENOTFOUND status.claude.com'); - }); - }); - - describe('checkPosthogOverallHealth', () => { - it('returns healthy when no ongoing incidents', async () => { - const result = await checkPosthogOverallHealth(); - expect(result.status).toBe(ServiceHealthStatus.Healthy); - }); - - it('returns down when an incident has full_outage impact', async () => { - const body = { - ...POSTHOG_INCIDENTIO_HEALTHY, - ongoing_incidents: [ - { - id: '01KA9JH0ZB14TFA8VD4CFC3AYN', - name: 'Major service outage', - status: 'identified', - current_worst_impact: 'full_outage', - affected_components: [ - { - id: 'c1', - name: 'App', - group_name: 'US Cloud', - current_status: 'full_outage', - }, - ], - url: 'https://www.posthogstatus.com/incidents/test', - last_update_at: '2026-04-22T00:00:00Z', - last_update_message: 'Investigating', - }, - ], - }; - (global.fetch as Mock).mockImplementation( - overrideFetch({ - [URLS.posthogIncidentIo]: () => - Promise.resolve( - new Response(JSON.stringify(body), { status: 200 }), - ), - }), - ); - const result = await checkPosthogOverallHealth(); - expect(result.status).toBe(ServiceHealthStatus.Down); - }); - - it('returns NoConnection when posthogstatus.com fetch fails with a network error', async () => { - (global.fetch as Mock).mockImplementation( - overrideFetch({ - [URLS.posthogIncidentIo]: () => - Promise.reject(new Error('getaddrinfo ENOTFOUND')), - }), - ); - const result = await checkPosthogOverallHealth(); - expect(result.status).toBe(ServiceHealthStatus.NoConnection); - }); - - it('returns NoConnection when posthogstatus.com fetch times out', async () => { - const abortError = new Error('aborted'); - abortError.name = 'AbortError'; - (global.fetch as Mock).mockImplementation( - overrideFetch({ - [URLS.posthogIncidentIo]: () => Promise.reject(abortError), - }), - ); - const result = await checkPosthogOverallHealth(); - expect(result.status).toBe(ServiceHealthStatus.NoConnection); - }); - - it('returns Down when posthogstatus.com returns an HTTP error', async () => { - (global.fetch as Mock).mockImplementation( - overrideFetch({ - [URLS.posthogIncidentIo]: () => - Promise.resolve(new Response('Bad Gateway', { status: 502 })), - }), - ); - const result = await checkPosthogOverallHealth(); - expect(result.status).toBe(ServiceHealthStatus.Down); - }); - - it('returns degraded when an incident has partial_outage impact', async () => { - const body = { - ...POSTHOG_INCIDENTIO_HEALTHY, - ongoing_incidents: [ - { - id: '01KA9JH0ZB14TFA8VD4CFC3AYN', - name: 'Partial outage', - status: 'investigating', - current_worst_impact: 'partial_outage', - affected_components: [], - url: 'https://www.posthogstatus.com/incidents/test', - last_update_at: '2026-04-22T00:00:00Z', - last_update_message: 'Investigating', - }, - ], - }; - (global.fetch as Mock).mockImplementation( - overrideFetch({ - [URLS.posthogIncidentIo]: () => - Promise.resolve( - new Response(JSON.stringify(body), { status: 200 }), - ), - }), - ); - const result = await checkPosthogOverallHealth(); - expect(result.status).toBe(ServiceHealthStatus.Degraded); - }); - }); - - describe('checkGithubHealth', () => { - it('returns healthy for indicator=none', async () => { - const result = await checkGithubHealth(); - expect(result.status).toBe(ServiceHealthStatus.Healthy); - }); - }); - - describe('checkNpmOverallHealth', () => { - it('returns healthy for indicator=none', async () => { - const result = await checkNpmOverallHealth(); - expect(result.status).toBe(ServiceHealthStatus.Healthy); - }); - }); - - describe('checkCloudflareOverallHealth', () => { - it('returns healthy for indicator=none', async () => { - const result = await checkCloudflareOverallHealth(); - expect(result.status).toBe(ServiceHealthStatus.Healthy); - }); - - it('returns degraded for indicator=minor', async () => { - const body = makeStatuspageStatus({ - pageId: 'yh6f0r4529hb', - pageName: 'Cloudflare', - pageUrl: 'https://www.cloudflarestatus.com', - indicator: 'minor', - description: 'Minor Service Outage', - }); - (global.fetch as Mock).mockImplementation( - overrideFetch({ - [URLS.cloudflareStatus]: () => - Promise.resolve( - new Response(JSON.stringify(body), { status: 200 }), - ), - }), - ); - const result = await checkCloudflareOverallHealth(); - expect(result.status).toBe(ServiceHealthStatus.Degraded); - }); - }); - - // ----------------------------------------------------------------------- - // Statuspage summary.json checks (component-based) - // ----------------------------------------------------------------------- - - describe('checkPosthogComponentHealth', () => { - it('reports healthy when no ongoing incidents', async () => { - const result = await checkPosthogComponentHealth(); - expect(result.status).toBe(ServiceHealthStatus.Healthy); - expect(result.degradedOrDownComponents).toBeUndefined(); - }); - - it('reports affected components from ongoing incidents', async () => { - const body = { - ...POSTHOG_INCIDENTIO_HEALTHY, - ongoing_incidents: [ - { - id: 'inc1', - name: 'US Cloud outage', - status: 'identified', - current_worst_impact: 'full_outage', - affected_components: [ - { - id: 'c1', - name: 'App', - group_name: 'US Cloud 🇺🇸', - current_status: 'full_outage', - }, - { - id: 'c2', - name: 'Event Ingestion', - group_name: 'US Cloud 🇺🇸', - current_status: 'full_outage', - }, - ], - url: 'https://www.posthogstatus.com/incidents/test', - last_update_at: '2026-04-22T00:00:00Z', - last_update_message: 'Investigating', - }, - ], - }; - (global.fetch as Mock).mockImplementation( - overrideFetch({ - [URLS.posthogIncidentIo]: () => - Promise.resolve( - new Response(JSON.stringify(body), { status: 200 }), - ), - }), - ); - const result = await checkPosthogComponentHealth(); - expect(result.status).toBe(ServiceHealthStatus.Degraded); - expect(result.degradedOrDownComponents).toHaveLength(2); - expect(result.degradedOrDownComponents![0].name).toBe( - 'US Cloud 🇺🇸 — App', - ); - expect(result.degradedOrDownComponents![0].status).toBe( - ServiceHealthStatus.Down, - ); - expect(result.degradedOrDownComponents![1].status).toBe( - ServiceHealthStatus.Down, - ); - }); - - it('reports degraded for degraded_performance components', async () => { - const body = { - ...POSTHOG_INCIDENTIO_HEALTHY, - ongoing_incidents: [ - { - id: 'inc1', - name: 'Slowness', - status: 'investigating', - current_worst_impact: 'degraded_performance', - affected_components: [ - { - id: 'c1', - name: 'App', - group_name: 'EU Cloud', - current_status: 'degraded_performance', - }, - ], - url: 'https://www.posthogstatus.com/incidents/test', - last_update_at: '2026-04-22T00:00:00Z', - last_update_message: 'Investigating', - }, - ], - }; - (global.fetch as Mock).mockImplementation( - overrideFetch({ - [URLS.posthogIncidentIo]: () => - Promise.resolve( - new Response(JSON.stringify(body), { status: 200 }), - ), - }), - ); - const result = await checkPosthogComponentHealth(); - expect(result.status).toBe(ServiceHealthStatus.Degraded); - expect(result.degradedOrDownComponents![0].rawStatus).toBe( - 'degraded_performance', - ); - expect(result.degradedOrDownComponents![0].status).toBe( - ServiceHealthStatus.Degraded, - ); - }); - }); - - describe('checkNpmComponentHealth', () => { - it('reports healthy when all npm components operational', async () => { - const result = await checkNpmComponentHealth(); - expect(result.status).toBe(ServiceHealthStatus.Healthy); - }); - - it('reports degraded when "Package installation" has partial_outage', async () => { - const body = makeStatuspageSummary({ - pageId: 'wyvgptkd90hm', - pageName: 'npm', - pageUrl: 'https://status.npmjs.org', - indicator: 'major', - description: 'Partial System Outage', - components: [ - { - id: 'mvm98gtxvb9b', - name: 'www.npmjs.com website', - status: 'operational', - position: 1, - description: null, - }, - { - id: 'k1wj10x6gmph', - name: 'Package installation', - status: 'partial_outage', - position: 2, - description: null, - }, - ], - }); - (global.fetch as Mock).mockImplementation( - overrideFetch({ - [URLS.npmSummary]: () => - Promise.resolve( - new Response(JSON.stringify(body), { status: 200 }), - ), - }), - ); - const result = await checkNpmComponentHealth(); - expect(result.status).toBe(ServiceHealthStatus.Degraded); - expect(result.degradedOrDownComponents![0].name).toBe( - 'Package installation', - ); - }); - }); - - describe('checkCloudflareComponentHealth', () => { - it('reports healthy when Cloudflare components operational', async () => { - const result = await checkCloudflareComponentHealth(); - expect(result.status).toBe(ServiceHealthStatus.Healthy); - }); - }); - - // ----------------------------------------------------------------------- - // fetchEndpointHealth (retry + status-taxonomy machinery, probed directly - // against a synthetic URL — no production probe uses the strict defaults - // any more, but every endpoint check shares this loop) - // ----------------------------------------------------------------------- - describe('fetchEndpointHealth', () => { const PROBE_URL = 'https://probe.posthog.test/_liveness'; @@ -879,90 +236,27 @@ describe('health-checks', () => { }); }); - // ----------------------------------------------------------------------- - // MCP (fetchEndpointHealth – / landing) - // ----------------------------------------------------------------------- - - describe('checkMcpHealth', () => { - it('returns healthy when MCP worker responds 200 with landing HTML', async () => { - const result = await checkMcpHealth(); - expect(result.status).toBe(ServiceHealthStatus.Healthy); - expect(result.rawIndicator).toBe('HTTP 200'); - expect(global.fetch).toHaveBeenCalledWith( - URLS.mcpLanding, - expect.objectContaining({ signal: expect.any(AbortSignal) }), - ); - }); - - it('returns healthy when worker responds 302 (redirect to docs, not followed)', async () => { - (global.fetch as Mock).mockImplementation( - overrideFetch({ - [URLS.mcpLanding]: () => - Promise.resolve(new Response(null, { status: 302 })), - }), - ); - const result = await checkMcpHealth(); + it.each([ + 'https://ai-gateway.us.posthog.com', + 'https://ai-gateway.eu.posthog.com/', + 'http://localhost:8789/v1', + ])( + 'checks readiness on the minted gateway %s without credentials', + async (gatewayUrl) => { + (global.fetch as Mock).mockResolvedValue( + new Response(null, { status: 200 }), + ); + const result = await checkLlmGatewayHealth(gatewayUrl); expect(result.status).toBe(ServiceHealthStatus.Healthy); - expect(result.rawIndicator).toBe('HTTP 302'); expect(global.fetch).toHaveBeenCalledWith( - URLS.mcpLanding, - expect.objectContaining({ redirect: 'manual' }), - ); - }); - - it('returns down on 400 — only 2xx-3xx counts as up', async () => { - (global.fetch as Mock).mockImplementation( - overrideFetch({ - [URLS.mcpLanding]: () => - Promise.resolve(new Response('Bad Request', { status: 400 })), - }), - ); - const result = await checkMcpHealth(); - expect(result.status).toBe(ServiceHealthStatus.Down); - expect(result.error).toContain('HTTP 400'); - }); - - it('returns down when worker responds 500', async () => { - (global.fetch as Mock).mockImplementation( - overrideFetch({ - [URLS.mcpLanding]: () => - Promise.resolve( - new Response('Internal Server Error', { status: 500 }), - ), - }), - ); - const result = await checkMcpHealth(); - expect(result.status).toBe(ServiceHealthStatus.Down); - expect(result.error).toContain('HTTP 500'); - }); - - it('returns down when Cloudflare returns 522 (connection timed out)', async () => { - (global.fetch as Mock).mockImplementation( - overrideFetch({ - [URLS.mcpLanding]: () => - Promise.resolve(new Response('', { status: 522 })), - }), - ); - const result = await checkMcpHealth(); - expect(result.status).toBe(ServiceHealthStatus.Down); - expect(result.error).toContain('HTTP 522'); - }); - - it('returns no-connection on network failure', async () => { - (global.fetch as Mock).mockImplementation( - overrideFetch({ - [URLS.mcpLanding]: () => Promise.reject(new Error('fetch failed')), - }), + new URL('/readyz', gatewayUrl).href, + { + signal: expect.any(AbortSignal), + redirect: 'follow', + }, ); - const result = await checkMcpHealth(); - expect(result.status).toBe(ServiceHealthStatus.NoConnection); - expect(result.error).toBe('fetch failed'); - }); - }); - - // ----------------------------------------------------------------------- - // Skills origins (fetchEndpointHealth – skill-menu.json on both origins) - // ----------------------------------------------------------------------- + }, + ); describe('checkSkillsOriginHealth', () => { it('returns healthy on a final 200 and follows redirects (GitHub 302s asset URLs even for missing assets)', async () => { @@ -1073,118 +367,22 @@ describe('health-checks', () => { }); }); - // ----------------------------------------------------------------------- - // checkAllExternalServices - // ----------------------------------------------------------------------- - describe('checkAllExternalServices', () => { - it('returns all 10 service keys when everything is healthy', async () => { + it('returns skills health and only probes the two download origins', async () => { const health = await checkAllExternalServices(); - const keys = Object.keys(health); - expect(keys).toEqual( - expect.arrayContaining([ - 'anthropic', - 'posthogOverall', - 'posthogComponents', - 'github', - 'npmOverall', - 'npmComponents', - 'cloudflareOverall', - 'cloudflareComponents', - 'mcp', - 'skillsOrigin', - ]), - ); - expect(keys).toHaveLength(10); - for (const val of Object.values(health)) { - expect(val.status).toBe(ServiceHealthStatus.Healthy); - } - }); - - it('upgrades NoConnection mcp to Down when status page reports an outage', async () => { - const incidentBody = { - ...POSTHOG_INCIDENTIO_HEALTHY, - ongoing_incidents: [ - { - id: 'inc1', - name: 'Major outage', - status: 'identified', - current_worst_impact: 'full_outage', - affected_components: [], - url: 'https://www.posthogstatus.com/incidents/test', - last_update_at: '2026-04-22T00:00:00Z', - last_update_message: 'Investigating', - }, - ], - }; - (global.fetch as Mock).mockImplementation( - overrideFetch({ - [URLS.posthogIncidentIo]: () => - Promise.resolve( - new Response(JSON.stringify(incidentBody), { status: 200 }), - ), - [URLS.mcpLanding]: () => Promise.reject(new Error('ECONNRESET')), - }), - ); - - const health = await checkAllExternalServices(); - expect(health.posthogOverall.status).toBe(ServiceHealthStatus.Down); - expect(health.mcp.status).toBe(ServiceHealthStatus.Down); - expect(health.mcp.error).toContain('corroborated by status page'); - }); - - it('keeps mcp as NoConnection when posthogstatus.com itself is unreachable (the bug-fix scenario)', async () => { - // User on flaky wifi: every PostHog-owned URL fetch fails at the - // network layer, including posthogstatus.com. Previously - // incidentio.ts returned Degraded for fetch failures, which - // tricked reconciliation into upgrading the gateway probe to Down - // and showing the red "Ongoing service disruptions" screen — the - // exact false positive this PR fixes. - (global.fetch as Mock).mockImplementation( - overrideFetch({ - [URLS.posthogIncidentIo]: () => - Promise.reject(new Error('ECONNRESET')), - [URLS.mcpLanding]: () => Promise.reject(new Error('ECONNRESET')), - }), - ); - - const health = await checkAllExternalServices(); - expect(health.posthogOverall.status).toBe( - ServiceHealthStatus.NoConnection, - ); - expect(health.mcp.status).toBe(ServiceHealthStatus.NoConnection); - }); - - it('keeps mcp as NoConnection when status page reports no incident', async () => { - (global.fetch as Mock).mockImplementation( - overrideFetch({ - [URLS.mcpLanding]: () => Promise.reject(new Error('ETIMEDOUT')), - }), - ); - - const health = await checkAllExternalServices(); - expect(health.posthogOverall.status).toBe(ServiceHealthStatus.Healthy); - expect(health.mcp.status).toBe(ServiceHealthStatus.NoConnection); - }); - - it('fires all fetch calls in parallel', async () => { - await checkAllExternalServices(); - const calledUrls = (global.fetch as Mock).mock.calls.map((c: unknown[]) => - typeof c[0] === 'string' ? c[0] : (c[0] as URL).toString(), + expect(health).toEqual({ + skillsOrigin: { + status: ServiceHealthStatus.Healthy, + rawIndicator: 'HTTP 200', + }, + }); + const calledUrls = (global.fetch as Mock).mock.calls.map( + (call: unknown[]) => call[0], ); - // PostHog uses a single incident.io endpoint for both overall + components - expect(calledUrls).toHaveLength(10); - expect(calledUrls).toContain(URLS.posthogIncidentIo); - expect(calledUrls).toContain(URLS.mcpLanding); - expect(calledUrls).toContain(URLS.githubSkillMenu); - expect(calledUrls).toContain(URLS.awsSkillMenu); + expect(calledUrls).toEqual([URLS.githubSkillMenu, URLS.awsSkillMenu]); }); }); - // ----------------------------------------------------------------------- - // evaluateWizardReadiness - // ----------------------------------------------------------------------- - describe('evaluateWizardReadiness', () => { it('returns Yes when all services are healthy', async () => { const result = await evaluateWizardReadiness( @@ -1193,99 +391,36 @@ describe('health-checks', () => { expect(result.decision).toBe(WizardReadiness.Yes); }); - it('returns No when Anthropic is degraded (degradedBlocksRun)', async () => { - const body = makeStatuspageStatus({ - pageId: 'tymt9n04zgry', - pageName: 'Claude', - pageUrl: 'https://status.claude.com', - indicator: 'minor', - description: 'Minor Service Outage', - }); - (global.fetch as Mock).mockImplementation( - overrideFetch({ - [URLS.anthropicStatus]: () => - Promise.resolve( - new Response(JSON.stringify(body), { status: 200 }), - ), - }), - ); - const result = await evaluateWizardReadiness( - DEFAULT_WIZARD_READINESS_CONFIG, - ); - expect(result.decision).toBe(WizardReadiness.No); - expect(result.health.anthropic.status).toBe(ServiceHealthStatus.Degraded); - }); - - it('returns No when MCP is down (downBlocksRun)', async () => { - (global.fetch as Mock).mockImplementation( - overrideFetch({ - [URLS.mcpLanding]: () => - Promise.resolve(new Response('Bad Gateway', { status: 502 })), - }), - ); - const result = await evaluateWizardReadiness( - DEFAULT_WIZARD_READINESS_CONFIG, - ); - expect(result.decision).toBe(WizardReadiness.No); - expect(result.health.mcp.status).toBe(ServiceHealthStatus.Down); - }); - - it('returns No when npm overall is down (downBlocksRun)', async () => { - const body = makeStatuspageStatus({ - pageId: 'wyvgptkd90hm', - pageName: 'npm', - pageUrl: 'https://status.npmjs.org', - indicator: 'critical', - description: 'Major Service Outage', - }); - (global.fetch as Mock).mockImplementation( - overrideFetch({ - [URLS.npmStatus]: () => - Promise.resolve( - new Response(JSON.stringify(body), { status: 200 }), - ), - }), - ); - const result = await evaluateWizardReadiness( - DEFAULT_WIZARD_READINESS_CONFIG, - ); - expect(result.decision).toBe(WizardReadiness.No); - expect(result.health.npmOverall.status).toBe(ServiceHealthStatus.Down); - }); - - it('returns YesWithWarnings when a non-blocking service is degraded', async () => { - const body = makeStatuspageStatus({ - pageId: 'yh6f0r4529hb', - pageName: 'Cloudflare', - pageUrl: 'https://www.cloudflarestatus.com', - indicator: 'minor', - description: 'Minor Service Outage', - }); - (global.fetch as Mock).mockImplementation( - overrideFetch({ - [URLS.cloudflareStatus]: () => - Promise.resolve( - new Response(JSON.stringify(body), { status: 200 }), - ), - }), - ); - const result = await evaluateWizardReadiness( - DEFAULT_WIZARD_READINESS_CONFIG, - ); - expect(result.decision).toBe(WizardReadiness.YesWithWarnings); - }); - - it('includes human-readable reasons for every service', async () => { - const result = await evaluateWizardReadiness( - DEFAULT_WIZARD_READINESS_CONFIG, - ); - expect(result.reasons.length).toBeGreaterThan(0); - expect(result.reasons.some((r) => r.includes('Anthropic'))).toBe(true); - expect(result.reasons.some((r) => r.includes('PostHog'))).toBe(true); - expect(result.reasons.some((r) => r.includes('GitHub'))).toBe(true); - expect(result.reasons.some((r) => r.includes('npm'))).toBe(true); - expect(result.reasons.some((r) => r.includes('Cloudflare'))).toBe(true); - expect(result.reasons.some((r) => r.includes('MCP'))).toBe(true); - }); + it.each([ + [true, true, WizardReadiness.Yes], + [false, true, WizardReadiness.Yes], + [true, false, WizardReadiness.Yes], + [false, false, WizardReadiness.No], + ])( + 'skills availability: GitHub=%s AWS=%s yields %s', + async (github, aws, decision) => { + (global.fetch as Mock).mockImplementation((url: string | URL) => { + const healthy = + (url.toString() === URLS.githubSkillMenu && github) || + (url.toString() === URLS.awsSkillMenu && aws); + return Promise.resolve( + new Response(null, { status: healthy ? 200 : 503 }), + ); + }); + const results = await Promise.all([ + evaluateWizardReadiness(), + evaluateWizardReadiness(SIGNUP_WIZARD_READINESS_CONFIG), + ]); + for (const result of results) { + expect(result.decision).toBe(decision); + expect(result.reasons).toHaveLength( + decision === WizardReadiness.No ? 1 : 0, + ); + if (decision === WizardReadiness.No) { + expect(result.reasons[0]).toContain('Skills download'); + } + } + }, + ); }); }); diff --git a/src/lib/health-checks/endpoints.ts b/src/lib/health-checks/endpoints.ts index 2be4c5df..f98a9eb7 100644 --- a/src/lib/health-checks/endpoints.ts +++ b/src/lib/health-checks/endpoints.ts @@ -2,24 +2,7 @@ import { AWS_SKILLS_BASE_URL, GITHUB_SKILLS_BASE_URL } from '@lib/constants'; import { logToFile } from '@utils/debug'; import { ServiceHealthStatus, type BaseHealthResult } from './types'; -// --------------------------------------------------------------------------- -// Direct endpoint health checks -// -// These ping PostHog-owned services directly (no Statuspage intermediary). -// Result taxonomy: -// - HTTP 2xx-3xx (per `isExpectedStatus`) → Healthy -// - HTTP 4xx / 5xx → Down (confirmed) -// - Network error / DNS / timeout (after retries) → NoConnection -// NoConnection means we don't know whose fault it is; readiness reconciles -// against the status page before deciding how to surface it to the user. -// -// MCP – Cloudflare Worker -// Source: posthog/services/mcp/src/index.ts -// GET / → 302 to posthog.com docs. The redirect proves the worker is up. -// -// Skills download – context-mill releases -// GET /skill-menu.json on both origins; see checkSkillsOriginHealth. -// --------------------------------------------------------------------------- +// Direct gateway and skill-origin checks distinguish HTTP failures from connection failures. function noConnectionResult(error: string, attempts: number): BaseHealthResult { return { @@ -122,10 +105,7 @@ export async function fetchEndpointHealth( const result = lastHttpStatus !== null - ? downResult( - `HTTP ${lastHttpStatus} (attempts=${attempts})`, - lastHttpStatus, - ) + ? downResult(`HTTP ${lastHttpStatus} (attempts=${attempts})`) : noConnectionResult(lastError, attempts); logToFile( `[health-checks] GET ${url} -> ${result.status}` + @@ -134,14 +114,10 @@ export async function fetchEndpointHealth( return result; } -export const checkMcpHealth = (): Promise => - fetchEndpointHealth( - 'https://mcp.posthog.com/', - 5000, - // 2xx-3xx counts as up (redirect to docs) - (s) => s >= 200 && s < 400, - 'manual', - ); +export const checkLlmGatewayHealth = ( + gatewayUrl: string, +): Promise => + fetchEndpointHealth(new URL('/readyz', gatewayUrl).href); /** * Skills are published to two origins under the same filenames and diff --git a/src/lib/health-checks/incidentio.ts b/src/lib/health-checks/incidentio.ts deleted file mode 100644 index 574a4f0f..00000000 --- a/src/lib/health-checks/incidentio.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { - ServiceHealthStatus, - type BaseHealthResult, - type ComponentHealthResult, - type ComponentStatus, -} from './types'; - -interface IncidentIoAffectedComponent { - id: string; - name: string; - group_name?: string; - current_status: string; -} - -interface IncidentIoIncident { - id: string; - name: string; - status: string; - current_worst_impact: string; - affected_components: IncidentIoAffectedComponent[]; -} - -interface IncidentIoSummary { - ongoing_incidents: IncidentIoIncident[]; - in_progress_maintenances: unknown[]; -} - -function mapIncidentImpact(impact: string): ServiceHealthStatus { - switch (impact) { - case 'full_outage': - return ServiceHealthStatus.Down; - case 'partial_outage': - case 'degraded_performance': - return ServiceHealthStatus.Degraded; - default: - return ServiceHealthStatus.Degraded; - } -} - -function mapComponentStatus(status: string): ServiceHealthStatus { - switch (status) { - case 'operational': - return ServiceHealthStatus.Healthy; - case 'full_outage': - return ServiceHealthStatus.Down; - case 'partial_outage': - case 'degraded_performance': - return ServiceHealthStatus.Degraded; - default: - return ServiceHealthStatus.Degraded; - } -} - -/** - * Build an error result for fetch failures. The kind matters for - * downstream reconciliation: - * - * - 'http' (incident.io returned a bad status code) → `Down`. We - * reached the status page but it told us something is wrong on - * its side. We have a definitive response. - * - 'network' (timeout, DNS failure, TCP/TLS failure) → `NoConnection`. - * We never reached the status page. Treating this as `Degraded` - * (the previous behavior) silently flipped the reconciliation in - * `readiness.ts` from "soft" to "confirmed outage" whenever the - * user's own network was flaky — exactly the false positive this - * module is meant to help diagnose. - */ -function errResult(error: string, kind: 'http' | 'network'): BaseHealthResult { - return { - status: - kind === 'http' - ? ServiceHealthStatus.Down - : ServiceHealthStatus.NoConnection, - error, - }; -} - -const POSTHOG_STATUS_URL = 'https://www.posthogstatus.com/api/v1/summary'; - -async function fetchPosthogStatus( - timeoutMs = 5000, -): Promise<{ overall: BaseHealthResult; components: ComponentHealthResult }> { - try { - const controller = new AbortController(); - const tid = setTimeout(() => controller.abort(), timeoutMs); - const res = await fetch(POSTHOG_STATUS_URL, { signal: controller.signal }); - clearTimeout(tid); - - if (!res.ok) { - const err = errResult(`HTTP ${res.status}`, 'http'); - return { overall: err, components: err }; - } - - const data = (await res.json()) as IncidentIoSummary; - const incidents = data.ongoing_incidents ?? []; - - if (incidents.length === 0) { - return { - overall: { status: ServiceHealthStatus.Healthy }, - components: { status: ServiceHealthStatus.Healthy }, - }; - } - - let worstOverall = ServiceHealthStatus.Degraded; - const affected: ComponentStatus[] = []; - - for (const incident of incidents) { - const impact = mapIncidentImpact(incident.current_worst_impact); - if (impact === ServiceHealthStatus.Down) { - worstOverall = ServiceHealthStatus.Down; - } - - for (const comp of incident.affected_components ?? []) { - const compStatus = mapComponentStatus(comp.current_status); - if (compStatus !== ServiceHealthStatus.Healthy) { - affected.push({ - name: comp.group_name - ? `${comp.group_name} — ${comp.name}` - : comp.name, - status: compStatus, - rawStatus: comp.current_status, - }); - } - } - } - - return { - overall: { status: worstOverall }, - components: { - status: - affected.length > 0 ? ServiceHealthStatus.Degraded : worstOverall, - degradedOrDownComponents: affected.length > 0 ? affected : undefined, - }, - }; - } catch (e) { - if (e instanceof Error && e.name === 'AbortError') { - const err = errResult('Request timed out', 'network'); - return { overall: err, components: err }; - } - const err = errResult( - e instanceof Error ? e.message : 'Unknown error', - 'network', - ); - return { overall: err, components: err }; - } -} - -let _cache: Promise<{ - overall: BaseHealthResult; - components: ComponentHealthResult; -}> | null = null; - -function getPosthogHealth() { - if (!_cache) _cache = fetchPosthogStatus(); - return _cache; -} - -export function resetPosthogHealthCache(): void { - _cache = null; -} - -export const checkPosthogOverallHealth = async (): Promise => - (await getPosthogHealth()).overall; - -export const checkPosthogComponentHealth = - async (): Promise => - (await getPosthogHealth()).components; diff --git a/src/lib/health-checks/index.ts b/src/lib/health-checks/index.ts index b4c3f604..a6af0fb7 100644 --- a/src/lib/health-checks/index.ts +++ b/src/lib/health-checks/index.ts @@ -1,28 +1,11 @@ export { ServiceHealthStatus, type BaseHealthResult, - type ComponentStatus, - type ComponentHealthResult, type AllServicesHealth, type HealthCheckKey, } from './types'; -export { - checkAnthropicHealth, - checkGithubHealth, - checkNpmOverallHealth, - checkNpmComponentHealth, - checkCloudflareOverallHealth, - checkCloudflareComponentHealth, -} from './statuspage'; - -export { - checkPosthogOverallHealth, - checkPosthogComponentHealth, - resetPosthogHealthCache, -} from './incidentio'; - -export { checkMcpHealth, checkSkillsOriginHealth } from './endpoints'; +export { checkLlmGatewayHealth, checkSkillsOriginHealth } from './endpoints'; export { type WizardReadinessConfig, diff --git a/src/lib/health-checks/readiness.ts b/src/lib/health-checks/readiness.ts index 6cd88367..0eba5a3b 100644 --- a/src/lib/health-checks/readiness.ts +++ b/src/lib/health-checks/readiness.ts @@ -2,22 +2,9 @@ import { ServiceHealthStatus, type AllServicesHealth, type BaseHealthResult, - type ComponentHealthResult, type HealthCheckKey, } from './types'; -import { - checkAnthropicHealth, - checkGithubHealth, - checkNpmOverallHealth, - checkNpmComponentHealth, - checkCloudflareOverallHealth, - checkCloudflareComponentHealth, -} from './statuspage'; -import { - checkPosthogOverallHealth, - checkPosthogComponentHealth, -} from './incidentio'; -import { checkMcpHealth, checkSkillsOriginHealth } from './endpoints'; +import { checkSkillsOriginHealth } from './endpoints'; import { logToFile } from '@utils/debug'; // --------------------------------------------------------------------------- @@ -25,15 +12,6 @@ import { logToFile } from '@utils/debug'; // --------------------------------------------------------------------------- export const SERVICE_LABELS: Record = { - anthropic: 'Anthropic', - posthogOverall: 'PostHog', - posthogComponents: 'PostHog (components)', - github: 'GitHub', - npmOverall: 'npm', - npmComponents: 'npm (components)', - cloudflareOverall: 'Cloudflare', - cloudflareComponents: 'Cloudflare (components)', - mcp: 'MCP', skillsOrigin: 'Skills download', }; @@ -48,129 +26,19 @@ export interface WizardReadinessConfig { degradedBlocksRun?: HealthCheckKey[]; } -/** - * See README section "Health checks" for the full rationale. - * Adjust these arrays to change what blocks a wizard run. - * - * The AI gateway is not probed: its URL is only known from the run's token - * mint, and a failed mint already stops the run at bootstrap with the - * server's reason. - */ +// Skills gate startup; gateway readiness is checked against the minted URL. export const DEFAULT_WIZARD_READINESS_CONFIG: WizardReadinessConfig = { - downBlocksRun: ['anthropic', 'npmOverall', 'mcp', 'skillsOrigin'], - degradedBlocksRun: ['anthropic'], + downBlocksRun: ['skillsOrigin'], }; -/** - * Reduced readiness config for --signup provisioning flows. - * - * Provisioning only needs PostHog - it doesn't use Anthropic directly, npm, - * the skills origins, or MCP. - */ -export const SIGNUP_WIZARD_READINESS_CONFIG: WizardReadinessConfig = { - downBlocksRun: ['posthogOverall'], -}; +export const SIGNUP_WIZARD_READINESS_CONFIG = DEFAULT_WIZARD_READINESS_CONFIG; // --------------------------------------------------------------------------- // Aggregate check // --------------------------------------------------------------------------- export async function checkAllExternalServices(): Promise { - const [ - anthropic, - posthogOverall, - posthogComponents, - github, - npmOverall, - npmComponents, - cloudflareOverall, - cloudflareComponents, - mcp, - skillsOrigin, - ] = await Promise.all([ - checkAnthropicHealth(), - checkPosthogOverallHealth(), - checkPosthogComponentHealth(), - checkGithubHealth(), - checkNpmOverallHealth(), - checkNpmComponentHealth(), - checkCloudflareOverallHealth(), - checkCloudflareComponentHealth(), - checkMcpHealth(), - checkSkillsOriginHealth(), - ]); - - const health: AllServicesHealth = { - anthropic, - posthogOverall, - posthogComponents, - github, - npmOverall, - npmComponents, - cloudflareOverall, - cloudflareComponents, - mcp, - skillsOrigin, - }; - return reconcilePosthogReachability(health); -} - -/** - * When a PostHog-owned endpoint probe returns `NoConnection`, decide - * whether it's a real outage or a likely-local issue by checking the - * official status page (`posthogstatus.com`): - * - * - Status page says PostHog is `Down` / `Degraded` → upgrade - * mcp to `Down`. The status page corroborates. - * - Status page is `Healthy` → keep `NoConnection`. The status page - * contradicts; this is probably the user's network. - * - Status page is also `NoConnection` → keep `NoConnection`. User - * can't reach two independent PostHog properties; almost - * certainly their network. (This case relies on incidentio.ts - * correctly emitting `NoConnection` for fetch failures rather - * than the previous `Degraded`, which used to silently flip the - * reconciliation into a false positive.) - * - * Why `Degraded` corroborates: a `Degraded` reading here only fires - * when incident.io's API parsed successfully and reported a real - * `partial_outage` or `degraded_performance` for some component. That's - * PostHog acknowledging an issue, even if narrower than a full outage. - * If our MCP probe is also failing, those two signals together - * justify pointing at PostHog rather than the user. - * - * A narrower variant — only corroborate when the affected component is - * MCP-related (US/EU Cloud, app) — would be more precise. We - * have the data in `posthogComponents` but don't use it here. If the - * analytics show false positives concentrated in this case, it's a - * cheap follow-up. - * - * Mutates a copy of `health` and returns it. - */ -export function reconcilePosthogReachability( - health: AllServicesHealth, -): AllServicesHealth { - const posthogStatus = health.posthogOverall.status; - const corroboratesOutage = - posthogStatus === ServiceHealthStatus.Down || - posthogStatus === ServiceHealthStatus.Degraded; - - if (!corroboratesOutage) return health; - - const upgrade = (r: BaseHealthResult): BaseHealthResult => - r.status === ServiceHealthStatus.NoConnection - ? { - ...r, - status: ServiceHealthStatus.Down, - error: r.error - ? `${r.error} (corroborated by status page)` - : 'corroborated by status page', - } - : r; - - return { - ...health, - mcp: upgrade(health.mcp), - }; + return { skillsOrigin: await checkSkillsOriginHealth() }; } // --------------------------------------------------------------------------- @@ -196,22 +64,6 @@ function describeResult(label: string, h: BaseHealthResult): string { return parts.join(' — '); } -const MAX_COMPONENT_NAMES = 8; - -function describeComponents(label: string, h: ComponentHealthResult): string { - const affected = h.degradedOrDownComponents; - if (!affected || affected.length === 0) - return `${label} components: all operational`; - const shown = affected - .slice(0, MAX_COMPONENT_NAMES) - .map((c) => `${c.name} (${c.status})`); - const suffix = - affected.length > MAX_COMPONENT_NAMES - ? `, +${affected.length - MAX_COMPONENT_NAMES} more` - : ''; - return `${label} components impacted: ${shown.join(', ')}${suffix}`; -} - // Each probe can take up to one base timeout + two retries with the // 500ms / 2000ms backoffs in endpoints.ts (worst case ~17.5s for a // network failure that exhausts retries). Probes run in parallel so @@ -232,20 +84,10 @@ export async function evaluateWizardReadiness( ), ]); - const reasons: string[] = []; - - for (const key of Object.keys(health) as HealthCheckKey[]) { - const result = health[key]; - const label = SERVICE_LABELS[key]; - - reasons.push(describeResult(label, result)); - - if ('degradedOrDownComponents' in result) { - reasons.push(describeComponents(label, result)); - } - } - const blockingKeys = getBlockingServiceKeys(health, config); + const reasons = blockingKeys.map((key) => + describeResult(SERVICE_LABELS[key], health[key]), + ); if (blockingKeys.length > 0) { const blockingDetails = blockingKeys.map((key) => { const h = health[key]; @@ -255,14 +97,6 @@ export async function evaluateWizardReadiness( return { decision: WizardReadiness.No, health, reasons }; } - const hasWarnings = Object.values(health).some( - (h) => h.status !== ServiceHealthStatus.Healthy, - ); - - if (hasWarnings) { - return { decision: WizardReadiness.YesWithWarnings, health, reasons }; - } - return { decision: WizardReadiness.Yes, health, reasons }; } catch (err) { logToFile( @@ -272,7 +106,7 @@ export async function evaluateWizardReadiness( return { decision: WizardReadiness.Yes, health: allUnknown('Unexpected error'), - reasons: ['Health check failed unexpectedly — proceeding anyway'], + reasons: [], }; } } @@ -281,27 +115,12 @@ export async function evaluateWizardReadiness( // Blocking service detection // --------------------------------------------------------------------------- -/** Keys that are component-level detail, not top-level services. */ -const COMPONENT_KEYS: HealthCheckKey[] = [ - 'posthogComponents', - 'npmComponents', - 'cloudflareComponents', -]; - -/** - * Get the keys of services that would block a wizard run per the given config. - * - * `NoConnection` blocks the same services as `Down` — the wizard genuinely - * can't continue if it can't reach the gateway. The screen shows softer - * framing in that case (HealthCheckScreen) so we don't falsely accuse - * PostHog of an outage when the user's network is the likely cause. - */ +// Report only dependencies that prevent this run from starting. export function getBlockingServiceKeys( health: AllServicesHealth, config: WizardReadinessConfig = DEFAULT_WIZARD_READINESS_CONFIG, ): HealthCheckKey[] { return (Object.keys(health) as HealthCheckKey[]).filter((key) => { - if (COMPONENT_KEYS.includes(key)) return false; const result = health[key]; if ( config.downBlocksRun.includes(key) && @@ -326,16 +145,5 @@ function allUnknown(error: string): AllServicesHealth { status: ServiceHealthStatus.Degraded, error, }; - return { - anthropic: base, - posthogOverall: base, - posthogComponents: { ...base }, - github: base, - npmOverall: base, - npmComponents: { ...base }, - cloudflareOverall: base, - cloudflareComponents: { ...base }, - mcp: base, - skillsOrigin: base, - }; + return { skillsOrigin: base }; } diff --git a/src/lib/health-checks/statuspage.ts b/src/lib/health-checks/statuspage.ts deleted file mode 100644 index bfcdd6da..00000000 --- a/src/lib/health-checks/statuspage.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { - ServiceHealthStatus, - type BaseHealthResult, - type ComponentHealthResult, -} from './types'; - -// --------------------------------------------------------------------------- -// Statuspage.io v2 API helpers -// https://metastatuspage.com/api -// -// status.json – page-level rollup; indicator is one of: none | minor | major | critical -// summary.json – same rollup + component list; component status is one of: -// operational | degraded_performance | partial_outage | major_outage | under_maintenance -// https://support.atlassian.com/statuspage/docs/show-service-status-with-components -// --------------------------------------------------------------------------- - -interface StatuspageStatusResponse { - status?: { indicator?: string; description?: string }; -} - -interface StatuspageSummaryResponse extends StatuspageStatusResponse { - components?: { id: string; name: string; status: string }[]; -} - -function mapIndicator(v: string | null | undefined): ServiceHealthStatus { - switch (v) { - case 'none': - return ServiceHealthStatus.Healthy; - case 'minor': - return ServiceHealthStatus.Degraded; - case 'major': - case 'critical': - return ServiceHealthStatus.Down; - default: - return ServiceHealthStatus.Degraded; - } -} - -function mapComponentRaw(v: string | null | undefined): ServiceHealthStatus { - switch (v) { - case 'operational': - return ServiceHealthStatus.Healthy; - case 'degraded_performance': - case 'under_maintenance': - return ServiceHealthStatus.Degraded; - case 'partial_outage': - case 'major_outage': - return ServiceHealthStatus.Down; - default: - return ServiceHealthStatus.Degraded; - } -} - -function errResult(error: string): BaseHealthResult { - return { status: ServiceHealthStatus.Degraded, error }; -} - -async function fetchStatuspageIndicator( - url: string, - timeoutMs = 5000, -): Promise { - try { - const controller = new AbortController(); - const tid = setTimeout(() => controller.abort(), timeoutMs); - const res = await fetch(url, { signal: controller.signal }); - clearTimeout(tid); - - if (!res.ok) return errResult(`HTTP ${res.status}`); - - const data = (await res.json()) as StatuspageStatusResponse; - const indicator = data.status?.indicator ?? null; - return { - status: mapIndicator(indicator), - rawIndicator: indicator ?? undefined, - }; - } catch (e) { - if (e instanceof Error && e.name === 'AbortError') - return errResult('Request timed out'); - return errResult(e instanceof Error ? e.message : 'Unknown error'); - } -} - -async function fetchStatuspageSummary( - url: string, - timeoutMs = 5000, -): Promise { - try { - const controller = new AbortController(); - const tid = setTimeout(() => controller.abort(), timeoutMs); - const res = await fetch(url, { signal: controller.signal }); - clearTimeout(tid); - - if (!res.ok) return errResult(`HTTP ${res.status}`); - - const data = (await res.json()) as StatuspageSummaryResponse; - const indicator = data.status?.indicator ?? null; - const overall = mapIndicator(indicator); - - const affected = (data.components ?? []) - .map((c) => ({ - name: c.name, - status: mapComponentRaw(c.status), - rawStatus: c.status, - })) - .filter((c) => c.status !== ServiceHealthStatus.Healthy); - - return { - status: affected.length > 0 ? ServiceHealthStatus.Degraded : overall, - rawIndicator: indicator ?? undefined, - degradedOrDownComponents: affected.length > 0 ? affected : undefined, - }; - } catch (e) { - if (e instanceof Error && e.name === 'AbortError') - return errResult('Request timed out'); - return errResult(e instanceof Error ? e.message : 'Unknown error'); - } -} - -// --------------------------------------------------------------------------- -// Individual statuspage-backed checks -// --------------------------------------------------------------------------- - -export const checkAnthropicHealth = (): Promise => - fetchStatuspageIndicator('https://status.claude.com/api/v2/status.json'); - -export const checkGithubHealth = (): Promise => - fetchStatuspageIndicator('https://www.githubstatus.com/api/v2/status.json'); - -export const checkNpmOverallHealth = (): Promise => - fetchStatuspageIndicator('https://status.npmjs.org/api/v2/status.json'); - -export const checkNpmComponentHealth = (): Promise => - fetchStatuspageSummary('https://status.npmjs.org/api/v2/summary.json'); - -export const checkCloudflareOverallHealth = (): Promise => - fetchStatuspageIndicator( - 'https://www.cloudflarestatus.com/api/v2/status.json', - ); - -export const checkCloudflareComponentHealth = - (): Promise => - fetchStatuspageSummary( - 'https://www.cloudflarestatus.com/api/v2/summary.json', - ); diff --git a/src/lib/health-checks/testme.md b/src/lib/health-checks/testme.md index 9cf6a720..ba5b0418 100644 --- a/src/lib/health-checks/testme.md +++ b/src/lib/health-checks/testme.md @@ -1,61 +1,17 @@ -# Health Checks — Testing Guide +# Health checks -## Running unit tests +Run the existing health and gateway tests with mocked HTTP requests: ```bash -# From the wizard/ root — runs only health-check tests (fast, no build step) -npx jest src/lib/health-checks/__tests__/health-checks.test.ts - -# Watch mode -npx jest src/lib/health-checks/__tests__/health-checks.test.ts --watch - -# With coverage -npx jest src/lib/health-checks/__tests__/health-checks.test.ts --coverage -``` - -## Running health checks live - -To hit all 10 endpoints for real and see the full readiness result: - -```bash -# From the wizard/ root -npx tsx -e "import { evaluateWizardReadiness } from './src/lib/health-checks/index'; evaluateWizardReadiness().then(r => console.log(JSON.stringify(r, null, 2)))" +pnpm exec vitest run src/lib/health-checks/__tests__/health-checks.test.ts src/lib/__tests__/gateway-session.test.ts ``` -## How the tests work - -All external HTTP calls are mocked via a global `fetch` override in -`beforeEach`. No network access is required. Mock data is modelled on real -responses captured from production endpoints on 2026-03-05. - -## Endpoints tested - -| Service | URL | Healthy response | -| ----------------------- | ------------------------------------------------------ | ------------------------------------- | -| Anthropic | `https://status.claude.com/api/v2/status.json` | `{"status":{"indicator":"none",...}}` | -| PostHog | `https://www.posthogstatus.com/api/v2/status.json` | Same shape | -| PostHog (components) | `https://www.posthogstatus.com/api/v2/summary.json` | Adds `components[]` array | -| GitHub | `https://www.githubstatus.com/api/v2/status.json` | Same shape | -| npm | `https://status.npmjs.org/api/v2/status.json` | Same shape | -| npm (components) | `https://status.npmjs.org/api/v2/summary.json` | Adds `components[]` array | -| Cloudflare | `https://www.cloudflarestatus.com/api/v2/status.json` | Same shape | -| Cloudflare (components) | `https://www.cloudflarestatus.com/api/v2/summary.json` | Adds `components[]` array | -| MCP | `https://mcp.posthog.com/` | HTML landing page (HTTP 200) | - -### Statuspage.io API v2 reference - -- Docs: -- `status.json` — page-level rollup; `indicator` is one of: `none`, `minor`, - `major`, `critical` -- `summary.json` — same rollup + `components[]`; component `status` is one of: - `operational`, `degraded_performance`, `partial_outage`, `major_outage`, - `under_maintenance` -- Component docs: - - -### MCP +The checks cover gateway readiness, endpoint retries, and skills downloads from +GitHub Releases and AWS. Either working skills origin is sufficient; both must +fail before startup is blocked. Third-party status pages are not queried. -- Source: `posthog/services/mcp/src/index.ts` -- `GET /` → HTML landing page (200) -- No dedicated `/health` endpoint; 200 on `/` confirms the Cloudflare Worker is - running. +| Check | Endpoint | Healthy response | +| ------------- | ------------------------------------------ | ------------------------ | +| Gateway | `/readyz` on the minted gateway URL | HTTP 200 | +| GitHub skills | `/skill-menu.json` | HTTP 200 after redirects | +| AWS skills | `/skill-menu.json` | HTTP 200 after redirects | diff --git a/src/lib/health-checks/types.ts b/src/lib/health-checks/types.ts index f838bbf9..2b59f9d1 100644 --- a/src/lib/health-checks/types.ts +++ b/src/lib/health-checks/types.ts @@ -2,13 +2,7 @@ export enum ServiceHealthStatus { Healthy = 'healthy', Degraded = 'degraded', Down = 'down', - /** - * Probe failed (network error, timeout, DNS failure) AND we have no - * corroborating status-page incident. The service may be fine — the - * user's network is the likely culprit. Distinct from `Down`, which - * is confirmed (HTTP 5xx or status-page incident). User-facing label: - * "No connection". - */ + // A network failure does not establish a service outage. NoConnection = 'no-connection', } @@ -18,26 +12,7 @@ export interface BaseHealthResult { error?: string; } -export interface ComponentStatus { - name: string; - status: ServiceHealthStatus; - rawStatus: string; -} - -export interface ComponentHealthResult extends BaseHealthResult { - degradedOrDownComponents?: ComponentStatus[]; -} - export interface AllServicesHealth { - anthropic: BaseHealthResult; - posthogOverall: BaseHealthResult; - posthogComponents: ComponentHealthResult; - github: BaseHealthResult; - npmOverall: BaseHealthResult; - npmComponents: ComponentHealthResult; - cloudflareOverall: BaseHealthResult; - cloudflareComponents: ComponentHealthResult; - mcp: BaseHealthResult; skillsOrigin: BaseHealthResult; } diff --git a/src/ui/tui/components/ServiceHealthList.tsx b/src/ui/tui/components/ServiceHealthList.tsx index c38e9db4..99ac8c7d 100644 --- a/src/ui/tui/components/ServiceHealthList.tsx +++ b/src/ui/tui/components/ServiceHealthList.tsx @@ -1,34 +1,14 @@ -/** - * ServiceHealthList — Shared component for displaying service health status. - * - * Used by HealthCheckScreen (blocking services only) and HealthWarningsTab (all services). - */ +// Display the dependencies reported by the health check. import { Box, Text } from 'ink'; import { ServiceHealthStatus, type AllServicesHealth, - type ComponentHealthResult, - type ComponentStatus, type HealthCheckKey, } from '@lib/health-checks/types'; import { SERVICE_LABELS } from '@lib/health-checks/readiness'; import { Icons } from '@ui/tui/styles'; -/** Keys that are component-level detail — shown inline under their parent. */ -const COMPONENT_KEYS: HealthCheckKey[] = [ - 'posthogComponents', - 'npmComponents', - 'cloudflareComponents', -]; - -/** Map component key → its parent "overall" key */ -const COMPONENT_PARENT: Partial> = { - posthogComponents: 'posthogOverall', - npmComponents: 'npmOverall', - cloudflareComponents: 'cloudflareOverall', -}; - function statusIcon(status: ServiceHealthStatus): { icon: string; color: string; @@ -58,9 +38,7 @@ export const ServiceHealthList = ({ filterKeys, showHealthy = true, }: ServiceHealthListProps) => { - const topLevelKeys = (Object.keys(health) as HealthCheckKey[]).filter( - (k) => !COMPONENT_KEYS.includes(k), - ); + const topLevelKeys = Object.keys(health) as HealthCheckKey[]; const keysToShow = filterKeys ? topLevelKeys.filter((k) => filterKeys.includes(k)) @@ -77,16 +55,6 @@ export const ServiceHealthList = ({ const { icon, color } = statusIcon(result.status); const label = SERVICE_LABELS[key]; - // Find component-level details if this is a parent key - const componentKey = ( - Object.entries(COMPONENT_PARENT) as [HealthCheckKey, HealthCheckKey][] - ).find(([, parent]) => parent === key)?.[0]; - const componentResult = componentKey - ? (health[componentKey] as ComponentHealthResult) - : undefined; - const affectedComponents: ComponentStatus[] = - componentResult?.degradedOrDownComponents ?? []; - return ( @@ -95,21 +63,6 @@ export const ServiceHealthList = ({ {label} - {affectedComponents.length > 0 && ( - - {affectedComponents.slice(0, 5).map((c) => { - const ci = statusIcon(c.status); - return ( - - {ci.icon} {c.name} - - ); - })} - {affectedComponents.length > 5 && ( - +{affectedComponents.length - 5} more - )} - - )} ); })} diff --git a/src/ui/tui/playground/demos/HealthCheckDemo.tsx b/src/ui/tui/playground/demos/HealthCheckDemo.tsx index 501f6972..a4e12b93 100644 --- a/src/ui/tui/playground/demos/HealthCheckDemo.tsx +++ b/src/ui/tui/playground/demos/HealthCheckDemo.tsx @@ -1,16 +1,4 @@ -/** - * HealthCheckDemo — Playground demo for health check UI components. - * - * Cycles through three states (2s checking spinner → 5s confirmed-outage - * red modal → 5s no-connection yellow modal, then loops): - * 1. Checking (spinner) - * 2. Confirmed outage (status page corroborates → red framing) - * 3. No connection only (no status-page incident → yellow "couldn't - * reach PostHog" framing) - * - * Renders components directly (not HealthCheckScreen) to avoid useInput - * conflicts with TabContainer's key handling. - */ +// Preview the skills health-check states without handling input. import { useEffect, useState } from 'react'; import { Box, Text } from 'ink'; @@ -21,47 +9,12 @@ import { getBlockingServiceKeys } from '@lib/health-checks/readiness'; import { ServiceHealthStatus } from '@lib/health-checks/types'; import type { AllServicesHealth } from '@lib/health-checks/types'; -const HEALTHY = { status: ServiceHealthStatus.Healthy } as const; - const MOCK_CONFIRMED_OUTAGE: AllServicesHealth = { - anthropic: { status: ServiceHealthStatus.Down, rawIndicator: 'major' }, - posthogOverall: HEALTHY, - posthogComponents: { status: ServiceHealthStatus.Healthy }, - github: HEALTHY, - npmOverall: { - status: ServiceHealthStatus.Degraded, - rawIndicator: 'minor', - }, - npmComponents: { - status: ServiceHealthStatus.Degraded, - degradedOrDownComponents: [ - { - name: 'Registry API', - status: ServiceHealthStatus.Degraded, - rawStatus: 'degraded_performance', - }, - ], - }, - cloudflareOverall: HEALTHY, - cloudflareComponents: { status: ServiceHealthStatus.Healthy }, - mcp: HEALTHY, - skillsOrigin: HEALTHY, + skillsOrigin: { status: ServiceHealthStatus.Down }, }; const MOCK_NO_CONNECTION: AllServicesHealth = { - anthropic: HEALTHY, - posthogOverall: HEALTHY, - posthogComponents: { status: ServiceHealthStatus.Healthy }, - github: HEALTHY, - npmOverall: HEALTHY, - npmComponents: { status: ServiceHealthStatus.Healthy }, - cloudflareOverall: HEALTHY, - cloudflareComponents: { status: ServiceHealthStatus.Healthy }, - mcp: { - status: ServiceHealthStatus.NoConnection, - error: 'fetch failed', - }, - skillsOrigin: HEALTHY, + skillsOrigin: { status: ServiceHealthStatus.NoConnection }, }; type Phase = 'checking' | 'confirmed' | 'no-connection'; @@ -103,15 +56,13 @@ export const HealthCheckDemo = () => { borderColor={isNoConnection ? 'yellow' : 'red'} title={ isNoConnection - ? "Couldn't reach PostHog" - : `${Icons.warning} Ongoing service disruptions` + ? "Couldn't reach skill downloads" + : `${Icons.warning} Skill downloads unavailable` } width={72} footer={ - - Continue [Enter] / Exit [Esc] (disabled in playground) - + Exit [Esc] (disabled in playground) } > @@ -136,8 +87,8 @@ export const HealthCheckDemo = () => { {isNoConnection - ? "We couldn't reach these services. PostHog's status page shows no incidents, likely a network issue (VPN, firewall, captive portal, or Wi-Fi)." - : 'The wizard may not work reliably while services are affected.'} + ? "We couldn't reach either skills source. Check your connection and try again." + : 'Neither GitHub Releases nor the AWS mirror is available.'} ); diff --git a/src/ui/tui/store.ts b/src/ui/tui/store.ts index 12cf174a..165dacf3 100644 --- a/src/ui/tui/store.ts +++ b/src/ui/tui/store.ts @@ -41,7 +41,6 @@ import { getBlockingServiceKeys, type WizardReadinessResult, } from '@lib/health-checks/readiness'; -import { ServiceHealthStatus } from '@lib/health-checks/types'; import { WizardRouter, type ScreenName, @@ -128,49 +127,17 @@ interface GateEntry { */ const MAX_STATUS_MESSAGES = EXPANDED_COUNT; -/** - * Fired once per blocked readiness result, so we can quantify how often - * the wizard refuses to start and — crucially — split that between - * confirmed PostHog outages and probe-level reachability failures that - * are most likely the user's network. Helps us decide whether the - * health-check UX is over-firing. - */ +// Capture blocked skill downloads once per readiness result. function captureHealthCheckBlocked(result: WizardReadinessResult): void { try { const health = result.health; const blockingKeys = getBlockingServiceKeys(health); - const blockingStatuses = blockingKeys.map((k) => health[k]?.status); - - const allNoConnection = - blockingStatuses.length > 0 && - blockingStatuses.every((s) => s === ServiceHealthStatus.NoConnection); - const onlySkillsOrigin = - blockingKeys.length === 1 && blockingKeys[0] === 'skillsOrigin'; - - const decision = onlySkillsOrigin - ? 'skills-origin-down' - : allNoConnection - ? 'no-connection' - : 'confirmed-outage'; - - const posthogStatus = health.posthogOverall?.status; - const retriesUsed = Math.max( - 0, - ...(['mcp', 'skillsOrigin'] as const).map((k) => { - const ind = health[k]?.rawIndicator ?? ''; - const m = ind.match(/attempts=(\d+)/); - return m ? Number(m[1]) - 1 : 0; - }), - ); + const attempts = health.skillsOrigin.rawIndicator?.match(/attempts=(\d+)/); + const retriesUsed = Math.max(0, attempts ? Number(attempts[1]) - 1 : 0); analytics.wizardCapture('health check blocked', { - decision, + decision: 'skills-origin-down', blocking_keys: blockingKeys, - posthog_status_reachable: - posthogStatus !== ServiceHealthStatus.NoConnection, - posthog_status_reports_incident: - posthogStatus === ServiceHealthStatus.Down || - posthogStatus === ServiceHealthStatus.Degraded, retries_used: retriesUsed, }); } catch (err) { diff --git a/src/utils/anthropic-status.ts b/src/utils/anthropic-status.ts deleted file mode 100644 index 5f05fbfa..00000000 --- a/src/utils/anthropic-status.ts +++ /dev/null @@ -1,73 +0,0 @@ -const CLAUDE_STATUS_URL = 'https://status.claude.com/api/v2/status.json'; - -type StatusIndicator = 'none' | 'minor' | 'major' | 'critical'; - -interface ClaudeStatusResponse { - page: { - id: string; - name: string; - url: string; - time_zone: string; - updated_at: string; - }; - status: { - indicator: StatusIndicator; - description: string; - }; -} - -export type StatusCheckResult = - | { status: 'operational' } - | { status: 'degraded'; description: string } - | { status: 'down'; description: string } - | { status: 'unknown'; error: string }; - -/** - * Check the Anthropic/Claude status page for service health. - * Pure function — no UI calls. - */ -export async function checkAnthropicStatus(): Promise { - try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 5000); - - const response = await fetch(CLAUDE_STATUS_URL, { - signal: controller.signal, - }); - - clearTimeout(timeoutId); - - if (!response.ok) { - return { - status: 'unknown', - error: `Status page returned ${response.status}`, - }; - } - - const data = (await response.json()) as ClaudeStatusResponse; - const indicator = data.status.indicator; - const rawDesc = data.status.description; - const description = - rawDesc.charAt(0).toUpperCase() + rawDesc.slice(1).toLowerCase(); - - switch (indicator) { - case 'none': - return { status: 'operational' }; - case 'minor': - return { status: 'degraded', description }; - case 'major': - case 'critical': - return { status: 'down', description }; - default: - return { status: 'unknown', error: `Unknown indicator: ${indicator}` }; - } - } catch (error) { - if (error instanceof Error && error.name === 'AbortError') { - return { status: 'unknown', error: 'Request timed out' }; - } - return { - status: 'unknown', - error: error instanceof Error ? error.message : 'Unknown error', - }; - } -}