index.js 13.5 KB
/**
 * 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/<agent>/<companyId>/<taskId>/`(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。
            const taskSettle = require('./services/task-settle');
            taskSettle.resumePendingGoalValidations()
                .catch(err => console.error('[Startup] 重推 goal 验收失败:', err.message));
            // T-mt622nil:上面那条只补「收敛钩子已经跑过、卡在验收/汇报半路」的。更前面那一步——
            // 钩子压根没被触发过(进程重启 / 异常吞掉 / 事件丢失)——task 早到终态但 goal 永远停在
            // active,只能靠人工 OKR 巡检才发现(e2e-demo 公司 G-mt2r62ia-73af 卡了 48 小时)。
            // 开机补一次全量扫描,再起周期性心跳兜底(同 wallclock 看门狗的形状)。
            try {
                const swept = taskSettle.sweepStuckActiveGoals();
                // 无条件打印:swept=0 时也要留痕,否则"扫描确实跑过但没找到"和"扫描压根没跑起来"
                // 在日志里长一个样——而这正是这次要修的那类静默失败。
                console.log(`[Startup] 🧟 僵尸 goal 扫描:开机收口 ${swept} 条`);
                taskSettle.startZombieGoalSweeper();
            } 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);
            }
            // 27-P4 §4.4:墙钟预算看门狗。manual 模式没人点「下一步」、或 auto 模式的驱动循环
            // (纯内存 promise 链)随进程重启丢失时,run_status='running' 的 run 从此再也不会
            // 触发 checkBudget —— wallclock_ms 早超了也没人发现(T-msx6toej 卡 135 小时才被人工揪出)。
            // 起跑前先补一次全量扫描,再起 60s 兜底定时器,同 task-scheduler 的形状。
            try {
                const engine = require('./services/graph-engine');
                const swept = engine.sweepWallclockBudgets();
                if (swept.suspended.length) {
                    console.log(`[Startup] ⏱️  墙钟看门狗:挂起 ${swept.suspended.length} 条超时仍在跑的 run`);
                }
                engine.startWallclockWatchdog();
            } 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;