feat: add extension registry health checks to doctor, expose registry on Fastify

This commit is contained in:
John Dvorak
2026-05-22 14:11:34 -07:00
parent 26f0e6ad7e
commit dab14ef77d
3 changed files with 175 additions and 3 deletions
@@ -0,0 +1,163 @@
/**
* S8: Doctor thread - Extension registry health checks
*
* Checks:
* - Extension registration status
* - Extension health check results
* - Production safety of registered extensions
*/
import type { ExtensionRegistry, ApophisExtension } from '../../../../extension/types.js'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface ExtensionCheckResult {
name: string
status: 'pass' | 'fail' | 'warn'
message: string
detail?: string
remediation?: string
mode: 'all' | 'verify' | 'observe' | 'qualify'
}
export interface ExtensionCheckOptions {
extensionRegistry?: ExtensionRegistry
env?: string
}
// ---------------------------------------------------------------------------
// Extension presence
// ---------------------------------------------------------------------------
export function checkExtensionPresence(options: ExtensionCheckOptions): ExtensionCheckResult {
const registry = options.extensionRegistry
if (!registry || registry.extensions.length === 0) {
return {
name: 'extensions-presence',
status: 'pass',
message: 'No extensions registered (optional)',
mode: 'all',
}
}
const names = registry.extensions.map((e: ApophisExtension) => e.name).join(', ')
return {
name: 'extensions-presence',
status: 'pass',
message: `${registry.extensions.length} extension(s) registered: ${names}`,
mode: 'all',
}
}
// ---------------------------------------------------------------------------
// Extension health
// ---------------------------------------------------------------------------
export async function checkExtensionHealth(options: ExtensionCheckOptions): Promise<ExtensionCheckResult> {
const registry = options.extensionRegistry
if (!registry || registry.extensions.length === 0) {
return {
name: 'extensions-health',
status: 'pass',
message: 'No extensions to health-check',
mode: 'all',
}
}
try {
const results = await registry.runHealthChecks()
const unhealthy = results.filter((r: { name: string; error?: string }) => r.error)
if (unhealthy.length === 0) {
return {
name: 'extensions-health',
status: 'pass',
message: 'All extensions passed health checks',
mode: 'all',
}
}
const details = unhealthy.map((r: { name: string; error?: string }) => ` ${r.name}: ${r.error}`).join('\n')
return {
name: 'extensions-health',
status: 'warn',
message: `${unhealthy.length} extension(s) failed health checks`,
detail: details,
remediation: 'Review extension configuration and dependencies',
mode: 'all',
}
} catch (error) {
return {
name: 'extensions-health',
status: 'warn',
message: 'Could not run extension health checks',
detail: error instanceof Error ? error.message : String(error),
mode: 'all',
}
}
}
// ---------------------------------------------------------------------------
// Production safety
// ---------------------------------------------------------------------------
export function checkExtensionProductionSafety(options: ExtensionCheckOptions): ExtensionCheckResult {
const registry = options.extensionRegistry
const env = options.env || process.env.NODE_ENV || 'local'
if (!registry || registry.extensions.length === 0) {
return {
name: 'extensions-production-safety',
status: 'pass',
message: 'No extensions to check',
mode: 'all',
}
}
const isProd = env === 'production' || env === 'prod'
if (!isProd) {
return {
name: 'extensions-production-safety',
status: 'pass',
message: 'Not in production — extension safety check skipped',
mode: 'all',
}
}
const unsafe = registry.extensions.filter((e: ApophisExtension) => e.productionSafe === false)
if (unsafe.length === 0) {
return {
name: 'extensions-production-safety',
status: 'pass',
message: 'All extensions are production-safe',
mode: 'all',
}
}
const names = unsafe.map((e: ApophisExtension) => e.name).join(', ')
return {
name: 'extensions-production-safety',
status: 'warn',
message: `${unsafe.length} extension(s) not marked production-safe`,
detail: `Unsafe extensions: ${names}`,
remediation: 'Remove these extensions from production config or mark them productionSafe',
mode: 'all',
}
}
// ---------------------------------------------------------------------------
// Aggregation
// ---------------------------------------------------------------------------
export async function runExtensionChecks(options: ExtensionCheckOptions): Promise<ExtensionCheckResult[]> {
return [
checkExtensionPresence(options),
await checkExtensionHealth(options),
checkExtensionProductionSafety(options),
]
}
+10 -2
View File
@@ -24,6 +24,7 @@ import { runRouteChecks } from './checks/routes.js';
import { runSafetyChecks } from './checks/safety.js'; import { runSafetyChecks } from './checks/safety.js';
import { runDocsChecks } from './checks/docs.js'; import { runDocsChecks } from './checks/docs.js';
import { runPluginContractChecks } from './checks/plugin-contracts.js'; import { runPluginContractChecks } from './checks/plugin-contracts.js';
import { runExtensionChecks } from './checks/extensions.js';
import { renderJson } from '../../renderers/json.js'; import { renderJson } from '../../renderers/json.js';
@@ -197,7 +198,7 @@ async function runPackageChecks(
checks.push({ ...result, package: packageName }); checks.push({ ...result, package: packageName });
} }
// 6. Plugin contract checks (all modes) // 6. Plugin contract and extension checks (all modes)
try { try {
const { loadApp } = await import('../../core/app-loader.js') const { loadApp } = await import('../../core/app-loader.js')
const loaded = await loadApp(cwd) const loaded = await loadApp(cwd)
@@ -210,9 +211,16 @@ async function runPackageChecks(
checks.push({ ...result, package: packageName }) checks.push({ ...result, package: packageName })
} }
} }
const extReg = (fastify as any)?._apophisExtensionRegistry
if (extReg) {
const extResults = await runExtensionChecks({ extensionRegistry: extReg, env: detectEnvironment() })
for (const result of extResults) {
checks.push({ ...result, package: packageName })
}
}
} }
} catch { } catch {
// App can't be loaded — skip plugin contract checks // App can't be loaded — skip plugin/extension checks
} }
return checks; return checks;
+2 -1
View File
@@ -147,8 +147,9 @@ export const apophisPlugin = async (fastify: FastifyInstance, opts: ApophisOptio
}, },
} }
fastify.decorate('apophis', decorations) fastify.decorate('apophis', decorations)
// Expose plugin contract registry for CLI qualify access // Expose registries for CLI qualify and doctor access
;(fastify as unknown as Record<string, unknown>)._apophisPluginContractRegistry = pluginContractRegistry ;(fastify as unknown as Record<string, unknown>)._apophisPluginContractRegistry = pluginContractRegistry
;(fastify as unknown as Record<string, unknown>)._apophisExtensionRegistry = extensionRegistry
// Runtime validation may block requests, so it is disabled in production. // Runtime validation may block requests, so it is disabled in production.
// Observe sinks are explicitly non-blocking and are allowed in production. // Observe sinks are explicitly non-blocking and are allowed in production.
const isProd = process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'prod' const isProd = process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'prod'