import Fastify from 'fastify'; import websocket from '@fastify/websocket'; import cors from '@fastify/cors'; import Redis from 'ioredis'; import { readFileSync } from 'fs'; import { load as loadYaml } from 'js-yaml'; import { UserService } from './db/user-service.js'; import { Authenticator } from './auth/authenticator.js'; import { createBetterAuth } from './auth/better-auth-config.js'; import { AuthService } from './auth/auth-service.js'; import { AuthRoutes } from './routes/auth-routes.js'; import { WebSocketHandler } from './channels/websocket-handler.js'; import { TelegramHandler } from './channels/telegram-handler.js'; import { KubernetesClient } from './k8s/client.js'; import { ContainerManager } from './k8s/container-manager.js'; import { ZMQRelayClient } from './clients/zmq-relay-client.js'; import { IcebergClient } from './clients/iceberg-client.js'; import { ConversationStore } from './harness/memory/conversation-store.js'; import { BlobStore } from './harness/memory/blob-store.js'; import { ConversationService } from './services/conversation-service.js'; import { AgentHarness, type HarnessSessionConfig } from './harness/agent-harness.js'; import { OHLCService } from './services/ohlc-service.js'; import { SymbolIndexService } from './services/symbol-index-service.js'; import { SymbolRoutes } from './routes/symbol-routes.js'; import { AdminRoutes } from './routes/admin-routes.js'; // Catch unhandled promise rejections for better debugging process.on('unhandledRejection', (reason: any, promise) => { console.error('=== UNHANDLED PROMISE REJECTION ==='); console.error('Reason:', reason); console.error('Message:', reason?.message); console.error('Stack:', reason?.stack); console.error('Cause:', reason?.cause); console.error('Promise:', promise); console.error('==================================='); process.exit(1); }); import { SessionRegistry, EventSubscriber, EventRouter, DeliveryService, } from './events/index.js'; import { QdrantClient } from './clients/qdrant-client.js'; import { EmbeddingService, RAGRetriever, DocumentLoader } from './harness/memory/index.js'; import { initializeToolRegistry } from './tools/tool-registry.js'; import { join } from 'path'; import { fileURLToPath } from 'url'; import { dirname } from 'path'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); // Load configuration from YAML files function loadConfig() { const configPath = process.env.CONFIG_PATH || '/config/config.yaml'; const secretsPath = process.env.SECRETS_PATH || '/config/secrets.yaml'; let configData: any = {}; let secretsData: any = {}; try { const configFile = readFileSync(configPath, 'utf8'); configData = loadYaml(configFile) || {}; console.log(`Loaded configuration from ${configPath}`); } catch (error: any) { console.warn(`Could not load config from ${configPath}: ${error.message}, using defaults`); } try { const secretsFile = readFileSync(secretsPath, 'utf8'); secretsData = loadYaml(secretsFile) || {}; console.log(`Loaded secrets from ${secretsPath}`); } catch (error: any) { console.warn(`Could not load secrets from ${secretsPath}: ${error.message}`); } return { port: configData.server?.port || parseInt(process.env.PORT || '3000'), host: configData.server?.host || process.env.HOST || '0.0.0.0', logLevel: configData.server?.log_level || process.env.LOG_LEVEL || 'info', corsOrigin: configData.server?.cors_origin || process.env.CORS_ORIGIN || '*', baseUrl: configData.server?.base_url || process.env.BASE_URL || 'http://localhost:3000', trustedOrigins: configData.server?.trusted_origins || [ process.env.BASE_URL || 'http://localhost:3000', 'http://localhost:5173', 'http://localhost:8080', ], databaseUrl: configData.database?.url || process.env.DATABASE_URL || 'postgresql://localhost/dexorder', // Authentication configuration authSecret: secretsData.auth?.secret || process.env.AUTH_SECRET || 'change-me-in-production', // LLM provider API keys and model configuration providerConfig: { deepinfraApiKey: secretsData.llm_providers?.deepinfra_api_key || process.env.DEEPINFRA_API_KEY, defaultModel: { provider: configData.defaults?.model_provider || 'deepinfra', model: configData.defaults?.model || 'zai-org/GLM-5', }, licenseModels: { free: { default: configData.license_models?.free?.default || 'zai-org/GLM-5', cost_optimized: configData.license_models?.free?.cost_optimized || 'zai-org/GLM-5', complex: configData.license_models?.free?.complex || 'zai-org/GLM-5', allowed_models: configData.license_models?.free?.allowed_models || ['zai-org/GLM-5'], }, pro: { default: configData.license_models?.pro?.default || 'zai-org/GLM-5', cost_optimized: configData.license_models?.pro?.cost_optimized || 'zai-org/GLM-5', complex: configData.license_models?.pro?.complex || 'zai-org/GLM-5', blocked_models: configData.license_models?.pro?.blocked_models || ['Qwen/Qwen3-235B-A22B-Instruct-2507'], }, enterprise: { default: configData.license_models?.enterprise?.default || 'zai-org/GLM-5', cost_optimized: configData.license_models?.enterprise?.cost_optimized || 'zai-org/GLM-5', complex: configData.license_models?.enterprise?.complex || 'Qwen/Qwen3-235B-A22B-Instruct-2507', }, }, }, telegramBotToken: secretsData.telegram?.bot_token || process.env.TELEGRAM_BOT_TOKEN || '', // Email service configuration emailServiceKey: secretsData.email?.service_key || process.env.EMAIL_SERVICE_KEY, emailFromAddress: configData.email?.from_address || process.env.EMAIL_FROM_ADDRESS || 'noreply@dexorder.com', // Push notification service configuration pushServiceKey: secretsData.push?.service_key || process.env.PUSH_SERVICE_KEY, // Event router configuration eventRouterBind: configData.events?.router_bind || process.env.EVENT_ROUTER_BIND || 'tcp://*:5571', // Redis configuration (for harness memory layer) redisUrl: configData.redis?.url || process.env.REDIS_URL || 'redis://localhost:6379', // Conversation history limit: number of prior turns loaded as LLM context and flushed to Iceberg conversationHistoryLimit: configData.agent?.conversation_history_limit || parseInt(process.env.CONVERSATION_HISTORY_LIMIT || '20'), // Qdrant configuration (for RAG) qdrant: { url: configData.qdrant?.url || process.env.QDRANT_URL || 'http://localhost:6333', apiKey: secretsData.qdrant?.api_key || process.env.QDRANT_API_KEY, collectionName: configData.qdrant?.collection || process.env.QDRANT_COLLECTION || 'gateway_memory', }, // Iceberg configuration (for durable storage) iceberg: { catalogUri: configData.iceberg?.catalog_uri || process.env.ICEBERG_CATALOG_URI || 'http://iceberg-catalog:8181', namespace: configData.iceberg?.namespace || process.env.ICEBERG_NAMESPACE || 'gateway', ohlcCatalogUri: configData.iceberg?.ohlc_catalog_uri || process.env.ICEBERG_OHLC_CATALOG_URI, ohlcNamespace: configData.iceberg?.ohlc_namespace || process.env.ICEBERG_OHLC_NAMESPACE || 'trading', s3Endpoint: configData.iceberg?.s3_endpoint || process.env.S3_ENDPOINT, s3AccessKey: secretsData.iceberg?.s3_access_key || process.env.S3_ACCESS_KEY, s3SecretKey: secretsData.iceberg?.s3_secret_key || process.env.S3_SECRET_KEY, conversationsBucket: configData.iceberg?.conversations_bucket || process.env.CONVERSATIONS_S3_BUCKET, }, // Relay configuration (for historical data) relay: { requestEndpoint: configData.relay?.request_endpoint || process.env.RELAY_REQUEST_ENDPOINT || 'tcp://relay:5559', notificationEndpoint: configData.relay?.notification_endpoint || process.env.RELAY_NOTIFICATION_ENDPOINT || 'tcp://relay:5558', }, // Embedding configuration (for RAG) embedding: { provider: (configData.embedding?.provider || process.env.EMBEDDING_PROVIDER || 'ollama') as 'ollama' | 'openai' | 'anthropic' | 'local' | 'voyage' | 'cohere' | 'none', model: configData.embedding?.model || process.env.EMBEDDING_MODEL, apiKey: secretsData.embedding?.api_key || process.env.EMBEDDING_API_KEY || secretsData.llm_providers?.openai_api_key || process.env.OPENAI_API_KEY, ollamaUrl: configData.embedding?.ollama_url || process.env.OLLAMA_URL || 'http://localhost:11434', }, // Kubernetes configuration kubernetes: { namespace: configData.kubernetes?.namespace || process.env.KUBERNETES_NAMESPACE || 'sandbox', serviceNamespace: configData.kubernetes?.service_namespace || process.env.KUBERNETES_SERVICE_NAMESPACE || 'default', inCluster: configData.kubernetes?.in_cluster ?? (process.env.KUBERNETES_IN_CLUSTER === 'true'), context: configData.kubernetes?.context || process.env.KUBERNETES_CONTEXT, sandboxImage: configData.kubernetes?.sandbox_image || process.env.SANDBOX_IMAGE || 'ghcr.io/dexorder/sandbox:latest', sidecarImage: configData.kubernetes?.sidecar_image || process.env.SIDECAR_IMAGE || 'ghcr.io/dexorder/lifecycle-sidecar:latest', storageClass: configData.kubernetes?.storage_class || process.env.SANDBOX_STORAGE_CLASS || '', imagePullPolicy: configData.kubernetes?.image_pull_policy || process.env.IMAGE_PULL_POLICY || 'Always', }, // Search API keys tavilyApiKey: secretsData.search?.tavily_api_key || process.env.TAVILY_API_KEY, }; } const config = loadConfig(); const app = Fastify({ logger: { level: config.logLevel, transport: { target: 'pino-pretty', options: { colorize: true, translateTime: 'HH:MM:ss Z', ignore: 'pid,hostname', }, }, }, }); // Validate LLM provider is configured if (!config.providerConfig.deepinfraApiKey) { app.log.error('DEEPINFRA_API_KEY is required'); process.exit(1); } // Register plugins await app.register(cors, { origin: config.corsOrigin, }); await app.register(websocket, { options: { maxPayload: 1024 * 1024, // 1MB }, }); // Initialize services const userService = new UserService(config.databaseUrl); // Initialize Better Auth let betterAuth; try { app.log.info({ databaseUrl: config.databaseUrl.replace(/:[^:@]+@/, ':***@') }, 'Initializing Better Auth'); betterAuth = await createBetterAuth({ databaseUrl: config.databaseUrl, secret: config.authSecret, baseUrl: config.baseUrl, trustedOrigins: config.trustedOrigins, logger: app.log, }); app.log.info('Better Auth initialized successfully'); } catch (error: any) { app.log.error({ error, message: error.message, stack: error.stack }, 'Failed to initialize Better Auth'); throw error; } // Initialize Auth Service const authService = new AuthService({ auth: betterAuth, pool: userService.getPool(), logger: app.log, }); // Connect UserService with AuthService for JWT verification userService.setAuthService(authService); // Initialize Redis client (for harness memory layer) const redis = new Redis(config.redisUrl, { maxRetriesPerRequest: 3, connectTimeout: 10000, // 10 seconds retryStrategy: (times) => { if (times > 5) { app.log.error('Redis connection failed after 5 retries'); return null; // Stop retrying } const delay = Math.min(times * 50, 2000); return delay; }, lazyConnect: true, }); // Initialize Qdrant client (for RAG) const qdrantClient = new QdrantClient(config.qdrant, app.log); // Initialize Iceberg client (for durable storage) // const icebergClient = new IcebergClient(config.iceberg, app.log); // Create metadata update callback that will be wired up when SymbolIndexService initializes // This ensures we don't miss notifications sent before the service is ready let symbolIndexService: SymbolIndexService | undefined; const onMetadataUpdate = async () => { if (symbolIndexService) { app.log.info('Reloading symbol metadata from Iceberg'); await symbolIndexService.initialize(); app.log.info({ stats: symbolIndexService.getStats() }, 'Symbol metadata reloaded'); } else { app.log.warn('Received METADATA_UPDATE before SymbolIndexService initialized, ignoring'); } }; // Initialize ZMQ Relay client (for historical data) // Pass onMetadataUpdate callback so it's registered before connection const zmqRelayClient = new ZMQRelayClient({ relayRequestEndpoint: config.relay.requestEndpoint, relayNotificationEndpoint: config.relay.notificationEndpoint, onMetadataUpdate, }, app.log); app.log.info({ redis: config.redisUrl, qdrant: config.qdrant.url, iceberg: config.iceberg.catalogUri, relay: config.relay.requestEndpoint, embeddingProvider: config.embedding.provider, }, 'Harness storage clients configured'); // Initialize Kubernetes client and container manager const k8sClient = new KubernetesClient({ namespace: config.kubernetes.namespace, inCluster: config.kubernetes.inCluster, context: config.kubernetes.context, logger: app.log, }); const containerManager = new ContainerManager({ k8sClient, userService, sandboxImage: config.kubernetes.sandboxImage, sidecarImage: config.kubernetes.sidecarImage, storageClass: config.kubernetes.storageClass, imagePullPolicy: config.kubernetes.imagePullPolicy, namespace: config.kubernetes.namespace, serviceNamespace: config.kubernetes.serviceNamespace, logger: app.log, }); app.log.debug('Container manager initialized'); const authenticator = new Authenticator({ userService, containerManager, logger: app.log, }); app.log.debug('Authenticator initialized'); // Initialize event system const sessionRegistry = new SessionRegistry(); app.log.debug('Session registry initialized'); const deliveryService = new DeliveryService({ telegramBotToken: config.telegramBotToken, emailServiceKey: config.emailServiceKey, emailFromAddress: config.emailFromAddress, pushServiceKey: config.pushServiceKey, logger: app.log, }); app.log.debug('Delivery service initialized'); const eventSubscriber = new EventSubscriber(sessionRegistry, app.log); app.log.debug('Event subscriber initialized'); const eventRouter = new EventRouter({ sessions: sessionRegistry, delivery: deliveryService, logger: app.log, bindEndpoint: config.eventRouterBind, }); app.log.debug('Event router initialized'); // Initialize shared Iceberg client (used by both OHLC service and conversation store) const icebergClient = new IcebergClient(config.iceberg, app.log); app.log.debug('Iceberg client initialized'); // Initialize OHLC service (optional - only if relay is available) let ohlcService: OHLCService | undefined; try { ohlcService = new OHLCService({ icebergClient, relayClient: zmqRelayClient, logger: app.log, }); app.log.info('OHLC service initialized'); } catch (error) { app.log.warn({ error }, 'Failed to initialize OHLC service - historical data will not be available'); } // Initialize conversation store (Redis hot path + Iceberg cold path) const conversationStore = new ConversationStore(redis, app.log, icebergClient); app.log.debug('Conversation store initialized'); const blobStore = new BlobStore(icebergClient, app.log); const conversationService = new ConversationService(conversationStore, blobStore, app.log); app.log.debug('Blob store and conversation service initialized'); // Harness factory: captures infrastructure deps; channel handlers stay infrastructure-free function createHarness(sessionConfig: HarnessSessionConfig): AgentHarness { return new AgentHarness({ ...sessionConfig, providerConfig: config.providerConfig, conversationStore, blobStore, historyLimit: config.conversationHistoryLimit, }); } // Symbol Index Service will be initialized after server starts // (declared above near ZMQ client initialization) // Initialize channel handlers const websocketHandler = new WebSocketHandler({ authenticator, containerManager, sessionRegistry, eventSubscriber, createHarness, ohlcService, // Optional symbolIndexService, // Optional conversationService, // Optional - for history replay on reconnect }); app.log.debug('WebSocket handler initialized'); const telegramHandler = new TelegramHandler({ authenticator, telegramBotToken: config.telegramBotToken, createHarness, }); app.log.debug('Telegram handler initialized'); // Initialize auth routes app.log.debug('Initializing auth routes...'); const authRoutes = new AuthRoutes({ authService, betterAuth, containerManager, userService, }); // Register routes app.log.debug('Registering auth routes...'); try { authRoutes.register(app); app.log.debug('Auth routes registered successfully'); } catch (error: any) { app.log.error({ error, message: error.message, stack: error.stack }, 'Failed to register auth routes'); throw error; } app.log.debug('Registering websocket handler...'); websocketHandler.register(app); app.log.debug('Registering telegram handler...'); telegramHandler.register(app); // Register symbol routes (service may not be ready yet, but routes will handle this) app.log.debug('Registering symbol routes...'); const getSymbolService = () => symbolIndexService; const symbolRoutes = new SymbolRoutes({ getSymbolIndexService: getSymbolService }); symbolRoutes.register(app); // Register admin routes new AdminRoutes(containerManager, userService).register(app); app.log.debug('All routes registered'); // Health check app.get('/health', async () => { const health: any = { status: 'ok', timestamp: new Date().toISOString(), activeSessions: sessionRegistry.size(), eventSubscriptions: eventSubscriber.getSubscriptionCount(), processedEvents: eventRouter.getProcessedEventCount(), }; // Add RAG stats if available if (app.hasDecorator('ragRetriever')) { try { const ragStats = await (app as any).ragRetriever.getStats(); health.rag = { vectorCount: ragStats.vectorCount, indexedCount: ragStats.indexedCount, }; } catch (error) { // Ignore errors in health check } } return health; }); // Admin endpoints app.post('/admin/reload-knowledge', async (_request, reply) => { if (!app.hasDecorator('documentLoader')) { return reply.code(503).send({ error: 'Document loader not initialized', }); } try { app.log.info('Manual knowledge reload requested'); const stats = await (app as any).documentLoader.loadAll(); return { success: true, stats, timestamp: new Date().toISOString(), }; } catch (error: any) { app.log.error({ error }, 'Failed to reload knowledge'); return reply.code(500).send({ error: 'Failed to reload knowledge', message: error.message, }); } }); app.get('/admin/knowledge-stats', async (_request, reply) => { if (!app.hasDecorator('documentLoader')) { return reply.code(503).send({ error: 'Document loader not initialized', }); } try { const loaderStats = (app as any).documentLoader.getStats(); const ragStats = await (app as any).ragRetriever.getStats(); return { loader: loaderStats, rag: { vectorCount: ragStats.vectorCount, indexedCount: ragStats.indexedCount, collectionSize: ragStats.collectionSize, }, timestamp: new Date().toISOString(), }; } catch (error: any) { app.log.error({ error }, 'Failed to get knowledge stats'); return reply.code(500).send({ error: 'Failed to get knowledge stats', message: error.message, }); } }); // Graceful shutdown const shutdown = async () => { app.log.info('Shutting down gracefully...'); try { // Flush all active sessions to Iceberg before shutdown await websocketHandler.endAllSessions(); await telegramHandler.endAllSessions(); // Stop event system first await eventSubscriber.stop(); await eventRouter.stop(); // Close ZMQ relay client if (zmqRelayClient.isConnected()) { await zmqRelayClient.close(); } // Disconnect Redis redis.disconnect(); await userService.close(); await app.close(); app.log.info('Shutdown complete'); process.exit(0); } catch (error) { app.log.error({ error }, 'Error during shutdown'); process.exit(1); } }; process.on('SIGTERM', shutdown); process.on('SIGINT', shutdown); // Start server try { app.log.debug('Starting server initialization...'); // Connect to Redis app.log.debug('Connecting to Redis...'); await redis.connect(); app.log.info('Redis connected'); // Connect to ZMQ Relay app.log.debug('Connecting to ZMQ Relay...'); try { await zmqRelayClient.connect(); app.log.info('ZMQ Relay connected'); } catch (error) { app.log.warn({ error }, 'ZMQ Relay connection failed - historical data will not be available'); } // Initialize Qdrant collection app.log.debug('Initializing Qdrant...'); try { await qdrantClient.initialize(); app.log.info('Qdrant collection initialized'); } catch (error) { app.log.warn({ error }, 'Qdrant initialization failed - RAG will not be available'); } // Initialize tool registry app.log.debug('Initializing tool registry...'); try { const toolRegistry = initializeToolRegistry(app.log, { // Use getter functions to support lazy initialization ohlcService: () => ohlcService, symbolIndexService: () => symbolIndexService, workspaceManager: undefined, // Will be set per-session tavilyApiKey: config.tavilyApiKey, }); // Register agent tool configurations // Main agent: platform tools + user's general MCP tools toolRegistry.registerAgentTools({ agentName: 'main', platformTools: ['symbol_lookup', 'get_chart_data'], mcpTools: ['python_list', 'backtest_strategy', 'list_active_strategies'], }); // Research subagent: only MCP tools for script creation/execution toolRegistry.registerAgentTools({ agentName: 'research', platformTools: [], // No platform tools (works at script level) mcpTools: ['python_*', 'execute_research'], }); // Indicator subagent: workspace patch + category tools + evaluate_indicator toolRegistry.registerAgentTools({ agentName: 'indicator', platformTools: [], mcpTools: ['workspace_read', 'workspace_patch', 'python_*', 'evaluate_indicator'], }); // Web explore subagent: platform search/fetch tools only (no MCP needed) toolRegistry.registerAgentTools({ agentName: 'web-explore', platformTools: ['web_search', 'fetch_page', 'arxiv_search'], mcpTools: [], }); // Strategy subagent: all strategy-related MCP tools toolRegistry.registerAgentTools({ agentName: 'strategy', platformTools: [], mcpTools: [ 'python_write', 'python_edit', 'python_read', 'python_list', 'python_log', 'python_revert', 'backtest_strategy', 'activate_strategy', 'deactivate_strategy', 'list_active_strategies', 'get_backtest_results', 'get_strategy_trades', 'get_strategy_events', ], }); app.log.info( { agents: toolRegistry.getRegisteredAgents(), configs: toolRegistry.getRegisteredAgents().map(name => ({ name, config: toolRegistry.getAgentToolConfig(name), })), }, 'Tool registry initialized' ); } catch (error) { app.log.error({ error }, 'Failed to initialize tool registry'); // Non-fatal - continue without tools } // Initialize RAG system and load global knowledge app.log.debug('Initializing RAG system...'); try { // Initialize embedding service const embeddingService = new EmbeddingService(config.embedding, app.log); const vectorDimension = embeddingService.getDimensions(); // Initialize RAG retriever const ragRetriever = new RAGRetriever(config.qdrant, app.log, vectorDimension); await ragRetriever.initialize(); // Initialize document loader const knowledgeDir = join(__dirname, '..', 'knowledge'); const documentLoader = new DocumentLoader( { knowledgeDir }, embeddingService, ragRetriever, app.log ); // Load all knowledge documents const loadStats = await documentLoader.loadAll(); app.log.info(loadStats, 'Global knowledge loaded into RAG'); // Store references for admin endpoints app.decorate('documentLoader', documentLoader); app.decorate('ragRetriever', ragRetriever); } catch (error) { app.log.warn({ error }, 'Failed to load global knowledge - RAG will use existing data'); } // Start event system app.log.debug('Starting event subscriber...'); await eventSubscriber.start(); app.log.debug('Starting event router...'); await eventRouter.start(); app.log.debug('Event system started'); app.log.debug('Starting Fastify server...'); await app.listen({ port: config.port, host: config.host, }); app.log.info( { port: config.port, host: config.host, eventRouterBind: config.eventRouterBind, redis: config.redisUrl, qdrant: config.qdrant.url, }, 'Gateway server started' ); // Initialize Symbol Index Service (after server is running) // This is done asynchronously to not block server startup // The onMetadataUpdate callback is already registered with zmqRelayClient (async () => { try { const icebergClient = new IcebergClient(config.iceberg, app.log); const indexService = new SymbolIndexService({ icebergClient, logger: app.log, }); // Assign to module-level variable so onMetadataUpdate callback can use it symbolIndexService = indexService; // Update websocket handler's config so it can use the service (websocketHandler as any).config.symbolIndexService = indexService; // Retry until we get at least some symbol metadata while (true) { await indexService.initialize(); const stats = indexService.getStats(); if (stats.symbolCount > 0) { app.log.info({ stats }, 'Symbol index service initialized'); break; } app.log.warn('Symbol index has no metadata yet, retrying in 5 seconds...'); await new Promise(resolve => setTimeout(resolve, 5000)); } } catch (error) { app.log.warn({ error }, 'Failed to initialize symbol index service - symbol search will not be available'); } })(); } catch (error) { app.log.error({ error }, 'Failed to start server'); process.exit(1); }