From 0062cfee8dad7db69ddd906cd14f8ad4d97d6139 Mon Sep 17 00:00:00 2001 From: Ajax Davis Date: Wed, 17 Dec 2025 12:56:25 +1000 Subject: [PATCH] feat(schema-extraction): auto-extract inputSchema via executor during sync - Add inputSchema, schemaSource, schemaExtractedAt fields to Tool model - Create schema extraction helper that calls executor's /load-and-describe - Update sync routes to extract schema synchronously after tool upsert - Reduce changes feed batch size from 100 to 30 for extraction time - Update /api/tools/update-schema to store full JSON Schema - Add /api/tools/extract-schema endpoint for manual re-extraction - Update tool page UI with schema source badge and re-extract button - Add deprecation comments to parameters, returns, aiAgent in types Authors no longer need to define inputSchema in package.json - it's now automatically extracted from the tool at sync time. Falls back to author-provided parameters if extraction fails. --- apps/web/src/app/api/sync/changes/route.ts | 46 ++++- apps/web/src/app/api/sync/keyword/route.ts | 42 ++++- .../src/app/api/tools/extract-schema/route.ts | 159 ++++++++++++++++++ .../src/app/api/tools/update-schema/route.ts | 68 ++++---- apps/web/src/app/tool/[...slug]/page.tsx | 129 ++++++++++++-- apps/web/src/lib/schema-extraction.ts | 132 +++++++++++++++ packages/db/prisma/schema.prisma | 11 +- packages/types/src/tpmjs.ts | 22 +++ 8 files changed, 556 insertions(+), 53 deletions(-) create mode 100644 apps/web/src/app/api/tools/extract-schema/route.ts create mode 100644 apps/web/src/lib/schema-extraction.ts diff --git a/apps/web/src/app/api/sync/changes/route.ts b/apps/web/src/app/api/sync/changes/route.ts index 5c6ee50..8b8ab3a 100644 --- a/apps/web/src/app/api/sync/changes/route.ts +++ b/apps/web/src/app/api/sync/changes/route.ts @@ -4,6 +4,7 @@ import { validateTpmjsField } from '@tpmjs/types/tpmjs'; import { type NextRequest, NextResponse } from 'next/server'; import { env } from '~/env'; import { performHealthCheck } from '~/lib/health-check/health-check-service'; +import { convertJsonSchemaToParameters, extractToolSchema } from '~/lib/schema-extraction'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -42,10 +43,10 @@ export async function POST(request: NextRequest) { ? String((checkpoint.checkpoint as { lastSeq?: string })?.lastSeq || '0') : '0'; - // Fetch changes from NPM (limit to 100 per run to avoid timeouts) + // Fetch changes from NPM (limit to 30 per run to allow time for schema extraction) const changesResult = await fetchChanges({ since: lastSeq, - limit: 100, + limit: 30, includeDocs: false, }); @@ -150,6 +151,8 @@ export async function POST(request: NextRequest) { // biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround aiAgent: toolDef.aiAgent ? (toolDef.aiAgent as any) : undefined, qualityScore: null, // Will be calculated by metrics sync + // Schema will be extracted below + schemaSource: toolDef.parameters ? 'author' : null, }, update: { description: toolDef.description, @@ -162,7 +165,44 @@ export async function POST(request: NextRequest) { }, }); - // Trigger immediate health check (non-blocking) + // Extract schema synchronously from executor + // Note: We pass null for env as schema extraction doesn't need env values + const schemaResult = await extractToolSchema( + pkg.name, + toolDef.exportName, + pkg.version, + null + ); + + if (schemaResult.success) { + // Update tool with extracted schema + await prisma.tool.update({ + where: { id: upsertedTool.id }, + data: { + // biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround + inputSchema: schemaResult.inputSchema as any, + // Also update parameters array for backward compatibility + // biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround + parameters: convertJsonSchemaToParameters(schemaResult.inputSchema) as any, + schemaSource: 'extracted', + schemaExtractedAt: new Date(), + }, + }); + console.log(`Schema extracted for ${pkg.name}/${toolDef.exportName}`); + } else { + // Extraction failed - mark schema source appropriately + console.log( + `Schema extraction failed for ${pkg.name}/${toolDef.exportName}: ${schemaResult.error}` + ); + await prisma.tool.update({ + where: { id: upsertedTool.id }, + data: { + schemaSource: toolDef.parameters ? 'author' : null, + }, + }); + } + + // Trigger health check (non-blocking) for execution testing performHealthCheck(upsertedTool.id, 'sync').catch((err) => { console.error( `Health check failed for ${pkg.name}/${toolDef.exportName} (${upsertedTool.id}):`, diff --git a/apps/web/src/app/api/sync/keyword/route.ts b/apps/web/src/app/api/sync/keyword/route.ts index e276396..25a7b98 100644 --- a/apps/web/src/app/api/sync/keyword/route.ts +++ b/apps/web/src/app/api/sync/keyword/route.ts @@ -4,6 +4,7 @@ import { validateTpmjsField } from '@tpmjs/types/tpmjs'; import { type NextRequest, NextResponse } from 'next/server'; import { env } from '~/env'; import { performHealthCheck } from '~/lib/health-check/health-check-service'; +import { convertJsonSchemaToParameters, extractToolSchema } from '~/lib/schema-extraction'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -164,6 +165,8 @@ export async function POST(request: NextRequest) { // biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround aiAgent: toolDef.aiAgent ? (toolDef.aiAgent as any) : undefined, qualityScore: null, // Will be calculated by metrics sync + // Schema will be extracted below + schemaSource: toolDef.parameters ? 'author' : null, }, update: { description: toolDef.description, @@ -176,7 +179,44 @@ export async function POST(request: NextRequest) { }, }); - // Trigger immediate health check (non-blocking) + // Extract schema synchronously from executor + // Note: We pass null for env as schema extraction doesn't need env values + const schemaResult = await extractToolSchema( + pkg.name, + toolDef.exportName, + pkg.version, + null + ); + + if (schemaResult.success) { + // Update tool with extracted schema + await prisma.tool.update({ + where: { id: upsertedTool.id }, + data: { + // biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround + inputSchema: schemaResult.inputSchema as any, + // Also update parameters array for backward compatibility + // biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround + parameters: convertJsonSchemaToParameters(schemaResult.inputSchema) as any, + schemaSource: 'extracted', + schemaExtractedAt: new Date(), + }, + }); + console.log(`Schema extracted for ${pkg.name}/${toolDef.exportName}`); + } else { + // Extraction failed - mark schema source appropriately + console.log( + `Schema extraction failed for ${pkg.name}/${toolDef.exportName}: ${schemaResult.error}` + ); + await prisma.tool.update({ + where: { id: upsertedTool.id }, + data: { + schemaSource: toolDef.parameters ? 'author' : null, + }, + }); + } + + // Trigger health check (non-blocking) for execution testing performHealthCheck(upsertedTool.id, 'sync').catch((err) => { console.error( `Health check failed for ${pkg.name}/${toolDef.exportName} (${upsertedTool.id}):`, diff --git a/apps/web/src/app/api/tools/extract-schema/route.ts b/apps/web/src/app/api/tools/extract-schema/route.ts new file mode 100644 index 0000000..26e7c01 --- /dev/null +++ b/apps/web/src/app/api/tools/extract-schema/route.ts @@ -0,0 +1,159 @@ +import { prisma } from '@tpmjs/db'; +import { type NextRequest, NextResponse } from 'next/server'; +import { convertJsonSchemaToParameters, extractToolSchema } from '~/lib/schema-extraction'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +/** + * POST /api/tools/extract-schema + * Manually trigger schema extraction for a tool + * + * Body: + * - packageName: npm package name + * - exportName: exported function name + * + * Rate limited to 1 extraction per minute per tool + */ +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { packageName, exportName } = body; + + if (!packageName || !exportName) { + return NextResponse.json( + { success: false, error: 'packageName and exportName are required' }, + { status: 400 } + ); + } + + console.log('[Extract Schema] Looking up tool:', { packageName, exportName }); + + // Find the tool by package name and export name + const tool = await prisma.tool.findFirst({ + where: { + exportName, + package: { + npmPackageName: packageName, + }, + }, + include: { + package: { + select: { + npmPackageName: true, + npmVersion: true, + env: true, + }, + }, + }, + }); + + if (!tool) { + console.log('[Extract Schema] Tool not found:', { packageName, exportName }); + return NextResponse.json({ success: false, error: 'Tool not found' }, { status: 404 }); + } + + // Rate limit: 1 minute cooldown + if (tool.schemaExtractedAt) { + const timeSinceLastExtraction = Date.now() - tool.schemaExtractedAt.getTime(); + const cooldownMs = 60000; // 1 minute + + if (timeSinceLastExtraction < cooldownMs) { + const retryAfter = Math.ceil((cooldownMs - timeSinceLastExtraction) / 1000); + return NextResponse.json( + { + success: false, + error: 'Rate limited', + message: `Please wait ${retryAfter} seconds before trying again`, + retryAfter, + }, + { status: 429 } + ); + } + } + + console.log('[Extract Schema] Extracting schema for:', { + packageName: tool.package.npmPackageName, + exportName: tool.exportName, + version: tool.package.npmVersion, + }); + + // Extract schema from executor + const schemaResult = await extractToolSchema( + tool.package.npmPackageName, + tool.exportName, + tool.package.npmVersion, + tool.package.env as Record | null + ); + + if (schemaResult.success) { + // Update tool with extracted schema + const updatedTool = await prisma.tool.update({ + where: { id: tool.id }, + data: { + // biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround + inputSchema: schemaResult.inputSchema as any, + // Also update parameters array for backward compatibility + // biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround + parameters: convertJsonSchemaToParameters(schemaResult.inputSchema) as any, + schemaSource: 'extracted', + schemaExtractedAt: new Date(), + }, + select: { + id: true, + exportName: true, + inputSchema: true, + parameters: true, + schemaSource: true, + schemaExtractedAt: true, + }, + }); + + console.log('[Extract Schema] Schema extracted successfully:', { + toolId: updatedTool.id, + exportName: updatedTool.exportName, + schemaSource: updatedTool.schemaSource, + }); + + return NextResponse.json({ + success: true, + message: 'Schema extracted successfully', + schemaSource: 'extracted', + tool: updatedTool, + }); + } + + // Extraction failed + console.log('[Extract Schema] Extraction failed:', { + packageName, + exportName, + error: schemaResult.error, + }); + + // Update tool to mark extraction attempt + await prisma.tool.update({ + where: { id: tool.id }, + data: { + schemaExtractedAt: new Date(), // Update timestamp even on failure for rate limiting + }, + }); + + return NextResponse.json({ + success: false, + error: 'Schema extraction failed', + message: schemaResult.error, + schemaSource: tool.schemaSource, + }); + } catch (error) { + console.error('[Extract Schema] Error:', error); + + return NextResponse.json( + { + success: false, + error: 'Failed to extract schema', + message: error instanceof Error ? error.message : 'Unknown error', + }, + { status: 500 } + ); + } +} diff --git a/apps/web/src/app/api/tools/update-schema/route.ts b/apps/web/src/app/api/tools/update-schema/route.ts index 4bfadac..1eb16b6 100644 --- a/apps/web/src/app/api/tools/update-schema/route.ts +++ b/apps/web/src/app/api/tools/update-schema/route.ts @@ -1,15 +1,17 @@ import { prisma } from '@tpmjs/db'; import { type NextRequest, NextResponse } from 'next/server'; +import { convertJsonSchemaToParameters } from '~/lib/schema-extraction'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; /** * POST /api/tools/update-schema - * Update a tool's input schema (parameters field) + * Update a tool's input schema * * Called by the executor when it loads a tool and discovers its schema. * Looks up tool by packageName + exportName (unique constraint). + * Stores the full JSON Schema and also converts to parameters array for backward compatibility. * * Body: * - packageName: npm package name @@ -48,7 +50,8 @@ export async function POST(request: NextRequest) { }, select: { id: true, - parameters: true, + inputSchema: true, + schemaSource: true, }, }); @@ -60,49 +63,39 @@ export async function POST(request: NextRequest) { ); } - // Convert JSON Schema to our parameters format - const parameters: Array<{ - name: string; - type: string; - required: boolean; - description: string; - }> = []; - if (inputSchema.properties) { - for (const [name, prop] of Object.entries(inputSchema.properties)) { - const propDef = prop as { type?: string; description?: string }; - parameters.push({ - name, - type: propDef.type || 'string', - required: inputSchema.required?.includes(name) || false, - description: propDef.description || '', - }); - } - } + // Convert JSON Schema to parameters array for backward compatibility + const parameters = convertJsonSchemaToParameters(inputSchema); - // Check if parameters already match (avoid unnecessary updates) - const existingParams = tool.parameters as Array<{ name: string }> | null; - const existingParamNames = - existingParams - ?.map((p) => p.name) - .sort() - .join(',') || ''; - const newParamNames = parameters - .map((p) => p.name) - .sort() - .join(','); - - if (existingParamNames === newParamNames && parameters.length > 0) { + // Check if schema already matches (avoid unnecessary updates) + const existingSchema = tool.inputSchema as Record | null; + if ( + existingSchema && + tool.schemaSource === 'extracted' && + JSON.stringify(existingSchema) === JSON.stringify(inputSchema) + ) { console.log('[Update Schema] Schema already up to date:', { packageName, exportName }); return NextResponse.json({ success: true, updated: false, message: 'Schema already up to date', + schemaSource: tool.schemaSource, }); } - // Update the tool - const updateData: { parameters: typeof parameters; description?: string } = { + // Build update data + const updateData: { + // biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround + inputSchema: any; + // biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround + parameters: any; + schemaSource: string; + schemaExtractedAt: Date; + description?: string; + } = { + inputSchema, parameters, + schemaSource: 'extracted', + schemaExtractedAt: new Date(), }; if (description) { @@ -117,6 +110,9 @@ export async function POST(request: NextRequest) { exportName: true, description: true, parameters: true, + inputSchema: true, + schemaSource: true, + schemaExtractedAt: true, }, }); @@ -125,11 +121,13 @@ export async function POST(request: NextRequest) { exportName: updatedTool.exportName, parameterCount: parameters.length, parameterNames: parameters.map((p) => p.name), + schemaSource: updatedTool.schemaSource, }); return NextResponse.json({ success: true, updated: true, + schemaSource: updatedTool.schemaSource, tool: updatedTool, }); } catch (error) { diff --git a/apps/web/src/app/tool/[...slug]/page.tsx b/apps/web/src/app/tool/[...slug]/page.tsx index 3aef081..547daca 100644 --- a/apps/web/src/app/tool/[...slug]/page.tsx +++ b/apps/web/src/app/tool/[...slug]/page.tsx @@ -47,6 +47,9 @@ interface Tool { required: boolean; default?: unknown; }> | null; + inputSchema: Record | null; + schemaSource: 'extracted' | 'author' | null; + schemaExtractedAt: string | null; returns: { type: string; description: string; @@ -77,6 +80,7 @@ export default function ToolDetailPage({ const [error, setError] = useState(null); const [slug, setSlug] = useState(''); const [recheckLoading, setRecheckLoading] = useState(false); + const [extractSchemaLoading, setExtractSchemaLoading] = useState(false); useEffect(() => { // Join slug array to reconstruct package name (e.g., ['@tpmjs', 'text-transformer'] -> '@tpmjs/text-transformer') @@ -208,6 +212,40 @@ export default function ToolDetailPage({ } }; + const extractSchema = async () => { + if (!tool) return; + + setExtractSchemaLoading(true); + try { + const response = await fetch('/api/tools/extract-schema', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + packageName: tool.package.npmPackageName, + exportName: tool.exportName, + }), + }); + + const data = await response.json(); + + if (!response.ok) { + alert(data.message || data.error || 'Schema extraction failed'); + return; + } + + if (data.success) { + // Refresh page to show updated schema + window.location.reload(); + } else { + alert(data.message || 'Schema extraction failed'); + } + } catch { + alert('Failed to extract schema'); + } finally { + setExtractSchemaLoading(false); + } + }; + return (