claude-flow-tbowman01
Version:
Enterprise-grade AI agent orchestration with ruv-swarm integration (Alpha Release)
367 lines (301 loc) • 10.4 kB
text/typescript
/**
* Fallback Coordinator for MCP
* Manages graceful degradation to CLI when MCP connection fails
*/
import { EventEmitter } from 'node:events';
import type { ILogger } from '../../core/logger.js';
import type { MCPRequest } from '../../utils/types.js';
import { exec } from 'node:child_process';
import { promisify } from 'node:util';
const execAsync = promisify(exec);
export interface FallbackOperation {
id: string;
type: 'tool' | 'resource' | 'notification';
method: string;
params: unknown;
timestamp: Date;
priority: 'high' | 'medium' | 'low';
retryable: boolean;
}
export interface FallbackConfig {
enableFallback: boolean;
maxQueueSize: number;
queueTimeout: number;
cliPath: string;
fallbackNotificationInterval: number;
}
export interface FallbackState {
isFallbackActive: boolean;
queuedOperations: number;
failedOperations: number;
successfulOperations: number;
lastFallbackActivation?: Date;
}
export class FallbackCoordinator extends EventEmitter {
private operationQueue: FallbackOperation[] = [];
private state: FallbackState;
private notificationTimer?: NodeJS.Timeout;
private processingQueue = false;
private readonly defaultConfig: FallbackConfig = {
enableFallback: true,
maxQueueSize: 100,
queueTimeout: 300000, // 5 minutes
cliPath: 'npx ruv-swarm',
fallbackNotificationInterval: 30000, // 30 seconds
};
constructor(
private logger: ILogger,
config?: Partial<FallbackConfig>,
) {
super();
this.config = { ...this.defaultConfig, ...config };
this.state = {
isFallbackActive: false,
queuedOperations: 0,
failedOperations: 0,
successfulOperations: 0,
};
}
private config: FallbackConfig;
/**
* Check if MCP is available
*/
async isMCPAvailable(): Promise<boolean> {
try {
// Try to execute a simple MCP command
const { stdout } = await execAsync(`${this.config.cliPath} status --json`);
const status = JSON.parse(stdout);
return status.connected === true;
} catch (error) {
this.logger.debug('MCP availability check failed', error);
return false;
}
}
/**
* Enable CLI fallback mode
*/
enableCLIFallback(): void {
if (this.state.isFallbackActive) {
this.logger.debug('Fallback already active');
return;
}
this.logger.warn('Enabling CLI fallback mode');
this.state.isFallbackActive = true;
this.state.lastFallbackActivation = new Date();
// Start notification timer
this.startNotificationTimer();
this.emit('fallbackEnabled', this.state);
}
/**
* Disable CLI fallback mode
*/
disableCLIFallback(): void {
if (!this.state.isFallbackActive) {
return;
}
this.logger.info('Disabling CLI fallback mode');
this.state.isFallbackActive = false;
// Stop notification timer
this.stopNotificationTimer();
this.emit('fallbackDisabled', this.state);
// Process any queued operations
if (this.operationQueue.length > 0) {
this.processQueue().catch((error) => {
this.logger.error('Error processing queue after fallback disabled', error);
});
}
}
/**
* Queue an operation for later execution
*/
queueOperation(operation: Omit<FallbackOperation, 'id' | 'timestamp'>): void {
if (!this.config.enableFallback) {
this.logger.debug('Fallback disabled, operation not queued');
return;
}
if (this.operationQueue.length >= this.config.maxQueueSize) {
this.logger.warn('Operation queue full, removing oldest operation');
this.operationQueue.shift();
this.state.failedOperations++;
}
const queuedOp: FallbackOperation = {
...operation,
id: this.generateOperationId(),
timestamp: new Date(),
};
this.operationQueue.push(queuedOp);
this.state.queuedOperations = this.operationQueue.length;
this.logger.debug('Operation queued', {
id: queuedOp.id,
type: queuedOp.type,
method: queuedOp.method,
queueSize: this.operationQueue.length,
});
this.emit('operationQueued', queuedOp);
// If in fallback mode, try to execute via CLI
if (this.state.isFallbackActive && !this.processingQueue) {
this.executeViaCliFallback(queuedOp).catch((error) => {
this.logger.error('CLI fallback execution failed', { operation: queuedOp, error });
});
}
}
/**
* Process all queued operations
*/
async processQueue(): Promise<void> {
if (this.processingQueue || this.operationQueue.length === 0) {
return;
}
this.processingQueue = true;
this.logger.info('Processing operation queue', {
queueSize: this.operationQueue.length,
});
this.emit('queueProcessingStart', this.operationQueue.length);
const results = {
successful: 0,
failed: 0,
};
// Process operations in order
while (this.operationQueue.length > 0) {
const operation = this.operationQueue.shift()!;
// Check if operation has expired
if (this.isOperationExpired(operation)) {
this.logger.warn('Operation expired', { id: operation.id });
results.failed++;
continue;
}
try {
await this.replayOperation(operation);
results.successful++;
this.state.successfulOperations++;
} catch (error) {
this.logger.error('Failed to replay operation', {
operation,
error,
});
results.failed++;
this.state.failedOperations++;
// Re-queue if retryable
if (operation.retryable) {
this.operationQueue.push(operation);
}
}
}
this.state.queuedOperations = this.operationQueue.length;
this.processingQueue = false;
this.logger.info('Queue processing complete', results);
this.emit('queueProcessingComplete', results);
}
/**
* Get current fallback state
*/
getState(): FallbackState {
return { ...this.state };
}
/**
* Get queued operations
*/
getQueuedOperations(): FallbackOperation[] {
return [...this.operationQueue];
}
/**
* Clear operation queue
*/
clearQueue(): void {
const clearedCount = this.operationQueue.length;
this.operationQueue = [];
this.state.queuedOperations = 0;
this.logger.info('Operation queue cleared', { clearedCount });
this.emit('queueCleared', clearedCount);
}
private async executeViaCliFallback(operation: FallbackOperation): Promise<void> {
this.logger.debug('Executing operation via CLI fallback', {
id: operation.id,
method: operation.method,
});
try {
// Map MCP operations to CLI commands
const cliCommand = this.mapOperationToCli(operation);
if (!cliCommand) {
throw new Error(`No CLI mapping for operation: ${operation.method}`);
}
const { stdout, stderr } = await execAsync(cliCommand);
if (stderr) {
this.logger.warn('CLI command stderr', { stderr });
}
this.logger.debug('CLI fallback execution successful', {
id: operation.id,
stdout: stdout.substring(0, 200), // Log first 200 chars
});
this.state.successfulOperations++;
this.emit('fallbackExecutionSuccess', { operation, result: stdout });
} catch (error) {
this.logger.error('CLI fallback execution failed', {
operation,
error,
});
this.state.failedOperations++;
this.emit('fallbackExecutionFailed', { operation, error });
// Re-queue if retryable
if (operation.retryable) {
this.queueOperation(operation);
}
}
}
private async replayOperation(operation: FallbackOperation): Promise<void> {
// This would typically use the MCP client to replay the operation
// For now, we'll log it
this.logger.info('Replaying operation', {
id: operation.id,
method: operation.method,
});
// Emit event for handling by the MCP client
this.emit('replayOperation', operation);
}
private mapOperationToCli(operation: FallbackOperation): string | null {
// Map common MCP operations to CLI commands
const mappings: Record<string, (params: any) => string> = {
// Tool operations
'tools/list': () => `${this.config.cliPath} tools list`,
'tools/call': (params) =>
`${this.config.cliPath} tools call ${params.name} '${JSON.stringify(params.arguments)}'`,
// Resource operations
'resources/list': () => `${this.config.cliPath} resources list`,
'resources/read': (params) => `${this.config.cliPath} resources read ${params.uri}`,
// Session operations
initialize: () => `${this.config.cliPath} session init`,
shutdown: () => `${this.config.cliPath} session shutdown`,
// Custom operations
heartbeat: () => `${this.config.cliPath} health check`,
};
const mapper = mappings[operation.method];
return mapper ? mapper(operation.params) : null;
}
private isOperationExpired(operation: FallbackOperation): boolean {
const age = Date.now() - operation.timestamp.getTime();
return age > this.config.queueTimeout;
}
private generateOperationId(): string {
return `op-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
private startNotificationTimer(): void {
if (this.notificationTimer) {
return;
}
this.notificationTimer = setInterval(() => {
if (this.state.isFallbackActive && this.operationQueue.length > 0) {
this.logger.info('Fallback mode active', {
queuedOperations: this.operationQueue.length,
duration: Date.now() - (this.state.lastFallbackActivation?.getTime() || 0),
});
this.emit('fallbackStatus', this.state);
}
}, this.config.fallbackNotificationInterval);
}
private stopNotificationTimer(): void {
if (this.notificationTimer) {
clearInterval(this.notificationTimer);
this.notificationTimer = undefined;
}
}
}