Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 10 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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
Expand Down
42 changes: 42 additions & 0 deletions src/lib/__tests__/gateway-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() },
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions src/lib/gateway-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading