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',
- };
- }
-}