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
This commit is contained in:
parent
572b7a081d
commit
25dec1795b
2 changed files with 198 additions and 66 deletions
|
|
@ -37,51 +37,38 @@ function getConversationEnv(conversationId: string): Record<string, string> {
|
||||||
return conversationEnv.get(conversationId) || {};
|
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
|
* Report tool execution result to centralized health service
|
||||||
* Non-blocking - logs error and triggers health check in background
|
* Non-blocking - calls web app API which has all the health logic
|
||||||
*/
|
*/
|
||||||
async function reportToolFailure(
|
async function reportToolResult(
|
||||||
packageName: string,
|
packageName: string,
|
||||||
exportName: string,
|
exportName: string,
|
||||||
error: string,
|
success: boolean,
|
||||||
phase: 'import' | 'execution'
|
error?: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Lazy load Prisma to avoid module initialization failures
|
// Call the web app's centralized health report endpoint
|
||||||
const { prisma } = await import('@tpmjs/db');
|
const response = await fetch(`${TPMJS_API_URL}/api/tools/report-health`, {
|
||||||
|
method: 'POST',
|
||||||
// Find the tool in the database
|
headers: { 'Content-Type': 'application/json' },
|
||||||
const tool = await prisma.tool.findFirst({
|
body: JSON.stringify({
|
||||||
where: {
|
packageName,
|
||||||
exportName,
|
exportName,
|
||||||
package: {
|
success,
|
||||||
npmPackageName: packageName,
|
error,
|
||||||
},
|
}),
|
||||||
},
|
|
||||||
select: { id: true },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!tool) {
|
if (!response.ok) {
|
||||||
console.warn(`⚠️ Tool not found in database for health check: ${packageName}/${exportName}`);
|
console.warn(`⚠️ Failed to report health status: ${response.status}`);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
} 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 controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), 120000);
|
const timeout = setTimeout(() => controller.abort(), 120000);
|
||||||
|
|
||||||
let response;
|
let response: Response | undefined;
|
||||||
let data;
|
let data:
|
||||||
|
| { success: boolean; tool?: { description: string; inputSchema?: unknown }; error?: string }
|
||||||
|
| undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
response = await fetch(`${RAILWAY_SERVICE_URL}/load-and-describe`, {
|
response = await fetch(`${RAILWAY_SERVICE_URL}/load-and-describe`, {
|
||||||
|
|
@ -138,14 +127,12 @@ export async function loadToolDynamically(
|
||||||
const errorText = await response.text();
|
const errorText = await response.text();
|
||||||
console.error(`❌ Railway service error (${response.status}): ${errorText}`);
|
console.error(`❌ Railway service error (${response.status}): ${errorText}`);
|
||||||
|
|
||||||
// Trigger health check update in background (non-blocking)
|
// Report failure to centralized health service (non-blocking)
|
||||||
reportToolFailure(
|
reportToolResult(
|
||||||
packageName,
|
packageName,
|
||||||
exportName,
|
exportName,
|
||||||
`Railway service error (${response.status}): ${errorText}`,
|
false,
|
||||||
'import'
|
`Railway service error (${response.status}): ${errorText}`
|
||||||
).catch((err) =>
|
|
||||||
console.error(`Failed to report tool failure for ${packageName}/${exportName}:`, err)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -153,14 +140,12 @@ export async function loadToolDynamically(
|
||||||
|
|
||||||
data = await response.json();
|
data = await response.json();
|
||||||
|
|
||||||
if (!data.success) {
|
if (!data || !data.success) {
|
||||||
console.error(`❌ Failed to load tool: ${data.error}`);
|
const errorMsg = data?.error || 'Unknown error';
|
||||||
|
console.error(`❌ Failed to load tool: ${errorMsg}`);
|
||||||
|
|
||||||
// Trigger health check update in background (non-blocking)
|
// Report failure to centralized health service (non-blocking)
|
||||||
reportToolFailure(packageName, exportName, data.error || 'Unknown error', 'import').catch(
|
reportToolResult(packageName, exportName, false, errorMsg);
|
||||||
(err) =>
|
|
||||||
console.error(`Failed to report tool failure for ${packageName}/${exportName}:`, err)
|
|
||||||
);
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -170,13 +155,11 @@ export async function loadToolDynamically(
|
||||||
if (fetchError instanceof Error && fetchError.name === 'AbortError') {
|
if (fetchError instanceof Error && fetchError.name === 'AbortError') {
|
||||||
console.error(`❌ Railway request timeout after 120s for ${packageName}/${exportName}`);
|
console.error(`❌ Railway request timeout after 120s for ${packageName}/${exportName}`);
|
||||||
|
|
||||||
reportToolFailure(
|
reportToolResult(
|
||||||
packageName,
|
packageName,
|
||||||
exportName,
|
exportName,
|
||||||
'Railway service timeout (120s) - tool dependencies may be too large',
|
false,
|
||||||
'import'
|
'Railway service timeout (120s) - tool dependencies may be too large'
|
||||||
).catch((err) =>
|
|
||||||
console.error(`Failed to report tool failure for ${packageName}/${exportName}:`, err)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -185,6 +168,12 @@ export async function loadToolDynamically(
|
||||||
throw fetchError;
|
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(`✅ Tool loaded from Railway: ${cacheKey}`);
|
||||||
console.log(`📋 Description: ${data.tool.description}`);
|
console.log(`📋 Description: ${data.tool.description}`);
|
||||||
|
|
||||||
|
|
@ -224,20 +213,17 @@ export async function loadToolDynamically(
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
console.error(`❌ Tool execution failed: ${result.error}`);
|
console.error(`❌ Tool execution failed: ${result.error}`);
|
||||||
|
|
||||||
// Trigger health check update in background (non-blocking)
|
// Report failure to centralized health service (non-blocking)
|
||||||
reportToolFailure(
|
reportToolResult(packageName, exportName, false, result.error || 'Tool execution failed');
|
||||||
packageName,
|
|
||||||
exportName,
|
|
||||||
result.error || 'Tool execution failed',
|
|
||||||
'execution'
|
|
||||||
).catch((err) =>
|
|
||||||
console.error(`Failed to report tool failure for ${packageName}/${exportName}:`, err)
|
|
||||||
);
|
|
||||||
|
|
||||||
throw new Error(result.error || 'Tool execution failed');
|
throw new Error(result.error || 'Tool execution failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`✅ Tool executed successfully in ${result.executionTimeMs}ms`);
|
console.log(`✅ Tool executed successfully in ${result.executionTimeMs}ms`);
|
||||||
|
|
||||||
|
// Report success to centralized health service (non-blocking)
|
||||||
|
reportToolResult(packageName, exportName, true);
|
||||||
|
|
||||||
return result.output;
|
return result.output;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -310,10 +296,8 @@ export async function loadToolsBatch(
|
||||||
console.log('\n❌ Failed Tools:');
|
console.log('\n❌ Failed Tools:');
|
||||||
for (const result of failed) {
|
for (const result of failed) {
|
||||||
console.log(` - ${result.packageName}/${result.exportName}`);
|
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('\n💡 Note: Tool failures have been reported to the health service.');
|
||||||
console.log(' Check individual error logs above for detailed failure reasons.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return tools;
|
return tools;
|
||||||
|
|
|
||||||
148
apps/web/src/app/api/tools/report-health/route.ts
Normal file
148
apps/web/src/app/api/tools/report-health/route.ts
Normal file
|
|
@ -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<NextResponse> {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue