diff --git a/backend/package.json b/backend/package.json index 3bdbaf4..0d4b555 100644 --- a/backend/package.json +++ b/backend/package.json @@ -28,6 +28,7 @@ "@bytelyst/fastify-core": "*", "@bytelyst/field-encrypt": "*", "@bytelyst/llm": "*", + "@bytelyst/monitoring": "*", "@bytelyst/palace": "*", "@bytelyst/logger": "*", "@bytelyst/webhook-dispatch": "*", diff --git a/backend/src/diagnostics.test.ts b/backend/src/diagnostics.test.ts index 4e7be03..901e686 100644 --- a/backend/src/diagnostics.test.ts +++ b/backend/src/diagnostics.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'; import Fastify from 'fastify'; import type { FastifyInstance } from 'fastify'; import { SignJWT } from 'jose'; @@ -30,6 +30,10 @@ afterAll(async () => { await app.close(); }); +afterEach(() => { + vi.unstubAllGlobals(); +}); + function makeToken(role: string) { return new SignJWT({ sub: 'diagnostics-user', @@ -80,6 +84,27 @@ describe('diagnostics routes', () => { expect(typeof body.featureFlagsEnabled).toBe('boolean'); }); + it('GET /api/diagnostics/readiness returns dependency health summary', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(JSON.stringify({ status: 'ok' }), { status: 200 })), + ); + + const res = await app.inject({ method: 'GET', url: '/api/diagnostics/readiness' }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.overall).toBe('ready'); + expect(body.summary).toMatchObject({ total: 5, unhealthy: 0, unreachable: 0 }); + expect(body.dependencies.map((dep: { name: string }) => dep.name)).toEqual([ + 'datastore', + 'encryption', + 'platform-service', + 'extraction-service', + 'mcp', + ]); + }); + it('GET /health returns standard health response', async () => { const res = await app.inject({ method: 'GET', url: '/health' }); expect(res.statusCode).toBe(200); diff --git a/backend/src/lib/dependency-readiness.test.ts b/backend/src/lib/dependency-readiness.test.ts new file mode 100644 index 0000000..ae704dc --- /dev/null +++ b/backend/src/lib/dependency-readiness.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { ServiceCheck } from '@bytelyst/monitoring'; +import { getDependencyReadiness } from './dependency-readiness.js'; + +describe('dependency readiness', () => { + it('reports all production dependencies as ready when local and service checks are healthy', async () => { + const serviceChecker = vi.fn(async target => ({ + name: target.name, + url: target.url, + status: 'healthy', + responseTimeMs: 4, + details: { ok: true }, + }) satisfies ServiceCheck); + + const report = await getDependencyReadiness({ serviceChecker, timeoutMs: 25 }); + + expect(report.overall).toBe('ready'); + expect(report.summary).toMatchObject({ healthy: 5, unhealthy: 0, unreachable: 0, total: 5 }); + expect(report.dependencies.map(dep => dep.name)).toEqual([ + 'datastore', + 'encryption', + 'platform-service', + 'extraction-service', + 'mcp', + ]); + expect(report.dependencies.find(dep => dep.name === 'mcp')?.details).toMatchObject({ + registeredToolsHealthy: true, + }); + expect(serviceChecker).toHaveBeenCalledWith( + expect.objectContaining({ name: 'platform-service', path: '/health' }), + { timeoutMs: 25 }, + ); + expect(serviceChecker).toHaveBeenCalledWith( + expect.objectContaining({ name: 'extraction-service', path: '/health' }), + { timeoutMs: 25 }, + ); + expect(serviceChecker).toHaveBeenCalledWith( + expect.objectContaining({ name: 'mcp-server', path: '/health' }), + { timeoutMs: 25 }, + ); + }); + + it('degrades when a platform dependency is unreachable', async () => { + const serviceChecker = vi.fn(async target => ({ + name: target.name, + url: target.url, + status: target.name === 'platform-service' ? 'unreachable' : 'healthy', + responseTimeMs: 6, + error: target.name === 'platform-service' ? 'connection refused' : undefined, + }) satisfies ServiceCheck); + + const report = await getDependencyReadiness({ serviceChecker }); + + expect(report.overall).toBe('degraded'); + expect(report.summary.unreachable).toBe(1); + expect(report.dependencies.find(dep => dep.name === 'platform-service')?.error).toBe('connection refused'); + }); +}); diff --git a/backend/src/lib/dependency-readiness.ts b/backend/src/lib/dependency-readiness.ts new file mode 100644 index 0000000..2a44967 --- /dev/null +++ b/backend/src/lib/dependency-readiness.ts @@ -0,0 +1,157 @@ +import { checkService, type ServiceCheck, type ServiceTarget } from '@bytelyst/monitoring'; +import { getNotesMcpToolsForRegistration } from '../mcp/register-note-tools.js'; +import { config } from './config.js'; +import { initDatastore } from './datastore.js'; +import { getEncryptionReadiness } from './field-encrypt.js'; + +export type DependencyStatus = ServiceCheck['status']; + +export type DependencyCheck = ServiceCheck & { + details?: Record; +}; + +export interface DependencyReadinessReport { + overall: 'ready' | 'degraded' | 'down'; + timestamp: string; + dependencies: DependencyCheck[]; + summary: { + healthy: number; + unhealthy: number; + unreachable: number; + total: number; + }; +} + +type ServiceChecker = (target: ServiceTarget, opts?: { timeoutMs?: number }) => Promise; + +function elapsedSince(start: number): number { + return Math.round(performance.now() - start); +} + +function normalizeServiceBaseUrl(url: string): string { + const trimmed = url.replace(/\/+$/, ''); + return trimmed.endsWith('/api') ? trimmed.slice(0, -4) : trimmed; +} + +function summarize(checks: DependencyCheck[]): DependencyReadinessReport['summary'] { + return { + healthy: checks.filter(check => check.status === 'healthy').length, + unhealthy: checks.filter(check => check.status === 'unhealthy').length, + unreachable: checks.filter(check => check.status === 'unreachable').length, + total: checks.length, + }; +} + +function overallFrom(summary: DependencyReadinessReport['summary']): DependencyReadinessReport['overall'] { + if (summary.healthy === summary.total) { + return 'ready'; + } + if (summary.healthy === 0) { + return 'down'; + } + return 'degraded'; +} + +export async function checkDatastoreReadiness(): Promise { + const start = performance.now(); + try { + const healthy = await initDatastore().isHealthy(); + return { + name: 'datastore', + url: config.DB_PROVIDER, + status: healthy ? 'healthy' : 'unhealthy', + responseTimeMs: elapsedSince(start), + details: { provider: config.DB_PROVIDER }, + ...(healthy ? {} : { error: 'Datastore provider reported unhealthy' }), + }; + } catch (error) { + return { + name: 'datastore', + url: config.DB_PROVIDER, + status: 'unreachable', + responseTimeMs: elapsedSince(start), + error: error instanceof Error ? error.message : String(error), + details: { provider: config.DB_PROVIDER }, + }; + } +} + +export async function checkEncryptionReadiness(): Promise { + const start = performance.now(); + const readiness = getEncryptionReadiness(); + return { + name: 'encryption', + url: readiness.keyProvider, + status: readiness.ready ? 'healthy' : 'unhealthy', + responseTimeMs: elapsedSince(start), + details: { + enabled: readiness.enabled, + keyProvider: readiness.keyProvider, + }, + ...(readiness.ready ? {} : { error: 'Field encryption is not ready' }), + }; +} + +async function checkHttpDependency( + checker: ServiceChecker, + target: ServiceTarget, + timeoutMs: number, +): Promise { + return checker(target, { timeoutMs }); +} + +async function checkMcpReadiness( + checker: ServiceChecker, + timeoutMs: number, +): Promise { + const baseUrl = normalizeServiceBaseUrl(config.MCP_SERVER_URL); + const tools = getNotesMcpToolsForRegistration(); + const serviceCheck = await checker({ name: 'mcp-server', url: baseUrl, path: '/health' }, { timeoutMs }); + const hasTools = tools.length > 0; + const status: DependencyStatus = + serviceCheck.status === 'healthy' && hasTools ? 'healthy' : serviceCheck.status; + + return { + ...serviceCheck, + name: 'mcp', + status: hasTools ? status : 'unhealthy', + details: { + ...(serviceCheck.details ?? {}), + registeredToolCount: tools.length, + registeredToolsHealthy: hasTools, + }, + ...(!hasTools ? { error: 'No NoteLett MCP tools are registered locally' } : {}), + }; +} + +export async function getDependencyReadiness(options: { + timeoutMs?: number; + serviceChecker?: ServiceChecker; +} = {}): Promise { + const timeoutMs = options.timeoutMs ?? 1500; + const serviceChecker = options.serviceChecker ?? checkService; + + const dependencies = await Promise.all([ + checkDatastoreReadiness(), + checkEncryptionReadiness(), + checkHttpDependency( + serviceChecker, + { name: 'platform-service', url: normalizeServiceBaseUrl(config.PLATFORM_SERVICE_URL), path: '/health' }, + timeoutMs, + ), + checkHttpDependency( + serviceChecker, + { name: 'extraction-service', url: normalizeServiceBaseUrl(config.EXTRACTION_SERVICE_URL), path: '/health' }, + timeoutMs, + ), + checkMcpReadiness(serviceChecker, timeoutMs), + ]); + + const summary = summarize(dependencies); + return { + overall: overallFrom(summary), + timestamp: new Date().toISOString(), + dependencies, + summary, + }; +} diff --git a/backend/src/lib/diagnostics-routes.ts b/backend/src/lib/diagnostics-routes.ts index 2706208..4eda63d 100644 --- a/backend/src/lib/diagnostics-routes.ts +++ b/backend/src/lib/diagnostics-routes.ts @@ -1,5 +1,6 @@ import { requireRole } from './auth.js'; import { config } from './config.js'; +import { getDependencyReadiness } from './dependency-readiness.js'; import { getAllFlags } from './feature-flags.js'; import { PRODUCT_ID } from './product-config.js'; import { flushEvents, getBufferedEvents } from './telemetry.js'; @@ -50,4 +51,9 @@ export async function diagnosticsRoutes(app: DiagnosticsRouteApp) { featureFlagsEnabled: config.FEATURE_FLAGS_ENABLED, }; }); + + app.get('/api/diagnostics/readiness', async req => { + await assertDiagnosticsAccess(req); + return getDependencyReadiness(); + }); } diff --git a/backend/src/lib/field-encrypt.ts b/backend/src/lib/field-encrypt.ts index 8a1a14c..51464df 100644 --- a/backend/src/lib/field-encrypt.ts +++ b/backend/src/lib/field-encrypt.ts @@ -50,6 +50,23 @@ export function getEncryptor(): FieldEncryptor { return _encryptor; } +export function getEncryptionReadiness(): { + enabled: boolean; + keyProvider: typeof config.FIELD_ENCRYPT_KEY_PROVIDER; + ready: boolean; +} { + const keyReady = + !_enabled || + config.FIELD_ENCRYPT_KEY_PROVIDER !== 'env' || + config.FIELD_ENCRYPT_KEY.trim().length > 0; + + return { + enabled: _enabled, + keyProvider: config.FIELD_ENCRYPT_KEY_PROVIDER, + ready: _enabled && keyReady, + }; +} + /** @internal — for testing only. */ export function _resetEncryptor(): void { _encryptor = null; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c252cc5..f2e8d3f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -719,6 +719,9 @@ importers: '@bytelyst/logger': specifier: '*' version: link:../../learning_ai/learning_ai_common_plat/packages/logger + '@bytelyst/monitoring': + specifier: '*' + version: link:../../learning_ai/learning_ai_common_plat/packages/monitoring '@bytelyst/palace': specifier: '*' version: link:../../learning_ai/learning_ai_common_plat/packages/palace