749 lines
26 KiB
TypeScript
749 lines
26 KiB
TypeScript
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);
|
|
}
|