/** * ArenaSK Server — 主入口 * * 启动: node index.js | node --watch index.js (开发) */ const express = require('express'); const cors = require('cors'); const cookieParser = require('cookie-parser'); const path = require('path'); const http = require('http'); const WebSocket = require('ws'); const config = require('./config'); const { ensureDataDirs } = require('./services/skill-dir-manager'); const { PluginManager } = require('./src/plugins/plugin-manager'); const { initClaudeChatWSServer } = require('./routes/claude-chat-ws'); const app = express(); const server = http.createServer(app); const pluginManager = new PluginManager(); // ─── 中间件 ─── app.use(cors(config.cors)); app.use(cookieParser()); // ── 控制字符净化中间件 ── // Agent 通过 curl 发送的 JSON body 可能包含原始换行/制表符等控制字符, // 导致 body-parser 的 JSON.parse 失败 (Bad control character in string literal) // 此中间件在 JSON 解析前预处理 body,转义 JSON 字符串内的控制字符 app.use((req, res, next) => { if (req.headers['content-type']?.includes('application/json')) { let rawBody = ''; req.on('data', chunk => rawBody += chunk); req.on('end', () => { if (!rawBody) return next(); try { // 先尝试直接解析 req.body = JSON.parse(rawBody); req._bodyParsed = true; next(); } catch (e) { // 解析失败,清理控制字符后重试 try { // eslint-disable-next-line no-control-regex const cleaned = rawBody.replace(/[\x00-\x1F\x7F]/g, (ch) => { if (ch === '\n') return '\\n'; if (ch === '\r') return '\\r'; if (ch === '\t') return '\\t'; return ''; // 删除其他控制字符 }); req.body = JSON.parse(cleaned); req._bodyParsed = true; console.log(`[BodySanitizer] ⚠️ 已净化请求体中的控制字符 (${req.path})`); next(); } catch (e2) { res.status(400).json({ error: 'Invalid JSON: ' + e2.message }); } } }); } else { next(); } }); // express.json() 仅处理未被上面中间件处理的请求 app.use((req, res, next) => { if (req._bodyParsed) return next(); express.json()(req, res, next); }); // ─── JSON 语法错误处理 ─── app.use((err, req, res, next) => { if (err instanceof SyntaxError && err['status'] === 400 && 'body' in err) { console.warn('❌ 收到无效的 JSON Payload:', err.message); return res.status(400).json({ error: '无效的 JSON 格式 (请检查是否包含未转义的多行文本、换行符或引号)', details: err.message }); } next(err); }); // ─── API 路由 ─── app.use('/api/claude-chat', require('./routes/claude-chat')); app.use('/api/config', require('./routes/config')); app.use('/api/evolution', require('./routes/evolution')); app.use('/api/innovator', require('./routes/innovator')); app.use('/api/master', require('./routes/master-chat')); app.use('/api/open', require('./routes/open-api')); app.use('/api/arena', require('./routes/arena')); app.use('/api/auth', require('./routes/auth')); app.use('/api/whatsapp', require('./routes/whatsapp-auth')); app.use('/api/users', require('./routes/users')); app.use('/api/agent', require('./routes/agent-chat')); app.use('/api/okr', require('./routes/okr')); // /api/goals 别名已退役(27-P3C §六.1) app.use('/api/company', require('./routes/company')); app.use('/api/tasks', require('./routes/tasks')); app.use('/api/devloop', require('./routes/devloop')); app.use('/api/graph-runs', require('./routes/graph-run')); // P3 图执行(T6) app.use('/api/company', require('./routes/graph-run').companyRouter); // GET /:cid/graph-runs app.use('/api/dream', require('./routes/dream')); // 29-DREAM 经验汇流 app.use('/public', require('express').static(require('path').join(__dirname, 'public'))); // ─── 系统端点 ─── app.get('/api/health', (req, res) => res.json({ status: 'ok', timestamp: new Date().toISOString(), plugins: pluginManager.summary(), })); app.get('/api/plugins', (req, res) => res.json(pluginManager.summary())); // ─── 静态文件 (生产环境) ─── if (process.env.NODE_ENV === 'production') { app.use(express.static(path.join(__dirname, '../web/dist'))); app.get('*', (req, res) => res.sendFile(path.join(__dirname, '../web/dist/index.html'))); } app.use((err, req, res, next) => { console.error('Server error:', err); res.status(500).json({ error: err.message }); }); // ─── 启动 ─── async function start() { try { ensureDataDirs(); // Graph agent 元数据对齐(P3B W1):把 system/agents 的目录现状 // 同步进 system_agents,并回收指向已删目录的悬空行。必须在 listen() 之前—— // work_graph_nodes.system_agent_id 是指向这张表的外键,第一个请求就可能提交图。 // 幂等 + 同步 + 毫秒级;内部已包 try/catch,失败只降级不挡启动。 require('./services/graph-seed-service').runStartupSeedSafely(); const host = config.host || '0.0.0.0'; server.listen(Number(config.port), host, () => { console.log(`✅ 服务启动: http://${host}:${config.port}`); }); const wss = new WebSocket.Server({ server, path: '/ws/claude' }); initClaudeChatWSServer(wss); // 插件系统(异步,不阻塞 HTTP) initPlugins().catch(err => console.error('插件启动失败:', err.message)); // 恢复中断任务并起搏 Agent (延迟 5s,等 HTTP 服务完全就绪) setTimeout(() => { // 跑全量测试前把后台驱动全关掉:`DISABLE_BACKGROUND_AGENTS=1 bash start.sh`。 // // 为什么需要这个开关:这些驱动会起 agent,而 agent 的 cwd 是运行态目录 // `system/agents////`(locator.taskDir,落在本体目录里属既定设计)。 // 心跳每 5 分钟建一个新的,实测 cto/_global/ 攒到 260+ 子项还在涨 —— 于是扫描 // system/agents/ 的守卫测试(no-dead-path-references 那一类)会被瞬时竞态打红: // 列到了目录名,进去时那一层还没写完。症状是偶发假失败,重跑就好,最难查。 // // 关的是「会自己起 agent 的东西」,恰好全在这个 setTimeout 里,所以一条早退就够: // 两处中断恢复 + 任务队列兜底扫描 + 两路心跳。 if (process.env.DISABLE_BACKGROUND_AGENTS === '1') { console.log('[Startup] ⏸ DISABLE_BACKGROUND_AGENTS=1 —— 中断恢复 / 任务队列 / 两路心跳全部跳过'); return; } resumeIncompleteTasks().catch(err => console.error('[Startup] 恢复任务失败:', err.message)); // 27-P3C:goal 级验收窗口里被打断的那些(status 已终态、判词还是占位值)。 // 和上面同属「中断任务恢复」,也同样要等 HTTP 就绪——它收尾时会打自家 /api/agent/task/report。 require('./services/task-settle').resumePendingGoalValidations() .catch(err => console.error('[Startup] 重推 goal 验收失败:', err.message)); // 27-P3C 批 3:任务队列。进程重启会丢掉所有在途的 setImmediate 唤醒 // (队列钩子挂在 graph-run-sse.transitionRun 上,那是进程内的), // 于是重启后队里的任务会一直躺着 —— 界面上和「正在排队」一模一样,正是本批要消灭的症状。 // 所以启动时先补一次全量扫描,再起 60s 兜底定时器(unref,绝不拖着进程不退出)。 try { const scheduler = require('./services/task-scheduler'); const woken = scheduler.sweep().filter(r => r.started && r.started.length); console.log(`[Startup] 🗂 任务队列扫描完成,起跑 ${woken.length} 条`); scheduler.startSweeper(); } catch (err) { console.error('[Startup] 任务队列扫描失败:', err.message); } const heartbeatService = require('./services/heartbeat-service'); heartbeatService.start(); // 公司级心跳 (Pipeline 自动驱动) const { companyHeartbeatService } = require('./services/company-heartbeat-service'); companyHeartbeatService.start(); // 29-DREAM 决策 C:经验汇流,每日一次。挂在这个早退之后 —— // 它会起 agent(提炼 + 去敏判定各一次),归 DISABLE_BACKGROUND_AGENTS 管。 // 只挂定时**不立刻跑一轮**:启动那一刻跑没有额外收益,反而让每次重启都烧一遍。 require('./services/dream-service').startDaily(); }, 5000); } catch (error) { console.error('Failed to start server:', error); process.exit(1); } } async function resumeIncompleteTasks() { const taskMgr = require('./services/skill-task-manager'); const skillsDir = config.skillsCodeDir || path.resolve(__dirname, '../skills'); const incomplete = taskMgr.findIncompleteTasks(skillsDir); if (incomplete.length === 0) return console.log('[Startup] ✅ 无中断任务'); console.log(`[Startup] 🔄 恢复 ${incomplete.length} 个中断任务...`); const { _jobManager, _runSkillCreation, _runFeedbackIteration } = require('./routes/open-api'); for (const item of incomplete) { try { const job = _jobManager.createJob({ domain: item.task.domain || '', scope: item.task.scope || '', description: item.task.description || '', keywords: item.task.keywords || [], }); job.skillName = item.skillName; if (item.task.pipelineType === 'feedback' && _runFeedbackIteration) { await _runFeedbackIteration(job, item.task.feedback || '', { skillName: item.skillName, domain: item.task.domain || '', scope: item.task.scope || '', keywords: item.task.keywords || [], }); } else if (_runSkillCreation) { await _runSkillCreation(job, item.nextStep); } } catch (err) { console.error(`[Startup] 恢复 ${item.skillName} 失败:`, err.message); } } } async function initPlugins() { await pluginManager.init(); if (!pluginManager.enabled) return console.log('ℹ️ 插件: 未配置 channel,跳过'); const { runOrchestration, runMasterSession } = require('./src/standalone-entry'); await pluginManager.start({ runOrchestration: ({ task }) => runOrchestration(task), runMasterSession: ({ message }) => runMasterSession(message), }); console.log(`✅ 插件就绪: channels=[${pluginManager.channels.map(c => c.id)}], tools=${pluginManager.tools.length}`); } // ─── 优雅关闭 ─── async function shutdown() { console.log('\n正在关闭...'); await pluginManager.shutdown(); process.exit(0); } process.on('SIGTERM', shutdown); process.on('SIGINT', shutdown); start(); module.exports = app;