@buger/probe-chat
Version:
CLI and web interface for Probe code search (formerly @buger/probe-web and @buger/probe-chat)
567 lines (487 loc) • 17.2 kB
JavaScript
/**
* Backend manager for handling multiple implementation backends
* @module BackendManager
*/
import { BackendError, ErrorTypes, ErrorHandler, RetryHandler } from './utils.js';
import { getDefaultTimeoutMs } from './timeouts.js';
/**
* Manages registration, selection, and execution of implementation backends
* @class
*/
class BackendManager {
/**
* @param {Object} config - Backend manager configuration
* @param {string} config.defaultBackend - Default backend name
* @param {string[]} [config.fallbackBackends] - Fallback backend names
* @param {string} [config.selectionStrategy='auto'] - Backend selection strategy
* @param {number} [config.maxConcurrentSessions=3] - Maximum concurrent sessions
* @param {number} [config.timeout=300000] - Default timeout in milliseconds
* @param {number} [config.retryAttempts=2] - Number of retry attempts
*/
constructor(config) {
this.config = {
defaultBackend: config.defaultBackend || 'aider',
selectionStrategy: config.selectionStrategy || 'auto',
maxConcurrentSessions: config.maxConcurrentSessions || 3,
timeout: config.timeout || getDefaultTimeoutMs(), // Use centralized default (20 minutes)
retryAttempts: config.retryAttempts || 2,
...config
};
this.backends = new Map();
this.activeSessionCount = 0;
this.sessionBackendMap = new Map();
this.initialized = false;
}
/**
* Initialize the backend manager
* @returns {Promise<void>}
*/
async initialize() {
if (this.initialized) return;
// Initialize any pre-registered backends
for (const [name, backend] of this.backends) {
try {
if (!backend.initialized) {
await backend.initialize(this.config.backends?.[name] || {});
}
} catch (error) {
console.warn(`Failed to initialize backend '${name}':`, error.message);
}
}
this.initialized = true;
}
/**
* Register a new backend
* @param {import('../backends/BaseBackend')} backend - Backend instance
* @returns {Promise<void>}
*/
async registerBackend(backend) {
if (!backend || !backend.name) {
throw new Error('Invalid backend: must have a name property');
}
// Check if backend already registered
if (this.backends.has(backend.name)) {
console.warn(`Backend '${backend.name}' is already registered, replacing...`);
}
this.backends.set(backend.name, backend);
// Initialize if manager is already initialized
if (this.initialized && !backend.initialized) {
try {
await backend.initialize(this.config.backends?.[backend.name] || {});
} catch (error) {
console.warn(`Failed to initialize backend '${backend.name}':`, error.message);
}
}
}
/**
* Unregister a backend
* @param {string} name - Backend name
* @returns {Promise<void>}
*/
async unregisterBackend(name) {
const backend = this.backends.get(name);
if (backend) {
await backend.cleanup();
this.backends.delete(name);
}
}
/**
* Get list of available backend names
* @returns {string[]}
*/
getAvailableBackends() {
return Array.from(this.backends.keys());
}
/**
* Get backend instance by name
* @param {string} name - Backend name
* @returns {import('../backends/BaseBackend')|null}
*/
getBackend(name) {
return this.backends.get(name) || null;
}
/**
* Get backend information
* @param {string} name - Backend name
* @returns {import('../types/BackendTypes').BackendInfo|null}
*/
async getBackendInfo(name) {
const backend = this.backends.get(name);
if (!backend) return null;
const info = backend.getInfo();
info.available = await backend.isAvailable();
return info;
}
/**
* Select appropriate backend for request
* @param {import('../types/BackendTypes').ImplementRequest} request - Implementation request
* @returns {Promise<string>} Selected backend name
*/
async selectBackend(request) {
switch (this.config.selectionStrategy) {
case 'preference':
return this.selectByPreference(request);
case 'capability':
return this.selectByCapability(request);
case 'auto':
default:
return this.selectAuto(request);
}
}
/**
* Select backend using auto strategy
* @param {import('../types/BackendTypes').ImplementRequest} request - Implementation request
* @returns {Promise<string>}
* @private
*/
async selectAuto(request) {
console.error('[BackendManager] Starting backend selection with strategy: auto');
console.error(`[BackendManager] Default backend: ${this.config.defaultBackend}`);
console.error(`[BackendManager] Available backends: ${Array.from(this.backends.keys()).join(', ')}`);
// First try explicit backend if specified
if (request.options?.backend && this.backends.has(request.options.backend)) {
console.error(`[BackendManager] Checking explicit backend: ${request.options.backend}`);
const backend = this.backends.get(request.options.backend);
if (await backend.isAvailable()) {
console.error(`[BackendManager] Selected explicit backend: ${request.options.backend}`);
return request.options.backend;
} else {
// If explicit backend is not available, fail immediately (no fallbacks)
console.error(`[BackendManager] Explicit backend ${request.options.backend} is not available`);
throw new BackendError(
`Requested backend '${request.options.backend}' is not available`,
ErrorTypes.BACKEND_NOT_FOUND,
'BACKEND_NOT_AVAILABLE'
);
}
}
// Try default backend
if (this.backends.has(this.config.defaultBackend)) {
console.error(`[BackendManager] Checking default backend: ${this.config.defaultBackend}`);
const backend = this.backends.get(this.config.defaultBackend);
const isAvailable = await backend.isAvailable();
console.error(`[BackendManager] Default backend ${this.config.defaultBackend} available: ${isAvailable}`);
if (isAvailable) {
console.error(`[BackendManager] Selected default backend: ${this.config.defaultBackend}`);
return this.config.defaultBackend;
}
}
// No fallbacks - if default backend is not available, fail
console.error('[BackendManager] No available backends found!');
throw new BackendError(
`Default backend '${this.config.defaultBackend}' is not available`,
ErrorTypes.BACKEND_NOT_FOUND,
'NO_AVAILABLE_BACKENDS'
);
}
/**
* Select backend by user preference
* @param {import('../types/BackendTypes').ImplementRequest} request - Implementation request
* @returns {Promise<string>}
* @private
*/
async selectByPreference(request) {
const preference = request.options?.backend || this.config.defaultBackend;
if (!this.backends.has(preference)) {
throw new BackendError(
`Preferred backend '${preference}' not found`,
ErrorTypes.BACKEND_NOT_FOUND,
'PREFERRED_BACKEND_NOT_FOUND'
);
}
const backend = this.backends.get(preference);
if (!(await backend.isAvailable())) {
throw new BackendError(
`Preferred backend '${preference}' is not available`,
ErrorTypes.BACKEND_NOT_FOUND,
'PREFERRED_BACKEND_UNAVAILABLE'
);
}
return preference;
}
/**
* Select backend by capability matching
* @param {import('../types/BackendTypes').ImplementRequest} request - Implementation request
* @returns {Promise<string>}
* @private
*/
async selectByCapability(request) {
const candidates = [];
for (const [name, backend] of this.backends) {
if (!(await backend.isAvailable())) continue;
const capabilities = backend.getCapabilities();
let score = 0;
// Score based on language support
if (request.context?.language) {
if (capabilities.supportsLanguages.includes(request.context.language)) {
score += 10;
} else if (capabilities.supportsLanguages.includes('all')) {
score += 5;
}
}
// Score based on requested features
if (request.options?.generateTests && capabilities.supportsTestGeneration) {
score += 5;
}
if (request.options?.streaming && capabilities.supportsStreaming) {
score += 3;
}
// Prefer backends with higher concurrent session limits
score += Math.min(capabilities.maxConcurrentSessions, 5);
candidates.push({ name, score });
}
if (candidates.length === 0) {
throw new BackendError(
'No capable backends found',
ErrorTypes.BACKEND_NOT_FOUND,
'NO_CAPABLE_BACKENDS'
);
}
// Sort by score and return highest
candidates.sort((a, b) => b.score - a.score);
return candidates[0].name;
}
/**
* Execute implementation request
* @param {import('../types/BackendTypes').ImplementRequest} request - Implementation request
* @returns {Promise<import('../types/BackendTypes').ImplementResult>}
*/
async executeImplementation(request) {
// Ensure manager is initialized
await this.initialize();
// Check concurrent session limit
if (this.activeSessionCount >= this.config.maxConcurrentSessions) {
throw new BackendError(
'Maximum concurrent sessions reached',
ErrorTypes.QUOTA_EXCEEDED,
'MAX_SESSIONS_REACHED',
{ limit: this.config.maxConcurrentSessions, current: this.activeSessionCount }
);
}
// Select backend
console.error(`[BackendManager] Selecting backend for request ${request.sessionId}`);
const backendName = await this.selectBackend(request);
console.error(`[BackendManager] Selected backend: ${backendName}`);
const backend = this.backends.get(backendName);
if (!backend) {
throw new BackendError(
`Backend '${backendName}' not found`,
ErrorTypes.BACKEND_NOT_FOUND,
'BACKEND_NOT_FOUND'
);
}
// Track session
this.activeSessionCount++;
this.sessionBackendMap.set(request.sessionId, backendName);
try {
// Apply timeout if not specified
if (!request.options?.timeout) {
request.options = request.options || {};
request.options.timeout = this.config.timeout;
}
// Execute with retry logic
const result = await RetryHandler.withRetry(
() => backend.execute(request),
{
maxAttempts: this.config.retryAttempts + 1,
shouldRetry: (error) => {
// Don't retry user cancellations or validation errors
if (error instanceof BackendError) {
if (error.type === ErrorTypes.CANCELLATION ||
error.type === ErrorTypes.VALIDATION_ERROR) {
return false;
}
}
return ErrorHandler.isRetryable(error);
}
}
);
// Add backend information to result
result.backend = backendName;
return result;
} catch (error) {
// Try fallback backends if configured
if (this.config.fallbackBackends.length > 0) {
console.warn(`Backend '${backendName}' failed, trying fallbacks...`);
for (const fallbackName of this.config.fallbackBackends) {
if (fallbackName === backendName) continue;
const fallbackBackend = this.backends.get(fallbackName);
if (!fallbackBackend || !(await fallbackBackend.isAvailable())) {
continue;
}
try {
console.log(`Trying fallback backend: ${fallbackName}`);
this.sessionBackendMap.set(request.sessionId, fallbackName);
const result = await fallbackBackend.execute(request);
result.backend = fallbackName;
result.fallback = true;
return result;
} catch (fallbackError) {
console.warn(`Fallback backend '${fallbackName}' also failed:`, fallbackError.message);
}
}
}
// All backends failed
throw error;
} finally {
this.activeSessionCount--;
this.sessionBackendMap.delete(request.sessionId);
}
}
/**
* Cancel an implementation session
* @param {string} sessionId - Session ID
* @returns {Promise<void>}
*/
async cancelImplementation(sessionId) {
const backendName = this.sessionBackendMap.get(sessionId);
if (!backendName) {
throw new BackendError(
`Session '${sessionId}' not found`,
ErrorTypes.SESSION_NOT_FOUND,
'SESSION_NOT_FOUND'
);
}
const backend = this.backends.get(backendName);
if (backend) {
await backend.cancel(sessionId);
}
this.sessionBackendMap.delete(sessionId);
this.activeSessionCount = Math.max(0, this.activeSessionCount - 1);
}
/**
* Get session status
* @param {string} sessionId - Session ID
* @returns {Promise<import('../types/BackendTypes').BackendStatus>}
*/
async getSessionStatus(sessionId) {
const backendName = this.sessionBackendMap.get(sessionId);
if (!backendName) {
return {
status: 'unknown',
message: 'Session not found'
};
}
const backend = this.backends.get(backendName);
if (!backend) {
return {
status: 'error',
message: 'Backend not found'
};
}
const status = await backend.getStatus(sessionId);
status.backend = backendName;
return status;
}
/**
* Validate configuration
* @returns {Promise<import('../types/BackendTypes').ValidationResult>}
*/
async validateConfiguration() {
const errors = [];
const warnings = [];
// Check default backend exists
if (!this.backends.has(this.config.defaultBackend)) {
errors.push(`Default backend '${this.config.defaultBackend}' not registered`);
}
// Fallback backends removed - no longer checking them
// Check at least one backend is available
let hasAvailable = false;
for (const [name, backend] of this.backends) {
if (await backend.isAvailable()) {
hasAvailable = true;
break;
}
}
if (!hasAvailable) {
errors.push('No backends are available');
}
return {
valid: errors.length === 0,
errors,
warnings
};
}
/**
* Check health of all backends
* @param {string} [name] - Specific backend to check, or all if not specified
* @returns {Promise<Object>}
*/
async checkBackendHealth(name = null) {
const results = {};
const backendsToCheck = name
? [name]
: Array.from(this.backends.keys());
for (const backendName of backendsToCheck) {
const backend = this.backends.get(backendName);
if (!backend) continue;
try {
const available = await backend.isAvailable();
const info = backend.getInfo();
results[backendName] = {
status: available ? 'healthy' : 'unavailable',
available,
version: info.version,
capabilities: info.capabilities,
dependencies: info.dependencies
};
} catch (error) {
results[backendName] = {
status: 'error',
available: false,
error: error.message
};
}
}
return results;
}
/**
* Get recommended backend for a given context
* @param {Object} context - Request context
* @returns {string|null}
*/
getRecommendedBackend(context) {
// Simple recommendation logic
if (context.language) {
for (const [name, backend] of this.backends) {
const capabilities = backend.getCapabilities();
if (capabilities.supportsLanguages.includes(context.language)) {
return name;
}
}
}
return this.config.defaultBackend;
}
/**
* Check if a backend can handle a request
* @param {string} backendName - Backend name
* @param {import('../types/BackendTypes').ImplementRequest} request - Request to check
* @returns {boolean}
*/
canHandleRequest(backendName, request) {
const backend = this.backends.get(backendName);
if (!backend) return false;
const validation = backend.validateRequest(request);
return validation.valid;
}
/**
* Clean up all backends
* @returns {Promise<void>}
*/
async cleanup() {
const cleanupPromises = [];
for (const [name, backend] of this.backends) {
cleanupPromises.push(
backend.cleanup().catch(error => {
console.error(`Error cleaning up backend '${name}':`, error);
})
);
}
await Promise.all(cleanupPromises);
this.backends.clear();
this.sessionBackendMap.clear();
this.activeSessionCount = 0;
this.initialized = false;
}
}
export default BackendManager;