import app from "./app"; import { logger } from "./lib/logger"; import { ensureLoaded } from "./lib/research-data"; import { startJobPoller } from "./lib/job-tracker"; import { startLatencyAlertMonitor } from "./llm/latency-alert-monitor"; import { loadPersistedLatencySamples, startLatencySamplePersistence, } from "./llm/sample-persistence"; import { config } from "./lib/config"; import { db } from "@workspace/db"; import { inviteCodes } from "@workspace/db"; import { sql } from "drizzle-orm"; import { seedToolGraphOnce } from "./lib/tool-graph-seed"; import { seedDrugClipOnce } from "./lib/drugclip/seed"; import { toolGraphEnabled, startDeprecationScheduler, } from "./lib/tool-graph"; import { startEvolutionScheduler } from "./lib/evolution/scheduler"; import { applyCapabilityMigrations } from "./lib/capability/migrations/apply.js"; import { loadRegisteredCapabilities } from "./lib/capability/loader.js"; // Production sanity check: if neither the env-derived invite list nor any // row in `invite_codes` is present, brand-new sign-ups will all 403. Log a // loud warning so operators notice before users do. We do not fail boot — // the seed step (`pnpm --filter @workspace/api-server seed:prod`) is the // supported recovery path and may be run after the server is up. if (process.env["NODE_ENV"] === "production" && config.inviteCodes.length === 0) { void (async () => { try { const rows = await db .select({ n: sql`count(*)::int` }) .from(inviteCodes); const count = rows[0]?.n ?? 0; if (count === 0) { logger.error( "AUTH BOOTSTRAP: no invite codes available (INVITE_CODES env is empty AND invite_codes table is empty). " + "All registrations will return 403. Run `pnpm --filter @workspace/api-server seed:prod` " + "with INITIAL_INVITE_CODES set, or populate the INVITE_CODES env var, then redeploy.", ); } } catch (err) { logger.warn( { err }, "AUTH BOOTSTRAP: could not query invite_codes table to verify auth bootstrap state.", ); } })(); } // Warm research data cache in background after server starts. void ensureLoaded().catch((err) => logger.error({ err }, "research data warm-load failed"), ); // Idempotently seed the cross-process tool capability graph (Task #147). // Safe to call on every boot — the seed importer no-ops if the curated // nodes are already present and verified. if (toolGraphEnabled() && process.env["NODE_ENV"] !== "test") { void seedToolGraphOnce().catch((err) => logger.warn({ err }, "tool-graph seed failed (continuing without graph)"), ); } // Seed first-class tool networks (Task #176, Wave A). DrugCLIP is the // inaugural network; future networks register here too. Idempotent. if (process.env["NODE_ENV"] !== "test") { void seedDrugClipOnce().catch((err) => logger.warn({ err }, "drugclip seed failed (continuing without network)"), ); } // Begin polling the research engine for in-flight async jobs so we can // broadcast completion events and post the proactive follow-up message. startJobPoller(); // Watch provider-gateway latency and broadcast an announcement when a // provider's p95 has been near its timeout ceiling for several minutes. // Skip during tests so suites don't have to teardown a global timer. if (process.env["NODE_ENV"] !== "test") { startLatencyAlertMonitor(); // Restore the persisted recent-samples window so the admin sparkline // (Task #127) is populated immediately after a restart instead of // waiting on fresh traffic, then start the debounced flush loop that // mirrors new in-memory samples back to Postgres. void loadPersistedLatencySamples() .catch((err) => logger.warn({ err }, "loadPersistedLatencySamples failed"), ) .finally(() => { startLatencySamplePersistence(); }); } const rawPort = process.env["PORT"]; if (!rawPort) { throw new Error( "PORT environment variable is required but was not provided.", ); } const port = Number(rawPort); if (Number.isNaN(port) || port <= 0) { throw new Error(`Invalid PORT value: "${rawPort}"`); } const server = app.listen(port, () => { logger.info({ port }, "Server listening"); // Task #255 / B2 — 启动时打印 7 类外部知识适配器状态(real / interface_only)。 void import("./lib/capability/external-knowledge/index.ts").then((m) => m.logAdapterStatusBanner({ info: (msg) => logger.info(msg) }), ); // Task #242 / B1 — 把 capability schema 拉到当前版本(idempotent), // 然后扫 _registered/*.manifest.{ts,js,mjs} 自动注册 capability。 // 顺序很重要:loader 里的 manifest 可能直接 insert capability 行, // 必须先确保 capabilities / capability_lifecycle_events 表存在。 void applyCapabilityMigrations() .then(({ applied, skipped }) => logger.info( { applied, skipped }, "capability schema migrations applied", ), ) .then(() => loadRegisteredCapabilities()) .then(({ loaded, errors }) => { if (loaded.length > 0 || errors.length > 0) { logger.info( { loaded, errors }, "capability registry boot scan complete", ); } else { logger.info( "capability registry boot scan complete (0 manifests in _registered/)", ); } }) .catch((err) => logger.error( { err }, "capability boot pipeline failed (server still up; capability layer degraded)", ), ); }); // Tool-graph deprecation scheduler (#159). No-op unless // DEPRECATION_DETECTOR_ENABLED=1; safe to leave enabled in production. if (toolGraphEnabled()) { startDeprecationScheduler(); } // Online-evolution flywheel cadence worker (Task #181). Calls // evaluateTriggers + attemptAutoPromote + tickRollbackWatch on a fixed // schedule so the loop runs without an operator hitting the admin // page. Honours EVOLUTION_SCHEDULER_DISABLED=1 and is inert in tests. if (process.env["NODE_ENV"] !== "test") { startEvolutionScheduler(); } server.on("error", (err: NodeJS.ErrnoException) => { if (err.code === "EADDRINUSE") { logger.fatal( { err, port }, `Port ${port} is already in use. Another process is bound to it; refusing to start.`, ); } else if (err.code === "EACCES") { logger.fatal( { err, port }, `Permission denied binding to port ${port}.`, ); } else { logger.fatal({ err, port }, "Fatal error while starting HTTP server"); } process.exit(1); });