godcrm/backend/server.js
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

1010 lines
48 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ⚠️ CRITICAL: Import config FIRST to load .env before other modules
import { config } from './config.js';
import path from 'node:path';
import http from 'node:http';
import { fileURLToPath } from 'node:url';
import express from 'express';
import { Server as SocketIOServer } from 'socket.io';
import cors from 'cors';
import helmet from 'helmet';
import cookieParser from 'cookie-parser';
// Security & Logging (ADR-015)
import { globalLimiter, authLimiter, codeExecutionLimiter } from './middleware/rateLimiter.js';
import { errorHandler, notFoundHandler } from './middleware/errorHandler.js';
import { createCorsOriginHandler } from './middleware/corsConfig.js'; // ADR-064: Strict CORS
import { logger, requestLogger, apiLogger } from './utils/logger.js';
// ADR-036: Swagger/OpenAPI Documentation
import swaggerJsdoc from 'swagger-jsdoc';
import swaggerUi from 'swagger-ui-express';
import { swaggerOptions } from './swagger.config.js';
// v0.003.001 - Clean v3 Architecture + Security Fixes (ADR-021)
// v2 legacy removed — all routes now in v3
import authRoutesV3 from './routes/v3/auth.js';
import spacesRoutesV3 from './routes/v3/spaces.js';
import projectsRoutesV3 from './routes/v3/projects.js';
import tablesRoutesV3 from './routes/v3/tables.js';
import columnsRoutesV3 from './routes/v3/columns.js'; // ADR-019: Separated columns
import rowsRoutesV3 from './routes/v3/rows.js'; // ADR-019: Separated rows (bulk operations)
import widgetRoutesV3 from './routes/v3/widgets.js';
import { mountModuleRoutes } from './modules/loader.js'; // MODULE-CONTRACT v1: autonomous-module route registry (replaces static per-module imports, e.g. ADR-165 SC-SIM)
import dataSourcesRoutesV3 from './routes/v3/data-sources.js';
import filesRoutesV3 from './routes/v3/files.js';
import webhooksRoutesV3 from './routes/v3/webhooks.js';
import apiKeysRoutesV3 from './routes/v3/api-keys.js';
import aiAgentsRoutesV3 from './routes/v3/ai-agents.js';
import foldersRoutesV3 from './routes/v3/folders.js';
import batchRoutesV3 from './routes/v3/batch.js';
import schemaRoutesV3 from './routes/v3/schema.js';
import userSettingsRoutesV3 from './routes/v3/user-settings.js';
import documentsRoutesV3 from './routes/v3/documents.js';
import systemRoutesV3 from './routes/v3/system.js';
import exportImportRoutesV3 from './routes/v3/export-import.js'; // ADR-020: Export/Import
import agentUsersRoutesV3 from './routes/v3/agent-users.js'; // ADR-023: Agent-as-User
import errorPagesRoutes from './routes/error-pages/index.js';
import testDoomRoutes from './routes/test-doom.js';
import chatRoutesV3 from './routes/v3/chat.js'; // ADR-024: Chat & Message Architecture
import fitnessRoutesV3 from './routes/v3/fitness.js'; // ADR-025: Fitness Module
import wellnessRoutesV3 from './routes/v3/wellness.js'; // ADR-027: Wellness Ecosystem
import codeRoutesV3 from './routes/v3/code.js'; // ADR-032: Code Execution Engine
import terminalRoutesV3 from './routes/v3/terminal.js'; // ADR-024: OpenCode Terminal Agent
import labsRoutesV3 from './routes/v3/labs.js'; // ADR-043: MindWorkflow Integration
import columnMappingRoutesV3 from './routes/v3/column-mapping.js'; // ADR-069: Column Mapping
import widgetLibraryRoutesV3 from './routes/v3/widget-library.js'; // ADR-073: Widget Picker System
import ticketsRoutesV3 from './routes/v3/tickets.js'; // ADR-098: Ticket Status API
import agentVersionsRoutesV3 from './routes/v3/agent-versions.js'; // ADR-0007-S: G1-data agent_versions write path
import telegramRoutesV3 from './routes/v3/telegram.js'; // ADR-098: Telegram Bot integration
import telegramNikitronRoutes from './routes/v3/telegramNikitron.js'; // NikitronBot: Telegram bot for Nikitron
import oauthRoutes from './routes/oauth/index.js'; // ADR-063: OIDC Provider for WorkAdventure
import waRoutesV3 from './routes/v3/wa/index.js'; // ADR-063: WorkAdventure Admin API
import calendarRoutesV3 from './routes/v3/calendar.js'; // Google Calendar Integration
import frameRoutesV3 from './routes/v3/frame.js'; // Brilliant Frame Smart Glasses Integration
import deviceLogsRoutesV3 from './routes/v3/device-logs.js'; // BLE Device Debug Logs
import { startCalendarSync } from './services/CalendarSyncScheduler.js'; // Google Calendar Scheduler
import scheduleTriggerService from './services/ScheduleTriggerService.js'; // Schedule-based automation triggers
import lifePipelineRoutesV3 from './routes/v3/life-pipeline.js'; // Life Pipeline: Morning Briefing, Evening Check-in
import contentPipelineRoutesV3 from './routes/v3/content-pipeline.js'; // Content Pipeline: AI/Tech News Aggregation
import publicRoutesV3 from './routes/v3/public.js'; // ADR-105: Public Space Access (no auth)
// NO-SIGNUP-MCP §B1: friendly-path alias for the per-space public MCP mount so
// the shipped `godcrm.ai/s/help/mcp` URL resolves at the app level (the
// canonical mount is `/api/v3/public/s/:slug/mcp` on publicRoutesV3).
import { publicAbuseGuard, publicRateLimit, publicSpaceAccess } from './middleware/publicAccess.js';
import { mcpPublicHandler } from './services/mcp/publicMcp.js';
import screenshotsRoutesV3 from './routes/v3/screenshots.js'; // Screenshot Service for Telegram posts
import pesRoutesV3 from './routes/v3/pes.js'; // PES ↔ CRM Bridge
import pesAppRoutes from './routes/v3/pes/appRoutes.js'; // PES Mini App (public)
import pluginRoutesV3 from './routes/v3/plugin.js'; // Plugin API (Photoshop, etc.)
import bddRoutesV3 from './routes/v3/bdd.js'; // ADR-156 Phase 5A: BDD test runs
import connectorsAuthedRouter, { connectorsCallbackRouter } from './routes/v3/connectors.js'; // ADR-0028 Phase 1: Space Connectors
import mailRoutesV3 from './routes/v3/mail.js'; // ADR-160: Mail module (IMAP/SMTP bridge)
import credentialVault from './services/connectors/CredentialVault.js'; // ADR-0028 Phase 1: vault lifecycle
import adminChatNotificationsRoutesV3 from './routes/v3/admin/chatNotifications.js'; // ADR-0064 WP-A: global default chat notification prefs
import { startInvalidationListener as startChatPrefsListener } from './services/notifications/resolveChatPrefs.js'; // ADR-0064 WP-A: cluster-wide cache eviction
import { startInflightBus } from './services/inflight/notifyBus.js'; // ADR-0057-A WP-B: chat_inflight LISTEN bus
import ownerRoutesV3 from './routes/v3/owner.js'; // ADR-0059 AMEND-3 §4.9: owner calls settings
import permissionResolver from './services/agent-permissions/resolver.js'; // ADR-0053 Phase C2: command-policy resolver
import agentPermissionsRoutesV3 from './routes/v3/agent-permissions.js'; // ADR-0053 Phase C2: PreToolUse hook endpoint
import secretsVault from './services/secrets/SecretsVault.js'; // ADR-0040 Phase 0: owner secrets vault
import secretsRoutesV3 from './routes/v3/secrets.js'; // ADR-0040 Phase 1: owner secrets CRUD + reveal
import auditRoutesV3 from './routes/v3/audit.js'; // ADR-0066-A §C: owner-only audit chain verify
import { getAdapter as getDbAdapter } from './database/connection.js'; // ADR-0040/ADR-0053: vault + resolver DB binding
import agentRunDispatcherAdminRouter from './routes/v3/agentRunDispatcher.js'; // ADR-0030 Phase 2: dispatcher admin API
import { authenticate } from './middleware/auth.js';
import { requestContext } from './middleware/requestContext.js'; // ADR-0066 P0: req.requestId/spaceId/actingAs
import { uploadsFileGuard, downloadsFileGuard } from './middleware/fileGuard.js'; // ADR-0016: Authenticated file delivery
import { dbGet } from './database/connection.js'; // ADR-064: Health check DB ping
// Monitoring service (self-hosted)
import { createMonitoringRouter } from './services/MonitoringService.js';
// Routes moved from v2 to v3 (still used on v3 endpoints)
import automationsRoutes from './routes/v3/automations.js';
import formConfigsRoutes from './routes/v3/form-configs.js';
import accessRoutes from './routes/v3/access.js';
const app = express();
// Trust first proxy (nginx) — required for express-rate-limit behind reverse proxy
app.set('trust proxy', 1);
const PORT = config.PORT;
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const clientDistPath = path.resolve(__dirname, '../dist');
// Middleware
// CSP must allow WebAssembly instantiation (pixi/transcoder WASM modules).
// helmet()'s default script-src is 'self' only, which throws
// "WebAssembly.instantiate(): ... 'unsafe-eval' is not an allowed source"
// in Safari/Chrome. 'wasm-unsafe-eval' enables WASM compile without the
// full 'unsafe-eval' relaxation. Directives mirror deploy/nginx/godcrm.conf.
app.use(
helmet({
contentSecurityPolicy: {
useDefaults: true,
directives: {
'default-src': ["'self'"],
'script-src': ["'self'", "'unsafe-inline'", "'unsafe-eval'", "'wasm-unsafe-eval'"],
// mirror deploy/nginx/godcrm.conf: marketing landing (godcrm.ai) pulls
// Google Fonts (fonts.googleapis.com CSS + fonts.gstatic.com files) and
// embeds YouTube. Without https: in style/font-src the stylesheet is
// refused and the landing falls back to system fonts.
'style-src': ["'self'", 'https:', "'unsafe-inline'"],
'img-src': ["'self'", 'data:', 'blob:'],
'font-src': ["'self'", 'https:', 'data:'],
'connect-src': ["'self'", 'wss:', 'https:'],
'frame-src': ['https://www.youtube-nocookie.com', 'https://www.youtube.com']
}
}
})
);
// ADR-176: the embed loader + chat iframe are deliberately cross-origin — they
// are dropped into third-party sites (e.g. the Penpot clone at design-test.godcrm.ai).
// helmet's default Cross-Origin-Resource-Policy: same-origin makes the browser
// block the cross-origin <script src=".../embed.js"> with ERR_BLOCKED_BY_RESPONSE
// .NotSameOrigin, so the loader never runs and the bubble never mounts. Relax CORP
// to cross-origin for the embed surface ONLY (framing is still gated by the CSP
// frame-ancestors allow-list on /embed/chat). Runs after helmet so it overrides.
app.use((req, res, next) => {
if (req.path === '/embed.js' || req.path === '/embed/chat') {
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
}
next();
});
// Allow CORS for AI agent worker endpoints (server-to-server, external workers)
app.use('/api/v3/ai/agents/jobs', cors());
// ADR-064: Strict CORS — production requires explicit CORS_ORIGINS
app.use(
cors({
credentials: true,
origin: createCorsOriginHandler()
})
);
app.use(express.json({ limit: '50mb' }));
app.use(express.urlencoded({ limit: '50mb', extended: true }));
app.use(cookieParser());
// Request logging middleware (Pino)
app.use(requestLogger);
// Global rate limiter for all API routes
app.use('/api', globalLimiter);
// ADR-036: Swagger UI and OpenAPI JSON endpoints
const swaggerSpec = swaggerJsdoc(swaggerOptions);
app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, {
customSiteTitle: 'GOD CRM API Documentation',
customCss: '.swagger-ui .topbar { display: none }',
swaggerOptions: {
persistAuthorization: true
}
}));
app.get('/api/openapi.json', (req, res) => {
res.setHeader('Content-Type', 'application/json');
res.json(swaggerSpec);
});
// PostgreSQL mode — Knex migrations handle schema
logger.info('🐘 PostgreSQL mode — using Knex migrations');
// ✅ OIDC Provider Routes (ADR-063: WorkAdventure Integration)
// Public OAuth endpoints - no authentication required
app.use('/oauth', oauthRoutes);
app.use('/.well-known', oauthRoutes); // For OIDC discovery
// Plugin login — no auth required, always returns 200 (UXP fetch throws on 4xx)
app.post('/api/v3/plugin/login', async (req, res) => {
try {
const { email, password } = req.body;
if (!email || !password) {
return res.json({ success: false, error: 'Email and password are required' });
}
const ipAddress = req.headers['x-forwarded-for']?.split(',')[0] || req.socket.remoteAddress;
const userAgent = req.headers['user-agent'];
const result = await (await import('./services/AuthService.js')).loginUser(email, password, ipAddress, userAgent);
if (!result.success) {
return res.json({ success: false, error: result.error || 'Invalid email or password' });
}
const { createAccessToken: makeToken } = await import('./routes/v3/auth/authShared.js');
const accessToken = makeToken(result.user);
return res.json({ success: true, accessToken, user: result.user });
} catch (error) {
return res.json({ success: false, error: error.message || 'Login failed' });
}
});
// Telegram Bot webhook (no auth — Telegram sends updates directly, security via chat_id check)
// Must be BEFORE authenticated routes to avoid /api/v3 catch-all with authenticate middleware
app.use('/api/v3/telegram', telegramRoutesV3);
app.use('/api/v3/telegram/nikitron', telegramNikitronRoutes); // NikitronBot
// Google Calendar OAuth callback (no auth — redirect from Google)
// Token exchange happens server-side using userId from state parameter
// No localStorage or CRM auth needed — fixes 401 errors after Google redirect
app.get('/auth/google/calendar/callback', async (req, res) => {
const { code, state } = req.query;
if (!code) {
return res.status(400).send('Missing authorization code');
}
// Extract userId from state parameter (format: "calendar:{userId}")
const decodedState = state ? decodeURIComponent(state) : '';
const userId = decodedState.startsWith('calendar:') ? parseInt(decodedState.split(':')[1], 10) : null;
if (!userId || isNaN(userId)) {
return res.status(400).send('Invalid state parameter — missing user ID');
}
// Import GoogleCalendarService functions
const { handleCallback: calHandleCallback, listCalendars: calListCalendars } = await import('./services/GoogleCalendarService.js');
let resultHtml;
try {
// Exchange code for tokens server-side (no client auth needed)
const redirectUri = `${req.protocol}://${req.get('host')}/auth/google/callback`;
await calHandleCallback(code, redirectUri, userId);
// Get calendar list for display
let calendars = [];
try {
calendars = await calListCalendars(userId);
} catch (e) {
apiLogger.warn({ err: e }, 'Could not list calendars after connect');
}
resultHtml = `
<div class="success" style="display:block">✅</div>
<h2>Аккаунт подключён!</h2>
<p>Календарей: ${calendars.length}<br><br>Можно закрыть эту вкладку</p>`;
} catch (err) {
apiLogger.error({ err }, 'Calendar callback failed');
resultHtml = `
<h2 style="color:#ea4335">Ошибка подключения</h2>
<p>${err.message || 'Unknown error'}</p>
<p><a href="/">Вернуться</a></p>`;
}
res.send(`<!DOCTYPE html>
<html><head><title>Google Calendar</title>
<style>body{font-family:system-ui;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#f8f9fa}
.card{background:white;padding:40px;border-radius:12px;box-shadow:0 2px 10px rgba(0,0,0,0.1);text-align:center;max-width:400px}
.success{color:#34a853;font-size:48px}
</style></head><body>
<div class="card">${resultHtml}</div>
</body></html>`);
});
// ✅ API Routes v3 (Clean Architecture)
// ADR-0066 P0: request-context middleware. Mounted on /api/v3 BEFORE all
// route handlers — including public routes and `authenticate` — so the
// requestId is available on every audit_log row (including auth failures).
app.use('/api/v3', requestContext);
app.use('/api/v3/auth', authLimiter, authRoutesV3);
// v2/auth alias removed — frontend uses v3
// Device logs: POST is public (no auth), GET/DELETE authenticated inside router
// Must be before '/api/v3' catch-all routes to avoid authenticate middleware interception
app.use('/api/v3/device-logs', deviceLogsRoutesV3);
// Life Pipeline (no auth for cron/webhook triggers — must be before catch-all /api/v3 routes)
app.use('/api/v3/integrations', lifePipelineRoutesV3);
// Content Pipeline (no auth for cron/webhook triggers — AI/tech news aggregation)
app.use('/api/v3/content-pipeline', contentPipelineRoutesV3);
// ADR-105: Public space access routes (no auth — external visibility via slug)
// Must be before authenticated /api/v3 routes to avoid authenticate middleware interception
app.use('/api/v3/public', publicRoutesV3);
// PES Mini App (public, no auth — must be before catch-all /api/v3 authenticated routes)
app.use('/api/v3/pes', pesAppRoutes);
// ADR-156 iter-5 Task 4: /api/v3/echo — smoke-test endpoint for BDD curl runner.
// Public (no auth) by design; globalLimiter above rate-limits abuse (10k/15m in prod).
app.get('/api/v3/echo', (req, res) => {
res.json({ ok: true, ts: Date.now(), method: 'GET' });
});
app.post('/api/v3/echo', (req, res) => {
res.json({ ok: true, ts: Date.now(), method: 'POST', body: req.body ?? null });
});
// ADR-0028 Phase 1: Space Connectors OAuth callback. MUST be mounted BEFORE
// any `app.use('/api/v3', authenticate, ...)` chain — state JWT carries identity,
// the upstream OAuth provider redirect arrives without our auth header.
app.use('/api/v3/connectors', connectorsCallbackRouter);
// ADR-0053 Phase C2: agent command-policy resolver. MUST be mounted BEFORE
// the global `authenticate` chain — POST /check is gated by an internal
// token (used by the PreToolUse hook subprocess, which has no JWT), while
// GET /audit and /health apply JWT inside the router.
app.use('/api/v3/agent-permissions', agentPermissionsRoutesV3);
app.use('/api/v3/spaces', authenticate, spacesRoutesV3);
app.use('/api/v3', authenticate, agentRunDispatcherAdminRouter); // ADR-0030 Phase 2 admin tick + health
app.use('/api/v3/projects', authenticate, projectsRoutesV3);
app.use('/api/v3/data-sources', authenticate, dataSourcesRoutesV3);
app.use('/api/v3/api-keys', authenticate, apiKeysRoutesV3);
app.use('/api/v3/ai', authenticate, aiAgentsRoutesV3);
app.use('/api/v3', authenticate, tablesRoutesV3);
app.use('/api/v3', authenticate, columnsRoutesV3); // ADR-019: Separated columns routes
app.use('/api/v3', authenticate, rowsRoutesV3); // ADR-019: Separated rows bulk operations
app.use('/api/v3', authenticate, widgetRoutesV3);
// MODULE-CONTRACT v1: mount present autonomous modules' backend routes by manifest
// (data, not static imports). Absent module → nothing mounted, core boots clean.
// Formerly the static SC-SIM mount; now sc-sim rides backend/modules/sc-sim/.
await mountModuleRoutes(app, authenticate, apiLogger);
app.use('/api/v3', authenticate, filesRoutesV3);
app.use('/api/v3', authenticate, webhooksRoutesV3);
app.use('/api/v3', authenticate, foldersRoutesV3);
app.use('/api/v3', authenticate, batchRoutesV3);
app.use('/api/v3', authenticate, schemaRoutesV3);
app.use('/api/v3/user-settings', authenticate, userSettingsRoutesV3);
app.use('/api/v3', authenticate, documentsRoutesV3);
app.use('/api/v3', authenticate, exportImportRoutesV3); // ADR-020: Export/Import
app.use('/api/v3/system', authenticate, systemRoutesV3);
app.use('/api/v3/users', authenticate, agentUsersRoutesV3); // ADR-023: Agent-as-User
app.use('/api/v3', authenticate, agentVersionsRoutesV3); // ADR-0007-S: G1-data agent_versions (mint/promote/retire)
app.use('/api/v3/chat', authenticate, chatRoutesV3); // ADR-024: Chat & Message Architecture
app.use('/api/v3/fitness', authenticate, fitnessRoutesV3); // ADR-025: Fitness Module
app.use('/api/v3/wellness', authenticate, wellnessRoutesV3); // ADR-027: Wellness Ecosystem
app.use('/api/v3/code', authenticate, codeExecutionLimiter, codeRoutesV3); // ADR-032: Code Execution Engine
app.use('/api/v3/terminal', authenticate, terminalRoutesV3); // ADR-024: OpenCode Terminal Agent
app.use('/api/v3/labs', authenticate, labsRoutesV3); // ADR-043: MindWorkflow Integration
app.use('/api/v3/column-mapping', authenticate, columnMappingRoutesV3); // ADR-069: Column Mapping
app.use('/api/v3', authenticate, widgetLibraryRoutesV3); // ADR-073: Widget Picker System
app.use('/api/v3', authenticate, ticketsRoutesV3); // ADR-098: Ticket Status API
app.use('/api/v3/wa', authenticate, waRoutesV3); // ADR-063: WorkAdventure Admin API
app.use('/api/v3/calendar', authenticate, calendarRoutesV3); // Google Calendar Integration
app.use('/api/v3/frame', authenticate, frameRoutesV3); // Brilliant Frame Smart Glasses
app.use('/api/v3/screenshots', authenticate, screenshotsRoutesV3); // Screenshot Service for Telegram posts
app.use('/api/v3/pes', authenticate, pesRoutesV3); // PES ↔ CRM Bridge
app.use('/api/v3/plugin', authenticate, pluginRoutesV3); // Plugin API (Photoshop, etc.)
app.use('/api/v3/bdd', authenticate, bddRoutesV3); // ADR-156 Phase 5A: BDD test runs
// ADR-0028 Phase 1: Space Connectors authed routes (callback mounted earlier, ~line 249).
app.use('/api/v3', authenticate, connectorsAuthedRouter);
app.use('/api/v3', authenticate, mailRoutesV3); // ADR-160: Mail module routes (/spaces/:id/mail/*)
// ADR-0064 WP-A: global-default chat notification prefs (app-owner gate
// enforced inside the router).
app.use('/api/v3/admin', authenticate, adminChatNotificationsRoutesV3);
// ADR-0064 WP-A: global-default chat notification prefs (app-owner gate
// enforced inside the router).
app.use('/api/v3/admin', authenticate, adminChatNotificationsRoutesV3);
// ADR-0059 AMEND-3 §4.9: owner-only calls-settings (read-only until D14).
app.use('/api/v3/owner', authenticate, ownerRoutesV3);
// ADR-0040 Phase 1: Owner Secrets Vault CRUD + reveal. Owner-only gate is
// enforced inside the router (compares req.user.id with space 11 owner_id).
app.use('/api/v3/secrets', authenticate, secretsRoutesV3);
// ADR-0066-A §C: audit hash-chain integrity check. Owner-only gate is
// enforced inside the router (same space-11 owner check as secrets).
app.use('/api/v3/audit', authenticate, auditRoutesV3);
// Life Pipeline route moved before catch-all /api/v3 routes (see line ~199)
// Monitoring API (Lunary-compatible, self-hosted)
// Public ingest endpoint for SDK compatibility
app.use('/api/v3/monitoring', createMonitoringRouter());
// Also mount at root /v1 for direct Lunary SDK compatibility
app.use('/v1', createMonitoringRouter());
// Public webhook endpoint (no auth)
app.use('/api/webhooks', webhooksRoutesV3);
// 🎮 DOOM 404 Page Routes
app.use('/error', errorPagesRoutes);
// 🧪 Test DOOM 404 Integration
app.use('/test', testDoomRoutes);
// ✅ v3 routes for previously v2-only endpoints
app.use('/api/v3/automations', authenticate, automationsRoutes);
app.use('/api/v3/form-configs', authenticate, formConfigsRoutes);
app.use('/api/v3/access', authenticate, accessRoutes);
// v2 aliases removed — all clients migrated to v3
// Health check — ADR-064 Task 12: Enhanced with DB status, version, uptime
app.get('/api/health', async (req, res) => {
const startMs = Date.now();
let dbStatus = 'unknown';
let dbLatencyMs = null;
try {
const dbStart = Date.now();
await dbGet('SELECT 1 AS ping');
dbLatencyMs = Date.now() - dbStart;
dbStatus = 'connected';
} catch {
dbStatus = 'disconnected';
}
const mem = process.memoryUsage();
res.json({
status: dbStatus === 'connected' ? 'ok' : 'degraded',
version: '0.003.001',
uptime_seconds: Math.floor(process.uptime()),
timestamp: new Date().toISOString(),
database: { status: dbStatus, latency_ms: dbLatencyMs },
memory: {
rss_mb: Math.round(mem.rss / 1024 / 1024),
heap_used_mb: Math.round(mem.heapUsed / 1024 / 1024)
}
});
});
// Deep health check — ADR-064 Task 12: Admin only, full system status
app.get('/api/health/deep', authenticate, async (req, res) => {
if (req.user.role !== 'admin' && req.user.role !== 'owner') {
return res.status(403).json({ success: false, error: { code: 'FORBIDDEN', message: 'Admin access required' } });
}
let dbStatus = 'unknown';
let dbLatencyMs = null;
try {
const dbStart = Date.now();
await dbGet('SELECT 1 AS ping');
dbLatencyMs = Date.now() - dbStart;
dbStatus = 'connected';
} catch {
dbStatus = 'disconnected';
}
const mem = process.memoryUsage();
res.json({
status: dbStatus === 'connected' ? 'ok' : 'degraded',
version: '0.003.001',
uptime_seconds: Math.floor(process.uptime()),
timestamp: new Date().toISOString(),
database: { status: dbStatus, latency_ms: dbLatencyMs },
memory: {
rss_mb: Math.round(mem.rss / 1024 / 1024),
heap_used_mb: Math.round(mem.heapUsed / 1024 / 1024),
heap_total_mb: Math.round(mem.heapTotal / 1024 / 1024),
external_mb: Math.round(mem.external / 1024 / 1024)
},
node_version: process.version,
env: process.env.NODE_ENV || 'development'
});
});
// Google OAuth callback (GET - receives redirect from Google)
// Handles both web and mobile flows:
// - Web: state is absent or doesn't start with "mobile:" → redirect to React frontend
// - Mobile: state starts with "mobile:" → forward to mobile-callback API handler
app.get('/auth/google/callback', (req, res) => {
const { code, error, state } = req.query;
if (error) {
return res.redirect(`/login?error=${encodeURIComponent(error)}`);
}
if (!code) {
return res.redirect('/login?error=no_code');
}
// Check if this is a mobile app request (state starts with "mobile:")
const decodedState = state ? decodeURIComponent(state) : '';
if (decodedState.startsWith('mobile:')) {
// Forward to mobile-callback handler which exchanges code and redirects via deep link
return res.redirect(`/api/v3/auth/google/mobile-callback?code=${encodeURIComponent(code)}&state=${encodeURIComponent(decodedState)}`);
}
// Check if this is a Google Calendar connection request (state starts with "calendar:")
if (decodedState.startsWith('calendar:')) {
// Forward to calendar callback handler which exchanges code for calendar tokens
return res.redirect(`/auth/google/calendar/callback?code=${encodeURIComponent(code)}&state=${encodeURIComponent(decodedState)}`);
}
// Web flow: redirect to frontend with code, which will exchange it via POST API
res.redirect(`/auth/google/complete?code=${encodeURIComponent(code)}`);
});
// ADR-0016 Phase 1: Authenticated file delivery — every byte stream
// previously served by open `express.static` mounts now goes through
// `fileGuard` first. The guard looks up the requested path against the
// `files` table, reads the owning column's `config.visibility`
// ('public' | 'internal' | 'private', default 'private'), and gates
// access accordingly. Orphan files (no DB row) -> 404.
//
// Static file serving for downloadable games/files
const DOWNLOADS_PATH = '/root/production/games';
app.use('/downloads', downloadsFileGuard, express.static(DOWNLOADS_PATH, {
setHeaders: (res, filePath) => {
const basename = path.basename(filePath);
res.setHeader('Content-Disposition', `attachment; filename="${basename}"`);
res.removeHeader('X-Frame-Options');
}
}));
// Static file serving for uploads (fix: file preview instead of download)
const UPLOAD_BASE_PATH = process.env.UPLOAD_PATH || '/var/lib/business-crm-data/uploads';
app.use('/uploads', uploadsFileGuard, express.static(UPLOAD_BASE_PATH, {
setHeaders: (res, filePath) => {
// Serve files inline (preview) instead of attachment (download)
const basename = path.basename(filePath);
res.setHeader('Content-Disposition', `inline; filename="${basename}"`);
// Allow embedding in iframes for preview modal
res.removeHeader('X-Frame-Options');
}
}));
// NO-SIGNUP-MCP §B1: friendly-path alias `/s/:slug/mcp` → public MCP handler.
// Registered BEFORE the static + SPA catch-all so POST/GET to the bare
// `godcrm.ai/s/help/mcp` reaches the MCP mount instead of falling to index.html.
// Inherits the same abuse-guard + rate-limit + space-pinning as the canonical
// `/api/v3/public/s/:slug/mcp` route.
app.all('/s/:slug/mcp', publicAbuseGuard, publicRateLimit, publicSpaceAccess, mcpPublicHandler);
// Static frontend (SPA)
app.use(express.static(clientDistPath, {
setHeaders: (res, filePath) => {
if (filePath.endsWith('.html')) {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
}
}
}));
app.get('*', (req, res) => {
// Don't serve SPA for API routes or monitoring routes
if (req.path.startsWith('/api') || req.path.startsWith('/v1')) {
return res.status(404).json({
success: false,
error: { code: 'ENDPOINT_NOT_FOUND', message: 'API endpoint not found' },
timestamp: new Date().toISOString()
});
}
// Disable caching for index.html to ensure latest version is always served
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
res.sendFile(path.join(clientDistPath, 'index.html'));
});
// Global error handler (must be last)
app.use(errorHandler);
// ── Socket.IO for 16Neo multiplayer ──────────────────
const httpServer = http.createServer(app);
const io = new SocketIOServer(httpServer, {
cors: { origin: '*', methods: ['GET', 'POST'] },
path: '/neo16-ws',
});
// Init Neo16 namespace
import('./routes/v3/neo16/socketHandler.js').then(({ initNeo16Socket }) => {
initNeo16Socket(io);
apiLogger.info('16Neo Socket.IO namespace /neo16 ready');
}).catch(err => {
apiLogger.error({ err }, 'Failed to init Neo16 Socket.IO');
});
// EADDRINUSE retry: wait for old process to release port on PM2 restart
let _listenRetries = 0;
const MAX_LISTEN_RETRIES = 10;
const LISTEN_RETRY_DELAY = 2000; // ms
httpServer.on('error', (err) => {
if (err.code === 'EADDRINUSE' && _listenRetries < MAX_LISTEN_RETRIES) {
_listenRetries++;
apiLogger.warn({ port: PORT, attempt: _listenRetries, max: MAX_LISTEN_RETRIES },
'Port in use, retrying...');
setTimeout(() => httpServer.listen(PORT), LISTEN_RETRY_DELAY);
} else {
apiLogger.error({ err }, 'Server error');
process.exit(1);
}
});
const server = httpServer.listen(PORT, () => {
logger.info({ port: PORT, version: '0.003.000' }, '🚀 GOD CRM Server started');
apiLogger.info('API Routes v3 ready');
// Startup recovery + agent worker initialization (sequential)
// Step 1: Recover stuck jobs (resets tickets to BACKLOG)
// Step 2: Start Agent Worker (polls BACKLOG tickets and re-dispatches)
// Must run in order: recovery first, then worker, so tickets are in correct state
if (process.env.NODE_ENV !== 'test') {
(async () => {
// Step 0: Kill any orphan Claude Code CLI and MCP processes from previous server instance
// On systemctl restart, child processes may survive if the old server didn't shut down cleanly
try {
const { killAllActiveProcesses, killOrphanMCPProcesses } = await import('./services/labs/ai-execution-service.js');
const killed = killAllActiveProcesses();
if (killed > 0) {
apiLogger.warn({ killed }, 'Startup: Killed orphan Claude Code CLI processes');
}
// Also kill any orphan MCP processes (mcp-searxng, google-drive-mcp, etc.)
const mcpKilled = await killOrphanMCPProcesses();
if (mcpKilled > 0) {
apiLogger.warn({ mcpKilled }, 'Startup: Killed orphan MCP processes');
}
} catch (err) {
apiLogger.debug({ err }, 'Startup: No orphan processes to clean (ok)');
}
// Step 1 & 2: Agent recovery + worker — only when agent worker is enabled
if (process.env.AGENT_WORKER_ENABLED !== 'false') {
// Step 1: Recover stuck agent jobs + reset bound tickets to BACKLOG
try {
const { recoverStuckJobs } = await import('./services/AgentJobService.js');
const result = await recoverStuckJobs();
apiLogger.info({ result }, 'Startup: Agent job recovery complete');
} catch (err) {
apiLogger.error({ err }, 'Startup: Failed to run agent job recovery');
}
// Step 2: Start Agent Worker (after recovery, so tickets are in correct state)
try {
const { AgentWorkerService } = await import('./services/AgentWorkerService.js');
await AgentWorkerService.start();
apiLogger.info('ADR-104: AgentWorkerService started — agents will auto-resume');
} catch (err) {
apiLogger.error({ err }, 'ADR-104: Failed to start AgentWorkerService');
}
// Step 3: Start Job Watchdog (monitors stalled jobs + orphaned conversations)
try {
const { startJobWatchdog } = await import('./services/AgentJobService.js');
startJobWatchdog();
apiLogger.info('AgentJobService: Job watchdog started');
} catch (err) {
apiLogger.error({ err }, 'AgentJobService: Failed to start job watchdog');
}
} else {
apiLogger.info('AgentWorkerService disabled via AGENT_WORKER_ENABLED=false — skipping recovery and worker');
}
})();
}
// Google Calendar: Start periodic sync scheduler
if (process.env.CALENDAR_SYNC_ENABLED !== 'false' && process.env.NODE_ENV !== 'test') {
try {
startCalendarSync();
apiLogger.info('Google Calendar sync scheduler started (every 5 min)');
} catch (err) {
apiLogger.error({ err }, 'Failed to start Calendar sync scheduler');
}
}
// ADR-0066-A §D: nightly audit-chain verify + owner head anchor.
// Self-gates on AUDIT_CHAIN_VERIFY_ENABLED (default OFF; stays dark on
// NL until Phase C). @sysadmin flips the env or swaps for a systemd
// timer hitting GET /api/v3/audit/verify.
if (process.env.NODE_ENV !== 'test') {
import('./services/audit/AuditChainScheduler.js')
.then(({ startAuditChainScheduler }) => startAuditChainScheduler())
.catch((err) =>
apiLogger.error({ err }, 'Failed to start audit chain scheduler')
);
}
// Schedule-based automation triggers: check cron expressions every minute
if (process.env.SCHEDULE_TRIGGERS_ENABLED !== 'false' && process.env.NODE_ENV !== 'test') {
scheduleTriggerService.init().then(() => {
apiLogger.info('Schedule trigger service started (every 60s)');
}).catch(err => {
apiLogger.error({ err }, 'Failed to start Schedule trigger service');
});
}
// ADR-0028 Phase 1: CredentialVault lifecycle (Module Lifecycle pattern, ADR-0025).
// Does NOT throw if CRM_CREDENTIAL_KEY is missing — logs warning, ops can fix later.
credentialVault.init().then((h) => {
apiLogger.info({ vault: h }, 'CredentialVault init complete');
}).catch(err => {
apiLogger.error({ err }, 'CredentialVault init failed');
});
// ADR-0064 WP-A: chat-prefs resolver invalidation listener. Best-effort —
// failure degrades eviction to local-only + 60s TTL (still correct, just
// slower to propagate across PM2 workers).
startChatPrefsListener().catch((err) => {
apiLogger.warn({ err }, 'chat_prefs invalidation listener failed to start — TTL-only eviction');
});
// ADR-0053 Phase C2: PermissionResolver lifecycle. Backs the PreToolUse hook
// (scripts/agent-permission-hook.js) — never fail-hard, hook fails-open if
// the resolver is unavailable. Listens on pg_notify('command_policies_changed')
// for cluster-wide cache eviction.
getDbAdapter()
.then((adapter) => permissionResolver.init({ adapter }))
.then((h) => {
apiLogger.info({ resolver: h }, 'PermissionResolver init complete');
})
.catch((err) => {
apiLogger.error({ err }, 'PermissionResolver init failed');
});
// ADR-0040 Phase 0: SecretsVault lifecycle. Fail-fast in production when
// SECRETS_MASTER_KEY is missing — init() exits the process (see AC4).
getDbAdapter()
.then((adapter) => secretsVault.init({ adapter }))
.then((h) => {
apiLogger.info({ vault: h }, 'SecretsVault init complete');
})
.catch((err) => {
apiLogger.error({ err }, 'SecretsVault init failed');
// In production, init() already scheduled process.exit(1). Re-raising
// here in non-prod paths preserves stack trace in logs but does not
// crash the dev server (vault stays disabled, getSecret returns null).
});
// ADR-0064 WP-A: chat-prefs resolver invalidation listener. Best-effort —
// failure degrades eviction to local-only + 60s TTL (still correct, just
// slower to propagate across PM2 workers).
startChatPrefsListener().catch((err) => {
apiLogger.warn({ err }, 'chat_prefs invalidation listener failed to start — TTL-only eviction');
});
// ADR-0057-A WP-B: chat_inflight LISTEN bus. Best-effort — failure leaves
// SSE clients with the per-poll `active_agents` snapshot but no live push.
startInflightBus().catch((err) => {
apiLogger.warn({ err }, 'chat_inflight bus failed to start — push-deltas disabled');
});
// ADR-0053 Phase C2: PermissionResolver lifecycle. Backs the PreToolUse hook
// (scripts/agent-permission-hook.js) — never fail-hard, hook fails-open if
// the resolver is unavailable. Listens on pg_notify('command_policies_changed')
// for cluster-wide cache eviction.
getDbAdapter()
.then((adapter) => permissionResolver.init({ adapter }))
.then((h) => {
apiLogger.info({ resolver: h }, 'PermissionResolver init complete');
})
.catch((err) => {
apiLogger.error({ err }, 'PermissionResolver init failed');
});
// ADR-0028 Phase 2: Connector token-refresh scheduler (5-min tick).
if (process.env.NODE_ENV !== 'test') {
import('./services/connectors/refreshScheduler.js').then(({ init: initRefresh }) => {
return initRefresh();
}).then((h) => {
apiLogger.info({ refresh: h }, 'Connector refresh scheduler init complete');
}).catch(err => {
apiLogger.error({ err }, 'Connector refresh scheduler init failed');
});
}
// ADR-0030 Phase 2: Agent run dispatcher (poll-driven, dry-run mode).
// Gated by AGENT_RUN_DISPATCHER_ENABLED — service no-ops if flag != 'true'.
if (process.env.NODE_ENV !== 'test') {
import('./services/agent-run-dispatcher/index.js').then(({ init: initDispatcher }) => {
return initDispatcher();
}).then((h) => {
apiLogger.info({ dispatcher: h }, 'Agent run dispatcher init complete');
}).catch(err => {
apiLogger.error({ err }, 'Agent run dispatcher init failed');
});
}
// WP-17: Scheduled message delivery worker (every 30s)
if (process.env.NODE_ENV !== 'test') {
import('./services/ScheduledMessageWorker.js').then(({ startScheduledMessageWorker }) => {
startScheduledMessageWorker();
}).catch(err => {
apiLogger.error({ err }, 'Failed to start ScheduledMessageWorker');
});
}
// ADR-0011 §Phase D: Verification TTL sweeper (hourly; flag-gated)
if (process.env.NODE_ENV !== 'test' && process.env.VERIFICATION_COLUMN_ENABLED === 'true') {
import('./services/verification/ttlSweeper.js').then(({ startVerificationTTLSweeper }) => {
startVerificationTTLSweeper();
}).catch(err => {
apiLogger.error({ err }, 'Failed to start VerificationTTLSweeper');
});
}
});
// BUG-504: Increase Node.js HTTP server timeouts to match nginx (1800s)
// Default headersTimeout is 60s, keepAliveTimeout is 5s — too short for long agent tasks
server.keepAliveTimeout = 1800 * 1000; // 30 min
server.headersTimeout = 1805 * 1000; // Must be > keepAliveTimeout
server.timeout = 0; // Disable per-request timeout (nginx handles it)
// ─── GRACEFUL SHUTDOWN ──────────────────────────────────────────
// On SIGTERM/SIGINT (systemctl restart, deploy, Ctrl+C):
// 1. Stop AgentWorkerService (clears polling, waits for active jobs)
// 2. Stop ScheduleTriggerService (clear cron intervals)
// 3. Stop CalendarSyncScheduler (clear sync interval)
// 4. Kill all active Claude Code CLI child processes
// 5. Clear stuck is_processing flags
// 6. Close HTTP server
// This ensures a clean restart without orphan processes or stuck state.
let _isShuttingDown = false;
async function gracefulShutdown(signal) {
if (_isShuttingDown) return; // prevent double-shutdown
_isShuttingDown = true;
apiLogger.info({ signal }, '⏹️ Graceful shutdown initiated');
// FIX-A: Signal AgentJobService that shutdown is in progress BEFORE killing
// child processes. This prevents processJobLocally()'s catch block from
// overwriting the recovery marker that we set in step 5 below.
try {
const { setShuttingDown } = await import('./services/AgentJobService.js');
setShuttingDown();
apiLogger.info('Shutdown: AgentJobService shutdown flag set');
} catch (err) {
apiLogger.error({ err }, 'Shutdown: Failed to set AgentJobService shutdown flag');
}
// 1. Stop AgentWorkerService (stop polling + wait for active jobs)
try {
const { AgentWorkerService } = await import('./services/AgentWorkerService.js');
await AgentWorkerService.stop();
apiLogger.info('Shutdown: AgentWorkerService stopped');
} catch (err) {
apiLogger.error({ err }, 'Shutdown: Failed to stop AgentWorkerService');
}
// 2. Stop ScheduleTriggerService (clear cron intervals)
try {
const scheduleTriggerService = (await import('./services/ScheduleTriggerService.js')).default;
scheduleTriggerService.stop();
apiLogger.info('Shutdown: ScheduleTriggerService stopped');
} catch (err) {
apiLogger.error({ err }, 'Shutdown: Failed to stop ScheduleTriggerService');
}
// 2b. Stop ScheduledMessageWorker
try {
const { stopScheduledMessageWorker } = await import('./services/ScheduledMessageWorker.js');
stopScheduledMessageWorker();
apiLogger.info('Shutdown: ScheduledMessageWorker stopped');
} catch (err) {
apiLogger.error({ err }, 'Shutdown: Failed to stop ScheduledMessageWorker');
}
// 2c. Shutdown CredentialVault (ADR-0028: zero key buffer)
try {
await credentialVault.shutdown();
apiLogger.info('Shutdown: CredentialVault stopped');
} catch (err) {
apiLogger.error({ err }, 'Shutdown: Failed to stop CredentialVault');
}
// 2c. Shutdown PermissionResolver (ADR-0053: close LISTEN client, drop cache)
try {
await permissionResolver.shutdown();
apiLogger.info('Shutdown: PermissionResolver stopped');
} catch (err) {
apiLogger.error({ err }, 'Shutdown: Failed to stop PermissionResolver');
}
// 2c″. Shutdown SecretsVault (ADR-0040: close LISTEN client, zero key)
try {
await secretsVault.shutdown();
apiLogger.info('Shutdown: SecretsVault stopped');
} catch (err) {
apiLogger.error({ err }, 'Shutdown: Failed to stop SecretsVault');
}
// 2c″. Shutdown PermissionResolver (ADR-0053: close LISTEN client, drop cache)
try {
await permissionResolver.shutdown();
apiLogger.info('Shutdown: PermissionResolver stopped');
} catch (err) {
apiLogger.error({ err }, 'Shutdown: Failed to stop PermissionResolver');
}
// 2d. Stop connector refresh scheduler (ADR-0028 Phase 2)
try {
const { shutdown: stopRefresh } = await import('./services/connectors/refreshScheduler.js');
await stopRefresh();
apiLogger.info('Shutdown: ConnectorRefreshScheduler stopped');
} catch (err) {
apiLogger.error({ err }, 'Shutdown: Failed to stop ConnectorRefreshScheduler');
}
// 2e. Stop agent run dispatcher (ADR-0030 Phase 2)
try {
const { shutdown: stopDispatcher } = await import('./services/agent-run-dispatcher/index.js');
await stopDispatcher();
apiLogger.info('Shutdown: AgentRunDispatcher stopped');
} catch (err) {
apiLogger.error({ err }, 'Shutdown: Failed to stop AgentRunDispatcher');
}
// 3. Stop CalendarSyncScheduler (clear sync interval)
try {
const { stopCalendarSync } = await import('./services/CalendarSyncScheduler.js');
stopCalendarSync();
apiLogger.info('Shutdown: CalendarSyncScheduler stopped');
} catch (err) {
apiLogger.error({ err }, 'Shutdown: Failed to stop CalendarSyncScheduler');
}
// 3b. Stop VerificationTTLSweeper (ADR-0011 §Phase D)
try {
const { stopVerificationTTLSweeper } = await import('./services/verification/ttlSweeper.js');
stopVerificationTTLSweeper();
apiLogger.info('Shutdown: VerificationTTLSweeper stopped');
} catch (err) {
apiLogger.error({ err }, 'Shutdown: Failed to stop VerificationTTLSweeper');
}
// 4. CLI agent processes — DO NOT KILL on graceful shutdown.
// Claude Code CLI processes run as detached process groups. Killing them on
// PM2 restart / deploy was the root cause of agents "stopping" — each restart
// killed all running CLI agents. Instead, let them finish naturally.
// On next startup, recoverStuckJobs() will check PID liveness and handle:
// - Still alive → leave running (PID tracked in agent_jobs.worker_pid)
// - Dead → mark failed + re-dispatch
// Only kill truly orphaned MCP server processes (those leak resources).
try {
const { getActiveProcessCount, killOrphanMCPProcesses } = await import('./services/labs/ai-execution-service.js');
const activeCount = getActiveProcessCount();
if (activeCount > 0) {
apiLogger.info({ activeCount }, 'Shutdown: Leaving active CLI agent processes running (will be adopted on restart)');
}
// Only kill orphan MCP processes (not the CLI agents themselves)
const mcpKilled = await killOrphanMCPProcesses();
if (mcpKilled > 0) {
apiLogger.info({ mcpKilled }, 'Shutdown: Killed orphan MCP processes');
}
} catch (err) {
apiLogger.error({ err }, 'Shutdown: Failed to handle child processes');
}
// 5. Mark processing/pending agent_jobs as failed (so recoverStuckJobs finds them cleanly on restart)
try {
const { dbRun, dbAll, isPostgres } = await import('./database/connection.js');
// FIX-D: Mark processing jobs with recovery marker in BOTH error_message AND result_metadata.
// The result_metadata JSON flag is more reliable than text matching on error_message,
// which could be overwritten by concurrent catch blocks (though FIX-A now prevents that).
// Belt-and-suspenders: use both mechanisms for maximum reliability.
const shutdownMetadata = JSON.stringify({ shutdown_recovery: true, shutdown_at: new Date().toISOString() });
const processingResult = await dbRun(
isPostgres()
? `UPDATE agent_jobs SET status = 'failed', error_message = 'Graceful shutdown — will auto-recover on restart', result_metadata = $1, completed_at = NOW() WHERE status = 'processing'`
: `UPDATE agent_jobs SET status = 'failed', error_message = 'Graceful shutdown — will auto-recover on restart', result_metadata = ?, completed_at = datetime('now') WHERE status = 'processing'`,
[shutdownMetadata]
);
// For graceful shutdown: store which conversations need recovery (for restart Phase 1b)
// Get conversations that had active jobs before marking them failed
const activeConvs = await dbAll(
isPostgres()
? `SELECT DISTINCT conversation_id, agent_name, agent_row_id, agent_user_id, context, trigger_message_id, trigger_user_id
FROM agent_jobs WHERE error_message = 'Graceful shutdown — will auto-recover on restart' AND completed_at >= NOW() - INTERVAL '5 seconds'`
: `SELECT DISTINCT conversation_id, agent_name, agent_row_id, agent_user_id, context, trigger_message_id, trigger_user_id
FROM agent_jobs WHERE error_message = 'Graceful shutdown — will auto-recover on restart' AND completed_at >= datetime('now', '-5 seconds')`
);
if (processingResult?.changes > 0) {
apiLogger.info({ count: processingResult.changes, conversations: activeConvs?.length || 0 }, 'Shutdown: Marked processing jobs as failed for recovery');
}
// Reset all is_processing conversations so they don't stay stuck
const result = await dbRun(
isPostgres()
? `UPDATE conversations SET is_processing = false, processing_started_at = NULL, processing_agent_id = NULL, processing_agent_name = NULL, updated_at = NOW() WHERE is_processing = true`
: `UPDATE conversations SET is_processing = 0, processing_started_at = NULL, processing_agent_id = NULL, processing_agent_name = NULL, updated_at = datetime('now') WHERE is_processing = 1`
);
if (result?.changes > 0) {
apiLogger.info({ count: result.changes }, 'Shutdown: Cleared is_processing flags');
}
} catch (err) {
apiLogger.error({ err }, 'Shutdown: Failed to clean up jobs/conversations');
}
// 4. Close HTTP server (stop accepting new connections)
server.close(() => {
apiLogger.info('Shutdown: HTTP server closed');
process.exit(0);
});
// Safety: force exit after 10 seconds if graceful close hangs
setTimeout(() => {
apiLogger.warn('Shutdown: Forced exit after 10s timeout');
process.exit(1);
}, 10000).unref();
}
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));