UNPKG

@vfarcic/dot-ai

Version:

AI-powered development productivity platform that enhances software development workflows through intelligent automation and AI-driven assistance

497 lines (496 loc) 19.3 kB
"use strict"; /** * Plugin Manager for dot-ai Plugin System * * Discovers plugins at startup and manages tool routing. * Provides discovered tools to the MCP server for registration. * * PRD #343: kubectl Plugin Migration * * Background Retry: If plugins aren't ready at startup, discovery continues * in the background every 30 seconds. MCP server starts immediately and * remains observable via the version tool. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.PluginManager = exports.PluginDiscoveryError = void 0; const node_fs_1 = require("node:fs"); const plugin_client_1 = require("./plugin-client"); /** Path for plugins config file (mounted from ConfigMap in K8s) */ const PLUGINS_CONFIG_PATH = '/etc/dot-ai/plugins.json'; /** Background retry interval in milliseconds */ const BACKGROUND_RETRY_INTERVAL_MS = 30_000; /** Maximum time to keep retrying in background (10 minutes) */ const BACKGROUND_RETRY_MAX_DURATION_MS = 10 * 60 * 1000; /** * Error thrown when plugin discovery fails */ class PluginDiscoveryError extends Error { failedPlugins; constructor(message, failedPlugins) { super(message); this.failedPlugins = failedPlugins; this.name = 'PluginDiscoveryError'; } } exports.PluginDiscoveryError = PluginDiscoveryError; /** * Manages plugin discovery, registration, and tool routing */ class PluginManager { logger; plugins = new Map(); discoveredPlugins = new Map(); toolToPlugin = new Map(); /** Plugins pending background discovery */ pendingPlugins = []; /** Background retry timer */ backgroundRetryTimer = null; /** When background retry started */ backgroundRetryStartTime = null; /** Callback for background discovery */ onPluginDiscovered = null; constructor(logger) { this.logger = logger; } /** * Parse plugin configuration from file * * Reads from /etc/dot-ai/plugins.json (mounted from ConfigMap in K8s). * Returns empty array if file doesn't exist (plugins only work in-cluster). * Throws on invalid JSON or malformed plugin configuration. */ static parsePluginConfig() { if (!(0, node_fs_1.existsSync)(PLUGINS_CONFIG_PATH)) { return []; } let content; try { content = (0, node_fs_1.readFileSync)(PLUGINS_CONFIG_PATH, 'utf-8'); } catch (err) { throw new Error(`Failed to read plugin config at ${PLUGINS_CONFIG_PATH}: ${err instanceof Error ? err.message : String(err)}`, { cause: err }); } let parsed; try { parsed = JSON.parse(content); } catch (err) { throw new Error(`Invalid JSON in plugin config at ${PLUGINS_CONFIG_PATH}: ${err instanceof Error ? err.message : String(err)}`, { cause: err }); } if (!Array.isArray(parsed)) { throw new Error(`Plugin config at ${PLUGINS_CONFIG_PATH} must be an array, got ${typeof parsed}`); } return parsed.map((p, index) => { if (!p || typeof p !== 'object') { throw new Error(`Plugin at index ${index} must be an object`); } if (!p.url || typeof p.url !== 'string') { throw new Error(`Plugin at index ${index} (${p.name || 'unnamed'}) is missing required 'url' field`); } return { name: p.name || `plugin-${index}`, url: p.url, timeout: p.timeout, required: p.required, }; }); } /** * Set callback for when plugins are discovered in the background * * This allows the MCP server to register new tools when plugins * become available after initial startup. */ setOnPluginDiscovered(callback) { this.onPluginDiscovered = callback; } /** * Discover all configured plugins * * Does a quick initial discovery attempt (2 retries, 1s apart). * Plugins that fail are queued for background retry. * Required plugins that fail will throw PluginDiscoveryError. * * Call startBackgroundDiscovery() after this to enable background retries. */ async discoverPlugins(configs) { if (configs.length === 0) { this.logger.debug('No plugins configured for discovery'); return; } this.logger.info('Starting plugin discovery', { pluginCount: configs.length, plugins: configs.map(c => c.name), }); const results = await Promise.allSettled(configs.map(config => this.discoverPluginQuick(config))); const failed = []; const requiredFailed = []; results.forEach((result, index) => { const config = configs[index]; if (result.status === 'rejected' || (result.status === 'fulfilled' && !result.value)) { const error = result.status === 'rejected' ? result.reason instanceof Error ? result.reason.message : String(result.reason) : 'Discovery failed'; failed.push({ name: config.name, error }); if (config.required) { requiredFailed.push({ name: config.name, error }); } else { // Queue for background retry this.pendingPlugins.push(config); } } }); if (failed.length > 0) { this.logger.warn('Some plugins failed initial discovery', { failed: failed.map(f => f.name), willRetryInBackground: this.pendingPlugins.map(p => p.name), }); } if (requiredFailed.length > 0) { throw new PluginDiscoveryError(`Required plugins failed to discover: ${requiredFailed.map(f => f.name).join(', ')}`, requiredFailed); } this.logger.info('Plugin discovery complete', { discovered: this.discoveredPlugins.size, totalTools: this.toolToPlugin.size, pendingBackgroundRetry: this.pendingPlugins.length, }); } /** * Start background discovery for plugins that failed initial discovery * * Retries every 30 seconds for up to 10 minutes. * When a plugin is discovered, calls the onPluginDiscovered callback. */ startBackgroundDiscovery() { if (this.pendingPlugins.length === 0) { this.logger.debug('No pending plugins for background discovery'); return; } this.backgroundRetryStartTime = Date.now(); this.logger.info('Starting background plugin discovery', { pendingPlugins: this.pendingPlugins.map(p => p.name), retryIntervalMs: BACKGROUND_RETRY_INTERVAL_MS, maxDurationMs: BACKGROUND_RETRY_MAX_DURATION_MS, }); this.scheduleBackgroundRetry(); } /** * Stop background discovery */ stopBackgroundDiscovery() { if (this.backgroundRetryTimer) { clearTimeout(this.backgroundRetryTimer); this.backgroundRetryTimer = null; this.logger.info('Stopped background plugin discovery', { remainingPending: this.pendingPlugins.map(p => p.name), }); } this.backgroundRetryStartTime = null; } /** * Get pending plugins that are still awaiting discovery */ getPendingPlugins() { return this.pendingPlugins.map(p => p.name); } /** * Check if background discovery is active */ isBackgroundDiscoveryActive() { return this.backgroundRetryTimer !== null; } /** * Schedule the next background retry attempt */ scheduleBackgroundRetry() { this.backgroundRetryTimer = setTimeout(async () => { await this.runBackgroundRetry(); }, BACKGROUND_RETRY_INTERVAL_MS); } /** * Run a background retry attempt for all pending plugins */ async runBackgroundRetry() { // Check if we've exceeded max duration if (this.backgroundRetryStartTime) { const elapsed = Date.now() - this.backgroundRetryStartTime; if (elapsed >= BACKGROUND_RETRY_MAX_DURATION_MS) { this.logger.warn('Background plugin discovery timed out', { elapsedMs: elapsed, remainingPending: this.pendingPlugins.map(p => p.name), }); this.stopBackgroundDiscovery(); return; } } this.logger.debug('Running background plugin discovery attempt', { pendingCount: this.pendingPlugins.length, }); // Try each pending plugin const stillPending = []; for (const config of this.pendingPlugins) { const discovered = await this.discoverPluginQuick(config); if (discovered) { // Plugin discovered - notify callback const plugin = this.discoveredPlugins.get(config.name); if (plugin && this.onPluginDiscovered) { this.logger.info('Plugin discovered in background', { name: plugin.name, version: plugin.version, toolCount: plugin.tools.length, }); this.onPluginDiscovered(plugin); } } else { stillPending.push(config); } } this.pendingPlugins = stillPending; // If all discovered, stop background retry if (this.pendingPlugins.length === 0) { this.logger.info('All plugins discovered, stopping background discovery'); this.stopBackgroundDiscovery(); return; } // Schedule next retry this.scheduleBackgroundRetry(); } /** * Quick discovery attempt for a single plugin * * Does 2 retries with 1 second delay. Returns true if discovered, * false if should be queued for background retry. */ async discoverPluginQuick(config) { const client = new plugin_client_1.PluginClient(config, this.logger); const maxRetries = 2; const retryDelayMs = 1000; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { const response = await client.describe(); // Store client for later invocation this.plugins.set(config.name, client); // Store discovered plugin metadata this.discoveredPlugins.set(config.name, { name: config.name, url: config.url, version: response.version, tools: response.tools, discoveredAt: new Date(), }); // Map tools to plugin for (const tool of response.tools) { if (this.toolToPlugin.has(tool.name)) { this.logger.warn('Tool name conflict - overwriting', { tool: tool.name, existingPlugin: this.toolToPlugin.get(tool.name), newPlugin: config.name, }); } this.toolToPlugin.set(tool.name, config.name); } this.logger.info('Plugin discovered', { name: config.name, version: response.version, tools: response.tools.map(t => t.name), attempts: attempt, }); return true; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); if (attempt < maxRetries) { this.logger.debug('Plugin discovery attempt failed, retrying', { plugin: config.name, attempt, error: errorMessage, }); await new Promise(resolve => setTimeout(resolve, retryDelayMs)); } else { this.logger.debug('Plugin discovery failed, will retry in background', { plugin: config.name, attempts: maxRetries, error: errorMessage, }); } } } return false; } /** * Get all discovered tools as AITool format for registration * * Only returns tools where the tool-to-plugin mapping is canonical. * This filters out duplicate tools when multiple plugins define the same tool name. */ getDiscoveredTools() { const tools = []; for (const plugin of this.discoveredPlugins.values()) { for (const tool of plugin.tools) { // Only include tool if this plugin owns it in the routing map // This handles conflicts where multiple plugins define the same tool if (this.toolToPlugin.get(tool.name) === plugin.name) { tools.push(this.convertToAITool(tool)); } } } return tools; } /** * Get tools from a specific plugin */ getPluginTools(pluginName) { const plugin = this.discoveredPlugins.get(pluginName); if (!plugin) { return []; } return plugin.tools.map(t => this.convertToAITool(t)); } /** * Check if a tool is provided by a plugin */ isPluginTool(toolName) { return this.toolToPlugin.has(toolName); } /** * Get the plugin name for a tool */ getToolPlugin(toolName) { return this.toolToPlugin.get(toolName); } /** * Invoke a tool on a specific plugin (explicit routing) * * PRD #359: Unified plugin invocation with explicit plugin specification. * Use this when you know which plugin provides the tool, avoiding * ambiguity when multiple plugins might have tools with the same name. */ async invokeToolOnPlugin(pluginName, toolName, args, state = {}, sessionId) { const client = this.plugins.get(pluginName); if (!client) { return { sessionId: sessionId || '', success: false, error: { code: 'PLUGIN_NOT_AVAILABLE', message: `Plugin '${pluginName}' is not available`, }, state, }; } return client.invoke(toolName, args, state, sessionId); } /** * Create a ToolExecutor that routes plugin tools to plugins * * Returns a function compatible with toolLoop's toolExecutor parameter. * Plugin tools are routed to their plugins via HTTP; non-plugin tools * are routed to the optional fallback executor. * * @param fallbackExecutor Optional executor for non-plugin tools * @returns ToolExecutor function for use in agentic tool loops */ createToolExecutor(fallbackExecutor) { return async (toolName, input) => { // Route to plugin if this is a plugin tool if (this.isPluginTool(toolName)) { this.logger.debug('Routing tool to plugin', { tool: toolName, plugin: this.getToolPlugin(toolName), }); try { const pluginName = this.getToolPlugin(toolName); const response = await this.invokeToolOnPlugin(pluginName, toolName, input); if (response.success) { // PRD #343: Return only the data field to AI, not the full JSON wrapper // This saves tokens and provides cleaner output matching raw command output if (typeof response.result === 'object' && response.result !== null) { const result = response.result; if ('success' in result && 'data' in result) { // Return just the data (raw command output) for successful results // Return error message string for failed results return result.success ? result.data : `Error: ${result.message || result.error || 'Command failed'}`; } } // Fallback for non-standard responses - return result directly return response.result; } else { // Return error as simple string, not JSON return `Error: ${response.error?.message || 'Unknown error'}`; } } catch (err) { // Catch invoke exceptions to prevent tool-loop crashes const message = err instanceof Error ? err.message : String(err); this.logger.error('Plugin invokeToolOnPlugin failed with exception', new Error(message), { tool: toolName, plugin: this.getToolPlugin(toolName), }); return `Error: ${message}`; } } // Fall back to provided executor for non-plugin tools if (fallbackExecutor) { return fallbackExecutor(toolName, input); } // No handler for this tool return `Error: Tool '${toolName}' not found in plugins or fallback executor`; }; } /** * Get list of discovered plugin names */ getDiscoveredPluginNames() { return Array.from(this.discoveredPlugins.keys()); } /** * Get discovered plugin metadata */ getDiscoveredPlugin(name) { return this.discoveredPlugins.get(name); } /** * Get all discovered plugins */ getAllDiscoveredPlugins() { return Array.from(this.discoveredPlugins.values()); } /** * Get plugin statistics */ getStats() { const plugins = Array.from(this.discoveredPlugins.values()).map(p => ({ name: p.name, version: p.version, toolCount: p.tools.length, })); return { pluginCount: this.discoveredPlugins.size, toolCount: this.toolToPlugin.size, plugins, pendingDiscovery: this.pendingPlugins.map(p => p.name), backgroundDiscoveryActive: this.isBackgroundDiscoveryActive(), }; } /** * Convert PluginToolDefinition to AITool format */ convertToAITool(tool) { return { name: tool.name, description: tool.description, inputSchema: tool.inputSchema, }; } } exports.PluginManager = PluginManager;