import { resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { existsSync } from 'node:fs'; import Fastify from 'fastify'; import fastifyWebsocket from '@fastify/websocket'; import fastifyStatic from '@fastify/static'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); import { loadConfig } from './config.js'; import { getSql, applySchema, pingDb, closeDb } from './db.js'; import { startMcpServer } from './services/mcp-server.js'; // v2.0.0 Phase 2B: workspace dependency on @boocode/server — reuse the // inference loop, broker, and tool registry without duplication. import { createInferenceRunner } from '@boocode/server/inference'; import { createBroker } from '@boocode/server/broker'; import { appendMcpTools, ALL_TOOLS } from '@boocode/server/tools'; import type { Config as ServerConfig } from '@boocode/server/config'; import type { WsFrame } from '@boocode/server/ws-frames'; // v2.0.0 Phase 2C: write tools + adapter for BooChat ToolDef compatibility. import { WRITE_TOOLS } from './services/tools/index.js'; import { adaptWriteTool } from './services/tools/adapter.js'; import { setInferenceContext, clearInferenceContext } from './services/tools/inference_context.js'; // Routes import { registerMessageRoutes } from './routes/messages.js'; import { registerPendingRoutes } from './routes/pending.js'; import { registerTaskRoutes } from './routes/tasks.js'; import { registerInboxRoutes } from './routes/inbox.js'; import { registerStatsRoutes } from './routes/stats.js'; import { registerArenaRoutes } from './routes/arena.js'; import { registerProviderRoutes } from './routes/providers.js'; import { registerWebSocket } from './routes/ws.js'; // Phase 4: dispatcher + agent probe import { createDispatcher } from './services/dispatcher.js'; import { probeAgents } from './services/agent-probe.js'; async function main() { // MCP mode: stdio transport, no HTTP server if (process.argv.includes('--mcp')) { const config = loadConfig(); const sql = getSql(config); await applySchema(sql); await startMcpServer(sql); return; } const config = loadConfig(); const app = Fastify({ logger: { level: config.LOG_LEVEL }, }); // Allow empty JSON bodies (same pattern as apps/server). app.removeContentTypeParser(['application/json']); app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body, done) => { const str = (body as string) ?? ''; if (str.trim().length === 0) { done(null, {}); return; } try { done(null, JSON.parse(str)); } catch (err) { done(err as Error, undefined); } }); const sql = getSql(config); await applySchema(sql); app.log.info('database schema applied'); // Broker: in-memory pub/sub for session + user channel streaming. const broker = createBroker(app.log); // --- Tool registry extension --- // Append BooCoder write tools (adapted to BooChat's ToolDef interface) to // the shared ALL_TOOLS registry. appendMcpTools re-sorts and rebuilds // TOOLS_BY_NAME so tool-phase.ts dispatch sees the full set. const adaptedWriteTools = WRITE_TOOLS.map((t) => adaptWriteTool(t)); appendMcpTools(adaptedWriteTools); app.log.info(`tool registry: ${ALL_TOOLS.length} tools loaded (${WRITE_TOOLS.length} write tools)`); // Inference runner: same engine as BooChat, uses ALL_TOOLS (which includes // the appended write tools) for tool dispatch. const inference = createInferenceRunner( { sql, config: config as unknown as ServerConfig, log: app.log, publish: (sessionId, frame) => { broker.publishFrame(sessionId, frame as unknown as WsFrame); }, broker, }, (user, frame) => { broker.publishUserFrame(user, frame as unknown as WsFrame); } ); // Wrap the inference runner to set/clear the write-tool context around each run. // The inference runner calls enqueue() which fires asynchronously — we hook // into the enqueue to set context before the run starts. const inferenceApi = { enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => { // Set the inference context so write tools can access sql + sessionId. // The context persists for the duration of the inference run. Since // BooCoder is single-user and runs one inference at a time per session, // this module-level state is safe. setInferenceContext({ sql, sessionId, taskId: null }); inference.enqueue(sessionId, chatId, assistantId, user); }, cancel: async (sessionId: string, chatId: string) => { const result = await inference.cancel(sessionId, chatId); clearInferenceContext(); return result; }, hasActive: (chatId: string) => inference.hasActive(chatId), }; // Register WebSocket support await app.register(fastifyWebsocket); // Health endpoint app.get('/api/health', async (_req, reply) => { const dbOk = await pingDb(sql); const status = dbOk ? 200 : 503; return reply.status(status).send({ ok: dbOk, db: dbOk, tools: ALL_TOOLS.length, }); }); // Phase 4: probe available agents on startup await probeAgents(sql, app.log); // Phase 4: dispatcher — polls tasks table and runs inference const dispatcher = createDispatcher({ sql, inference: inferenceApi, broker, log: app.log, config }); dispatcher.start(); app.addHook('onClose', () => dispatcher.stop()); // Register routes registerMessageRoutes(app, sql, broker, inferenceApi); registerPendingRoutes(app, sql); registerTaskRoutes(app, sql, inferenceApi); registerInboxRoutes(app, sql); registerStatsRoutes(app, sql); registerArenaRoutes(app, sql); registerProviderRoutes(app, sql, config); registerWebSocket(app, sql, broker); // Serve static frontend (built web app). In production, the dist/ is // copied to ../web relative to the dist/ directory at /app/web. In dev, // check adjacent to the source. const webRoot = resolve(__dirname, '../web'); if (existsSync(webRoot)) { await app.register(fastifyStatic, { root: webRoot, prefix: '/', // Don't intercept /api routes — static only serves files that exist. wildcard: false, }); // SPA fallback: serve index.html for non-API routes that don't match a file. app.setNotFoundHandler(async (req, reply) => { if (req.url.startsWith('/api')) { reply.code(404); return { error: 'not found' }; } return reply.sendFile('index.html'); }); app.log.info(`serving frontend from ${webRoot}`); } // Graceful shutdown const shutdown = async () => { app.log.info('shutting down'); await app.close(); await closeDb(); process.exit(0); }; process.on('SIGTERM', shutdown); process.on('SIGINT', shutdown); await app.listen({ port: config.PORT, host: config.HOST }); app.log.info(`BooCoder listening on ${config.HOST}:${config.PORT}`); } main().catch((err) => { console.error('fatal:', err); process.exit(1); });