From 25dec1795b30623bb9cb223ceca39b468ce50f5e Mon Sep 17 00:00:00 2001 From: Ajax Davis Date: Fri, 12 Dec 2025 05:17:04 +1000 Subject: [PATCH] refactor(health): centralize health status logic in web app API - Remove direct DB updates from playground - Add /api/tools/report-health endpoint with all health logic - Playground now reports results to web app API - All env var / validation error detection is in one place - Health status updates based on execution success and error type - Fix type errors with proper null checks --- .../playground/src/lib/dynamic-tool-loader.ts | 116 ++++++-------- .../src/app/api/tools/report-health/route.ts | 148 ++++++++++++++++++ 2 files changed, 198 insertions(+), 66 deletions(-) create mode 100644 apps/web/src/app/api/tools/report-health/route.ts diff --git a/apps/playground/src/lib/dynamic-tool-loader.ts b/apps/playground/src/lib/dynamic-tool-loader.ts index 64bf6dc..14f7c4b 100644 --- a/apps/playground/src/lib/dynamic-tool-loader.ts +++ b/apps/playground/src/lib/dynamic-tool-loader.ts @@ -37,51 +37,38 @@ function getConversationEnv(conversationId: string): Record { return conversationEnv.get(conversationId) || {}; } +// Web app API URL for health status updates +const TPMJS_API_URL = process.env.TPMJS_API_URL || 'https://tpmjs.com'; + /** - * Report tool failure and trigger async health check - * Non-blocking - logs error and triggers health check in background + * Report tool execution result to centralized health service + * Non-blocking - calls web app API which has all the health logic */ -async function reportToolFailure( +async function reportToolResult( packageName: string, exportName: string, - error: string, - phase: 'import' | 'execution' + success: boolean, + error?: string ): Promise { try { - // Lazy load Prisma to avoid module initialization failures - const { prisma } = await import('@tpmjs/db'); - - // Find the tool in the database - const tool = await prisma.tool.findFirst({ - where: { + // Call the web app's centralized health report endpoint + const response = await fetch(`${TPMJS_API_URL}/api/tools/report-health`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + packageName, exportName, - package: { - npmPackageName: packageName, - }, - }, - select: { id: true }, + success, + error, + }), }); - if (!tool) { - console.warn(`āš ļø Tool not found in database for health check: ${packageName}/${exportName}`); - return; + if (!response.ok) { + console.warn(`āš ļø Failed to report health status: ${response.status}`); } - - console.log(`šŸ„ Triggering health check for ${packageName}/${exportName} (${tool.id})`); - - // Update health status immediately (optimistic) - await prisma.tool.update({ - where: { id: tool.id }, - data: { - [phase === 'import' ? 'importHealth' : 'executionHealth']: 'BROKEN', - healthCheckError: error, - lastHealthCheck: new Date(), - }, - }); - - console.log(`āœ… Health status updated for ${packageName}/${exportName}`); } catch (err) { - console.error('āŒ Failed to report tool failure:', err); + // Non-blocking - just log the error + console.error('āŒ Failed to report tool result:', err); } } @@ -115,8 +102,10 @@ export async function loadToolDynamically( const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 120000); - let response; - let data; + let response: Response | undefined; + let data: + | { success: boolean; tool?: { description: string; inputSchema?: unknown }; error?: string } + | undefined; try { response = await fetch(`${RAILWAY_SERVICE_URL}/load-and-describe`, { @@ -138,14 +127,12 @@ export async function loadToolDynamically( const errorText = await response.text(); console.error(`āŒ Railway service error (${response.status}): ${errorText}`); - // Trigger health check update in background (non-blocking) - reportToolFailure( + // Report failure to centralized health service (non-blocking) + reportToolResult( packageName, exportName, - `Railway service error (${response.status}): ${errorText}`, - 'import' - ).catch((err) => - console.error(`Failed to report tool failure for ${packageName}/${exportName}:`, err) + false, + `Railway service error (${response.status}): ${errorText}` ); return null; @@ -153,14 +140,12 @@ export async function loadToolDynamically( data = await response.json(); - if (!data.success) { - console.error(`āŒ Failed to load tool: ${data.error}`); + if (!data || !data.success) { + const errorMsg = data?.error || 'Unknown error'; + console.error(`āŒ Failed to load tool: ${errorMsg}`); - // Trigger health check update in background (non-blocking) - reportToolFailure(packageName, exportName, data.error || 'Unknown error', 'import').catch( - (err) => - console.error(`Failed to report tool failure for ${packageName}/${exportName}:`, err) - ); + // Report failure to centralized health service (non-blocking) + reportToolResult(packageName, exportName, false, errorMsg); return null; } @@ -170,13 +155,11 @@ export async function loadToolDynamically( if (fetchError instanceof Error && fetchError.name === 'AbortError') { console.error(`āŒ Railway request timeout after 120s for ${packageName}/${exportName}`); - reportToolFailure( + reportToolResult( packageName, exportName, - 'Railway service timeout (120s) - tool dependencies may be too large', - 'import' - ).catch((err) => - console.error(`Failed to report tool failure for ${packageName}/${exportName}:`, err) + false, + 'Railway service timeout (120s) - tool dependencies may be too large' ); return null; @@ -185,6 +168,12 @@ export async function loadToolDynamically( throw fetchError; } + // Type guard - data and data.tool are guaranteed after successful response + if (!data?.tool) { + console.error('āŒ Invalid response from Railway: missing tool data'); + return null; + } + console.log(`āœ… Tool loaded from Railway: ${cacheKey}`); console.log(`šŸ“‹ Description: ${data.tool.description}`); @@ -224,20 +213,17 @@ export async function loadToolDynamically( if (!result.success) { console.error(`āŒ Tool execution failed: ${result.error}`); - // Trigger health check update in background (non-blocking) - reportToolFailure( - packageName, - exportName, - result.error || 'Tool execution failed', - 'execution' - ).catch((err) => - console.error(`Failed to report tool failure for ${packageName}/${exportName}:`, err) - ); + // Report failure to centralized health service (non-blocking) + reportToolResult(packageName, exportName, false, result.error || 'Tool execution failed'); throw new Error(result.error || 'Tool execution failed'); } console.log(`āœ… Tool executed successfully in ${result.executionTimeMs}ms`); + + // Report success to centralized health service (non-blocking) + reportToolResult(packageName, exportName, true); + return result.output; }, }); @@ -310,10 +296,8 @@ export async function loadToolsBatch( console.log('\nāŒ Failed Tools:'); for (const result of failed) { console.log(` - ${result.packageName}/${result.exportName}`); - console.log(' (Health check triggered automatically)'); } - console.log('\nšŸ’” Note: Failed tools have been marked as broken in the database.'); - console.log(' Check individual error logs above for detailed failure reasons.'); + console.log('\nšŸ’” Note: Tool failures have been reported to the health service.'); } return tools; diff --git a/apps/web/src/app/api/tools/report-health/route.ts b/apps/web/src/app/api/tools/report-health/route.ts new file mode 100644 index 0000000..f50bf7d --- /dev/null +++ b/apps/web/src/app/api/tools/report-health/route.ts @@ -0,0 +1,148 @@ +import { prisma } from '@tpmjs/db'; +import { type NextRequest, NextResponse } from 'next/server'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +/** + * Check if an error is due to missing environment variables (configuration issue) + * rather than a broken tool (code issue) + */ +function isEnvironmentConfigError(error: string): boolean { + const envErrorPatterns = [ + /is required/i, + /is not set/i, + /missing.*environment/i, + /environment.*missing/i, + /api key.*required/i, + /api key.*not provided/i, + /missing.*api key/i, + /must be set/i, + /not found.*environment/i, + /please set/i, + /please provide/i, + /configure.*environment/i, + ]; + + return envErrorPatterns.some((pattern) => pattern.test(error)); +} + +/** + * Check if an error is due to input validation (Zod validation, URL format, etc.) + * These errors mean the tool is working correctly - it's validating input as expected + */ +function isInputValidationError(error: string): boolean { + const validationErrorPatterns = [ + /must have a valid.*domain/i, + /valid.*path/i, + /invalid.*url/i, + /invalid.*format/i, + /expected.*received/i, + /must be.*string/i, + /must be.*number/i, + /must be.*boolean/i, + /must be.*array/i, + /must be.*object/i, + /validation.*failed/i, + /does not match/i, + /too short/i, + /too long/i, + /minimum.*length/i, + /maximum.*length/i, + ]; + + return validationErrorPatterns.some((pattern) => pattern.test(error)); +} + +/** + * Check if an error is a configuration or input issue (not a broken tool) + */ +function isNonBreakingError(error: string): boolean { + return isEnvironmentConfigError(error) || isInputValidationError(error); +} + +interface ReportHealthRequest { + packageName: string; + exportName: string; + success: boolean; + error?: string; +} + +/** + * POST /api/tools/report-health + * + * Centralized endpoint for reporting tool execution results. + * All health status logic is here - playground and other clients just report results. + * + * This endpoint determines whether a failure should mark the tool as BROKEN or HEALTHY + * based on the error type (env vars, validation = HEALTHY, infrastructure = BROKEN). + */ +export async function POST(request: NextRequest): Promise { + try { + const body: ReportHealthRequest = await request.json(); + const { packageName, exportName, success, error } = body; + + if (!packageName || !exportName) { + return NextResponse.json( + { success: false, error: 'packageName and exportName are required' }, + { status: 400 } + ); + } + + // Find the tool + const tool = await prisma.tool.findFirst({ + where: { + exportName, + package: { npmPackageName: packageName }, + }, + select: { id: true }, + }); + + if (!tool) { + return NextResponse.json({ success: false, error: 'Tool not found' }, { status: 404 }); + } + + // Determine health status based on result + let healthStatus: 'HEALTHY' | 'BROKEN'; + let healthError: string | null = null; + + if (success) { + // Successful execution = HEALTHY + healthStatus = 'HEALTHY'; + } else if (error && isNonBreakingError(error)) { + // Failed due to config/validation = HEALTHY (tool works, just needs setup) + healthStatus = 'HEALTHY'; + console.log( + `ā„¹ļø ${packageName}/${exportName} failed due to config issue (not broken): ${error}` + ); + } else { + // Real failure = BROKEN + healthStatus = 'BROKEN'; + healthError = error || 'Unknown error'; + } + + // Update tool health status + await prisma.tool.update({ + where: { id: tool.id }, + data: { + executionHealth: healthStatus, + healthCheckError: healthError, + lastHealthCheck: new Date(), + }, + }); + + console.log(`šŸ„ Health updated for ${packageName}/${exportName}: ${healthStatus}`); + + return NextResponse.json({ + success: true, + data: { + toolId: tool.id, + healthStatus, + healthError, + }, + }); + } catch (err) { + console.error('Error reporting health:', err); + return NextResponse.json({ success: false, error: 'Failed to report health' }, { status: 500 }); + } +}