tpmjs/apps/web/src/lib/executors/index.ts
Ajax Davis 9f0728d6a2 feat: add hot-swappable executor support for collections and agents
- Add executor configuration to Collection and Agent models in Prisma schema
- Create ExecutorConfigPanel component for selecting default or custom executors
- Add executor resolution logic with cascade (Agent → Collection → System Default)
- Create /api/executors/verify endpoint to test custom executor connectivity
- Add executor documentation page at /docs/executors with API specification
- Create deployable Vercel executor template in templates/vercel-executor/
- Update MCP handlers and agent tool execution to use configurable executors
- Add executor types and schemas to @tpmjs/types package

Users can now deploy their own executor instances and configure collections
or agents to use custom executors instead of the TPMJS default executor.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-09 00:19:22 +10:00

335 lines
8.6 KiB
TypeScript

/**
* Executor Resolution Logic
*
* Handles the cascade resolution for hot-swappable executors:
* Agent Config → Collection Config → System Default
*/
import { executePackage } from '@tpmjs/package-executor';
import type {
ExecuteToolRequest,
ExecuteToolResponse,
ExecutorConfig,
ExecutorHealthResponse,
} from '@tpmjs/types/executor';
const DEFAULT_TIMEOUT = 30000; // 30 seconds for custom executors
/**
* Resolve executor configuration using cascade:
* Agent Config → Collection Config → System Default
*
* @param agentConfig - Executor config from Agent (highest priority)
* @param collectionConfig - Executor config from Collection (medium priority)
* @returns Resolved executor configuration
*/
export function resolveExecutorConfig(
agentConfig: ExecutorConfig | null | undefined,
collectionConfig: ExecutorConfig | null | undefined
): ExecutorConfig {
// Agent config takes precedence
if (agentConfig && agentConfig.type !== 'default') {
return agentConfig;
}
// Fall back to collection config
if (collectionConfig && collectionConfig.type !== 'default') {
return collectionConfig;
}
// System default
return { type: 'default' };
}
/**
* Parse executor config from database JSON
* Handles null/undefined and validates the shape
*/
export function parseExecutorConfig(
executorType: string | null | undefined,
executorConfig: unknown
): ExecutorConfig | null {
if (!executorType) {
return null;
}
if (executorType === 'default') {
return { type: 'default' };
}
if (executorType === 'custom_url' && executorConfig && typeof executorConfig === 'object') {
const config = executorConfig as { url?: string; apiKey?: string };
if (config.url && typeof config.url === 'string') {
return {
type: 'custom_url',
url: config.url,
apiKey: typeof config.apiKey === 'string' ? config.apiKey : undefined,
};
}
}
return null;
}
/**
* Execute a tool using a custom URL executor
*/
async function executeWithCustomUrl(
url: string,
apiKey: string | undefined,
request: ExecuteToolRequest,
timeout: number = DEFAULT_TIMEOUT
): Promise<ExecuteToolResponse> {
const startTime = Date.now();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (apiKey) {
headers.Authorization = `Bearer ${apiKey}`;
}
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
const response = await fetch(`${url}/execute-tool`, {
method: 'POST',
headers,
body: JSON.stringify(request),
signal: controller.signal,
});
clearTimeout(timeoutId);
const executionTimeMs = Date.now() - startTime;
if (!response.ok) {
const errorData = (await response.json().catch(() => ({ error: 'Unknown error' }))) as {
error?: string;
};
return {
success: false,
error: errorData.error || `Executor error: ${response.status}`,
executionTimeMs,
};
}
const result = (await response.json()) as ExecuteToolResponse;
return {
success: result.success,
output: result.output,
error: result.error,
executionTimeMs: result.executionTimeMs || executionTimeMs,
};
} catch (error) {
const executionTimeMs = Date.now() - startTime;
if (error instanceof Error && error.name === 'AbortError') {
return {
success: false,
error: 'Execution timeout',
executionTimeMs,
};
}
return {
success: false,
error: error instanceof Error ? error.message : String(error),
executionTimeMs,
};
}
}
/**
* Execute a tool using the resolved executor configuration
*
* @param config - Resolved executor config (or null for default)
* @param request - Tool execution request
* @returns Execution result
*/
export async function executeWithExecutor(
config: ExecutorConfig | null,
request: ExecuteToolRequest
): Promise<ExecuteToolResponse> {
const resolvedConfig = config ?? { type: 'default' };
// Use custom URL executor
if (resolvedConfig.type === 'custom_url' && resolvedConfig.url) {
return executeWithCustomUrl(resolvedConfig.url, resolvedConfig.apiKey, request);
}
// Default: use existing package-executor (which uses SANDBOX_EXECUTOR_URL)
const result = await executePackage(request.packageName, request.name, request.params);
return {
success: result.success,
output: result.output,
error: result.error,
executionTimeMs: result.executionTimeMs,
};
}
/**
* Check the health of a custom executor URL
*
* @param url - Executor URL to check
* @param apiKey - Optional API key for authentication
* @returns Health check response or error
*/
export async function checkExecutorHealth(
url: string,
apiKey?: string
): Promise<{ healthy: boolean; response?: ExecutorHealthResponse; error?: string }> {
const headers: Record<string, string> = {};
if (apiKey) {
headers.Authorization = `Bearer ${apiKey}`;
}
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout for health checks
const response = await fetch(`${url}/health`, {
method: 'GET',
headers,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
return {
healthy: false,
error: `Health check failed: ${response.status}`,
};
}
const data = (await response.json()) as ExecutorHealthResponse;
return {
healthy: data.status === 'ok' || data.status === 'degraded',
response: data,
};
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
return {
healthy: false,
error: 'Health check timeout',
};
}
return {
healthy: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
/**
* Test a custom executor by running a simple tool execution
*
* @param url - Executor URL to test
* @param apiKey - Optional API key for authentication
* @returns Test execution result
*/
export async function testExecutor(
url: string,
apiKey?: string
): Promise<{ success: boolean; executionTimeMs: number; error?: string }> {
const testRequest: ExecuteToolRequest = {
packageName: '@anthropic-ai/tpmjs-hello',
name: 'helloWorld',
params: { name: 'test' },
};
const result = await executeWithCustomUrl(url, apiKey, testRequest, 15000); // 15 second timeout for test
return {
success: result.success,
executionTimeMs: result.executionTimeMs,
error: result.error,
};
}
/**
* Verify a custom executor URL is valid and operational
*
* @param url - Executor URL to verify
* @param apiKey - Optional API key for authentication
* @returns Verification result
*/
export async function verifyExecutor(
url: string,
apiKey?: string
): Promise<{
valid: boolean;
healthCheck?: { healthy: boolean; response?: ExecutorHealthResponse };
testExecution?: { success: boolean; executionTimeMs: number };
errors: string[];
}> {
const errors: string[] = [];
// Validate URL format
try {
const parsed = new URL(url);
if (parsed.protocol !== 'https:' && process.env.NODE_ENV === 'production') {
errors.push('Custom executor URL must use HTTPS in production');
}
} catch {
errors.push('Invalid URL format');
return { valid: false, errors };
}
// Check health endpoint
const healthResult = await checkExecutorHealth(url, apiKey);
if (!healthResult.healthy) {
errors.push(healthResult.error || 'Health check failed');
}
// Test execution (only if health check passed)
let testResult: { success: boolean; executionTimeMs: number } | undefined;
if (healthResult.healthy) {
const execResult = await testExecutor(url, apiKey);
testResult = {
success: execResult.success,
executionTimeMs: execResult.executionTimeMs,
};
if (!execResult.success) {
errors.push(execResult.error || 'Test execution failed');
}
}
return {
valid: errors.length === 0,
healthCheck: {
healthy: healthResult.healthy,
response: healthResult.response,
},
testExecution: testResult,
errors,
};
}
/**
* Get executor description for display
*/
export function getExecutorDescription(config: ExecutorConfig | null): string {
if (!config || config.type === 'default') {
return 'TPMJS Default Executor';
}
if (config.type === 'custom_url') {
try {
const url = new URL(config.url);
return `Custom: ${url.hostname}`;
} catch {
return 'Custom Executor';
}
}
return 'Unknown Executor';
}