/** * Company Routes — 公司管理 API * * 挂载: /api/company */ const express = require('express'); const path = require('path'); const fs = require('fs'); const router = express.Router(); const { CompanyService, listCompanyAgents, buildAgentDefinitionBrief } = require('../services/company-service'); const { ScheduleService } = require('../services/schedule-service'); const orgGraphService = require('../services/org-graph-service'); // 28 批次 4:公司不再持有 agent 实体 —— 名册查 company_agents,目录一律经 agent-locator 推导 const locator = require('../services/agent-locator'); const companyService = new CompanyService(); const scheduleService = new ScheduleService(); /** * 把调用方给的相对路径解析到 baseDir 之内;越界返回 null。 * * **不能写成 `abs.startsWith(base)`** —— 那是本文件里六处文件读写检查原先的写法, * 它在「一个目录名是另一个的前缀」时会放行: * base = .../data/companies/foo * path = ../foo-evil/company.json → abs = .../data/companies/foo-evil/company.json * abs.startsWith(base) === true → 公司 foo 读到了公司 foo-evil 的文件 * PUT 那几处更糟:同样的缝隙上是跨公司**写**。 * * path.relative 才是真的在问「abs 在不在 base 里面」:越界时结果以 `..` 开头, * 指向别的盘/绝对路径时结果是绝对路径。绝对路径入参也被这一条挡住 * (path.resolve(base, '/etc/passwd') === '/etc/passwd' → relative 以 .. 开头)。 */ function resolveInside(baseDir, rel) { const base = path.resolve(baseDir); const abs = path.resolve(base, rel || ''); const r = path.relative(base, abs); if (r === '') return abs; // 指向 base 自身 if (r.startsWith('..') || path.isAbsolute(r)) return null; return abs; } /** * fire-and-forget 触发 orggraph-planning 规划这家公司的 workflow(27-P2 §4.4)。 * 不 await —— 一轮真实 Claude CLI 对话是分钟级的,调用方必须立即返回。 * 写法与本文件 /hire 里触发 agent-creator / innovator 的自调用保持一致。 */ function triggerOrgGraphPlanning(company, tag = 'OrgGraph') { const config = require('../config'); const PORT = config.port || 3000; const { id, name, goal, description } = company || {}; // ⚠️ 必须用 `${companyId}:orggraph-planning` 这个公司限定地址。 // 本体虽然只有一份,但公司态(company-context.md / 记忆 / 会话)是按公司分的, // 裸名 /api/agent/orggraph-planning/chat 会落到 `_global` 那份上,规划时看不到公司语境。 const namespacedId = `${id}:orggraph-planning`; fetch(`http://127.0.0.1:${PORT}/api/agent/${encodeURIComponent(namespacedId)}/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: `公司 ${id}(${name})已创建。\n公司目标(company-goal):${goal || '(未填写)'}\n公司描述:${description || '(未填写)'}\n请按 orggraph-planning 工作流规划并建立这家公司的 workflow:读 system/org-graph-templates/ 候选骨架 + 可招角色清单,产出执行流图后调用 POST /api/company/${id}/org-graph 落库,再按拓扑序完成招聘。`, conversationId: `orggraph-bootstrap-${id}-${Date.now().toString(36)}`, }), }) // fetch 不会因为 4xx/5xx 而 reject,只在网络层出错时 reject。 // 不显式检查 res.ok 的话,地址写错这类问题会被完全静默吞掉(实测踩过)。 .then(res => { if (!res.ok) { console.error(`[${tag}] orggraph-planning 触发失败: HTTP ${res.status} @ ${namespacedId}`); } else { console.log(`[${tag}] ✅ 已触发 ${namespacedId} 规划 org graph`); } }) .catch(e => console.error(`[${tag}] orggraph-planning 触发失败(网络):`, e.message)); } // ═══════════════════════════════════════ // Company CRUD // ═══════════════════════════════════════ // POST /api/company/create router.post('/create', async (req, res) => { try { const { id, name, goal, description } = req.body; if (!id || !name) return res.status(400).json({ error: 'id 和 name 为必填项' }); // 校验 id 格式 (小写字母、数字、-) if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(id) && id.length < 2) { return res.status(400).json({ error: 'id 格式无效 (只允许小写字母、数字、-)' }); } const company = companyService.create({ id, name, goal, description }); // ── P2 T6:自动招聘 orggraph-planning 并异步触发组织结构规划 ── // 降级约定(27-P2 §4.4):这一步任何失败都不能影响公司创建本身, // 最差退化成一家没有 workflow 的空壳公司(等同 P2 上线前的现状)。 try { const bootstrap = companyService.hire(id, 'orggraph-planning', {}); // 只有拿到真模板(或目录已在)才值得起一轮对话:模板缺失时 hire 会退化成 base 空骨架, // 那个骨架里没有 §4.1 的八步工作流,触发了也规划不出东西。 if (bootstrap.status === 'hired_from_template' || bootstrap.status === 'already_exists') { triggerOrgGraphPlanning(company, 'Create'); } else { console.warn(`[Create] orggraph-planning 招聘未走模板分支 (status=${bootstrap.status}),跳过自动规划;模板就绪后可调 POST /api/company/${id}/org-graph/replan 补跑`); } } catch (e) { console.error('[Create] orggraph-planning 自动招聘失败(不影响公司创建):', e.message); } res.json({ success: true, company, message: '公司已创建,正在由 orggraph-planning 自动规划组织结构(异步进行中)。' }); } catch (err) { res.status(400).json({ error: err.message }); } }); // GET /api/company/list router.get('/list', (req, res) => { res.json(companyService.list()); }); // GET /api/company/templates router.get('/templates', (req, res) => { res.json(companyService.listTemplates()); }); // PATCH /api/company/templates/:templateId — 修改模板元信息 (名称, 类型, 描述) router.patch('/templates/:templateId', (req, res) => { try { const { templateId } = req.params; const { name, type, description } = req.body; if (name !== undefined && !name.trim()) return res.status(400).json({ error: 'name 为必填项' }); const path = require('path'); const fs = require('fs'); const TEMPLATES_DIR = path.resolve(__dirname, '../../system/agents'); const templateDir = path.join(TEMPLATES_DIR, templateId); if (!fs.existsSync(templateDir)) { return res.status(404).json({ error: `模板 "${templateId}" 不存在` }); } // 1. 更新 config.json 中的字段 const configPath = path.join(templateDir, 'config.json'); if (fs.existsSync(configPath)) { const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); if (name !== undefined) config.name = name.trim(); if (type !== undefined) config.type = type.trim(); if (description !== undefined) config.description = description.trim(); fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8'); } // 2. 更新 _registry.json 中的字段 const registryPath = path.join(TEMPLATES_DIR, '_registry.json'); if (fs.existsSync(registryPath)) { try { const registry = JSON.parse(fs.readFileSync(registryPath, 'utf-8')); const entry = registry.find(t => t.id === templateId); if (entry) { if (name !== undefined) entry.name = name.trim(); if (type !== undefined) entry.type = type.trim(); if (description !== undefined) entry.description = description.trim(); fs.writeFileSync(registryPath, JSON.stringify(registry, null, 2) + '\n', 'utf-8'); } } catch {} } console.log(`[Template] ✏️ 模板 ${templateId} 元信息已更新`); res.json({ success: true, id: templateId, name: name?.trim(), type: type?.trim(), description: description?.trim() }); } catch (err) { res.status(500).json({ error: err.message }); } }); // DELETE /api/company/templates/:templateId — 删除模板 router.delete('/templates/:templateId', (req, res) => { try { const { templateId } = req.params; const path = require('path'); const fs = require('fs'); const TEMPLATES_DIR = path.resolve(__dirname, '../../system/agents'); const templateDir = path.join(TEMPLATES_DIR, templateId); if (!fs.existsSync(templateDir)) { return res.status(404).json({ error: `模板 "${templateId}" 不存在` }); } // 1. 删除模版文件夹 fs.rmSync(templateDir, { recursive: true, force: true }); // 2. 更新 registry const registryPath = path.join(TEMPLATES_DIR, '_registry.json'); if (fs.existsSync(registryPath)) { try { let registry = JSON.parse(fs.readFileSync(registryPath, 'utf-8')); registry = registry.filter(t => t.id !== templateId); fs.writeFileSync(registryPath, JSON.stringify(registry, null, 2) + '\n', 'utf-8'); } catch {} } console.log(`[Template] 🗑️ 模板 ${templateId} 已被删除`); res.json({ success: true, message: `模板 ${templateId} 已删除` }); } catch (err) { res.status(500).json({ error: err.message }); } }); // POST /api/company/templates — 创建新模板 Agent router.post('/templates', async (req, res) => { try { const { id, name, type, description } = req.body; if (!id || !name) return res.status(400).json({ error: 'id 和 name 为必填项' }); if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) { return res.status(400).json({ error: 'id 格式无效 (只允许小写字母、数字、-)' }); } const path = require('path'); const fs = require('fs'); // 28 批次 5-21:baseDir 只认 agent-locator 这一份定义(原来这里指向已不存在的 system/templates/) const TEMPLATES_DIR = locator.AGENTS_ROOT; const templateDir = path.join(TEMPLATES_DIR, id); if (fs.existsSync(templateDir)) { return res.status(409).json({ error: `模板 "${id}" 已存在` }); } // 使用 AgentScaffold 创建标准骨架 const { AgentScaffold } = await import('../../packages/agent-core/AgentScaffold.js'); const scaffold = new AgentScaffold(TEMPLATES_DIR); await scaffold.create({ id, type: type || 'skill', name: name, parentId: null, }); // 如果有 description,追加到 config.json if (description) { const configPath = path.join(templateDir, 'config.json'); const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); config.description = description; fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8'); } console.log(`[Template] ✅ 新模板已创建: ${id} (${type || 'skill'})`); res.status(201).json({ success: true, id, name, type: type || 'skill' }); } catch (err) { res.status(500).json({ error: err.message }); } }); // POST /api/company/templates/:templateId/generate — 调用 Agent Creator 生成定义文件 router.post('/templates/:templateId/generate', async (req, res) => { try { const { templateId } = req.params; const { description } = req.body; const path = require('path'); const fs = require('fs'); const config = require('../config'); const TEMPLATES_DIR = path.resolve(__dirname, '../../system/agents'); const templateDir = path.join(TEMPLATES_DIR, templateId); if (!fs.existsSync(templateDir)) { return res.status(404).json({ error: `模板 "${templateId}" 不存在` }); } const PORT = config.port || 3000; // 与 /hire 那处同一份产出契约(28 批次 5-23)—— 文本单点定义在 company-service, // 两处各抄一份的时候,运行期布局那半段只补进一处就等于没补。 const prompt = buildAgentDefinitionBrief({ agentId: templateId, bodyPath: templateDir, description }); // 异步调用 agent-creator fetch(`http://127.0.0.1:${PORT}/api/agent/agent-creator/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: prompt, conversationId: `tpl-gen-${templateId}-${Date.now().toString(36)}`, }), }).catch(e => console.error('[Template] Agent Creator 请求失败:', e.message)); console.log(`[Template] 🤖 已触发 Agent Creator 为 ${templateId} 生成定义`); res.json({ success: true, message: `已触发 Agent Creator 为 "${templateId}" 生成定义文件`, status: 'generating' }); } catch (err) { res.status(500).json({ error: err.message }); } }); // POST /api/company/templates/:templateId/train — 调用 Innovator 训练模板 router.post('/templates/:templateId/train', async (req, res) => { try { const { templateId } = req.params; const { instructions } = req.body; const path = require('path'); const fs = require('fs'); const config = require('../config'); const TEMPLATES_DIR = path.resolve(__dirname, '../../system/agents'); const templateDir = path.join(TEMPLATES_DIR, templateId); if (!fs.existsSync(templateDir)) { return res.status(404).json({ error: `模板 "${templateId}" 不存在` }); } const PORT = config.port || 3000; let prompt = `请你对模板 Agent "${templateId}" 进行评估和训练。 模板位于 \`../../system/agents/${templateId}\` 目录。 请使用 FDE pipeline 对该模板的技能进行评测和进化训练,目标: Benchmark Score ≥ 95。 训练过程包括 Hunt(验证数据), Digest(知识消化), Evolver(技能进化) 等。`; if (instructions) { prompt += `\n**【额外训练要求/指令】**\n${instructions}\n`; } prompt += `\n请立即开始评估和训练,训练完成后请汇报最终分数。`; // 异步调用 innovator fetch(`http://127.0.0.1:${PORT}/api/agent/innovator/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: prompt, conversationId: `tpl-train-${templateId}-${Date.now().toString(36)}`, }), }).catch(e => console.error('[Template] Innovator 请求失败:', e.message)); console.log(`[Template] 🎓 已触发 Innovator 训练 ${templateId}`); res.json({ success: true, message: `已触发 Innovator 训练 "${templateId}"`, status: 'training' }); } catch (err) { res.status(500).json({ error: err.message }); } }); // POST /api/company/templates/:templateId/skills/upload — 上传 Skill 文件/ZIP 包 const multer = require('multer'); const AdmZip = require('adm-zip'); const skillUploadStorage = multer.diskStorage({ destination: (req, file, cb) => { const os = require('os'); const tmpDir = path.join(os.tmpdir(), `skill-upload-${Date.now()}`); fs.mkdirSync(tmpDir, { recursive: true }); cb(null, tmpDir); }, filename: (req, file, cb) => { // Sanitize filename const safe = file.originalname.replace(/[^a-zA-Z0-9._-]/g, '_'); cb(null, safe); }, }); const skillUpload = multer({ storage: skillUploadStorage, limits: { fileSize: 50 * 1024 * 1024, files: 50 }, // 50MB per file, 50 files max }); router.post('/templates/:templateId/skills/upload', skillUpload.array('files', 50), async (req, res) => { try { const { templateId } = req.params; const targetSubdir = req.body.targetDir || '.claude/skills'; // default to .claude/skills/ (Claude Code ACP reads from here) const TEMPLATES_DIR = path.resolve(__dirname, '../../system/agents'); const templateDir = path.join(TEMPLATES_DIR, templateId); if (!fs.existsSync(templateDir)) { return res.status(404).json({ error: `模板 "${templateId}" 不存在` }); } const destDir = path.join(templateDir, targetSubdir); fs.mkdirSync(destDir, { recursive: true }); const uploadedFiles = req.files || []; if (uploadedFiles.length === 0) { return res.status(400).json({ error: '没有上传任何文件' }); } let extractedCount = 0; let copiedCount = 0; const details = []; for (const file of uploadedFiles) { const ext = path.extname(file.originalname).toLowerCase(); if (ext === '.zip') { // ── ZIP 解压 ── try { const zip = new AdmZip(file.path); const entries = zip.getEntries(); // 检测是否整个 ZIP 包在一个顶级目录下(常见的 GitHub 下载格式) const topDirs = new Set(); for (const entry of entries) { const firstSegment = entry.entryName.split('/')[0]; topDirs.add(firstSegment); } const hasWrapper = topDirs.size === 1 && entries.some(e => e.isDirectory && e.entryName === [...topDirs][0] + '/'); const stripPrefix = hasWrapper ? [...topDirs][0] + '/' : ''; for (const entry of entries) { if (entry.isDirectory) continue; // 安全:跳过 __MACOSX、.DS_Store 等系统文件 if (entry.entryName.includes('__MACOSX') || entry.entryName.endsWith('.DS_Store')) continue; let relativePath = entry.entryName; if (stripPrefix && relativePath.startsWith(stripPrefix)) { relativePath = relativePath.substring(stripPrefix.length); } if (!relativePath) continue; const outputPath = path.join(destDir, relativePath); // 防止路径穿越 if (!outputPath.startsWith(destDir)) continue; fs.mkdirSync(path.dirname(outputPath), { recursive: true }); fs.writeFileSync(outputPath, entry.getData()); extractedCount++; details.push({ action: 'extracted', file: `${targetSubdir}/${relativePath}` }); } } catch (zipErr) { details.push({ action: 'error', file: file.originalname, error: `ZIP 解压失败: ${zipErr.message}` }); } } else { // ── 普通文件直接复制 ── // 如果上传了带有 webkitRelativePath 的文件夹结构,保留相对路径 const relativePath = file.originalname; const outputPath = path.join(destDir, relativePath); // 防止路径穿越 if (!outputPath.startsWith(destDir)) continue; fs.mkdirSync(path.dirname(outputPath), { recursive: true }); fs.copyFileSync(file.path, outputPath); copiedCount++; details.push({ action: 'copied', file: `${targetSubdir}/${relativePath}` }); } // 清理临时文件 try { fs.unlinkSync(file.path); } catch {} } // 清理临时上传目录 for (const file of uploadedFiles) { try { fs.rmdirSync(path.dirname(file.path)); } catch {} } console.log(`[Template] 📥 ${templateId}: Skill 导入完成 - ${extractedCount} extracted, ${copiedCount} copied`); res.json({ success: true, templateId, targetDir: targetSubdir, stats: { extractedFromZip: extractedCount, copiedFiles: copiedCount, totalFiles: extractedCount + copiedCount }, details, }); } catch (err) { console.error('[Template] Skill upload error:', err); res.status(500).json({ error: err.message }); } }); /** * POST /api/company/templates/:templateId/provision * 系统级依赖安装 — 绕过 ACP,直接 shell 执行 * * Body: { commands: ["pip install rembg", "npm install ..."], agentDir?: string } * - commands: 要执行的 shell 命令数组 * - agentDir: 可选,指定公司 agent 目录(不传则在 template 目录执行) * * 特点: * 1. 不走 Claude CLI / ACP,不受 45s idle timeout 限制 * 2. 用 child_process.exec 同步执行,每个命令独立超时 10 分钟 * 3. 返回每个命令的 stdout/stderr 供前端展示 */ router.post('/templates/:templateId/provision', async (req, res) => { try { const { templateId } = req.params; const { commands, agentDir } = req.body; const { exec } = require('child_process'); const { promisify } = require('util'); const execAsync = promisify(exec); if (!commands || !Array.isArray(commands) || commands.length === 0) { return res.status(400).json({ error: '请提供 commands 数组' }); } // 确定工作目录 // 原来写的是 `../../data/system/templates` —— 一个从来不存在的路径(`data/` 多余), // 所以不带 agentDir 的调用一直是 404。顺手改正到合并后的本体目录。 const TEMPLATES_DIR = path.resolve(__dirname, '../../system/agents'); let workDir; if (agentDir) { // 公司 agent 目录 workDir = path.resolve(__dirname, '../../', agentDir); } else { workDir = path.join(TEMPLATES_DIR, templateId); } if (!fs.existsSync(workDir)) { return res.status(404).json({ error: `目录不存在: ${workDir}` }); } // 安全检查:禁止危险命令 const BLOCKED_PATTERNS = [/rm\s+-rf\s+\//, /mkfs/, /dd\s+if=/, />\s*\/dev\//]; for (const cmd of commands) { if (BLOCKED_PATTERNS.some(p => p.test(cmd))) { return res.status(400).json({ error: `命令被安全策略拒绝: ${cmd}` }); } } console.log(`[Provision] 🔧 ${templateId}: 开始执行 ${commands.length} 个系统命令`); const results = []; for (const cmd of commands) { console.log(`[Provision] ▶ ${cmd}`); try { const { stdout, stderr } = await execAsync(cmd, { cwd: workDir, timeout: 10 * 60 * 1000, // 每个命令最多 10 分钟 maxBuffer: 10 * 1024 * 1024, // 10MB output buffer env: { ...process.env, PIP_PROGRESS_BAR: 'off' }, }); results.push({ command: cmd, status: 'success', stdout: stdout.slice(-2000), stderr: stderr.slice(-1000) }); console.log(`[Provision] ✅ ${cmd} 完成`); } catch (err) { results.push({ command: cmd, status: 'error', error: err.message, stdout: err.stdout?.slice(-2000), stderr: err.stderr?.slice(-1000) }); console.error(`[Provision] ❌ ${cmd} 失败:`, err.message); } } const allOk = results.every(r => r.status === 'success'); res.json({ success: allOk, templateId, workDir, results, }); } catch (err) { console.error('[Provision] Error:', err); res.status(500).json({ error: err.message }); } }); // GET /api/company/templates/:templateId/logs — 获取模板相关的 agent-creator / innovator 执行日志 router.get('/templates/:templateId/logs', (req, res) => { try { const path = require('path'); const fs = require('fs'); const templateId = req.params.templateId; const locator = require('../services/agent-locator'); const logs = []; // 扫描 agent-creator 和 innovator 的对话,匹配 tpl-gen-{id} 和 tpl-train-{id} const scanAgents = [ { agentId: 'agent-creator', prefix: `tpl-gen-${templateId}`, type: 'generate', label: '🤖 Agent Creator' }, { agentId: 'org-innovator', prefix: `tpl-train-${templateId}`, type: 'train', label: '🎓 Innovator' }, ]; for (const scan of scanAgents) { // 全局 agent 的会话落在保留公司 id `_global` 下(28 §5.1) const convDir = path.join(locator.companyDir(scan.agentId), 'conversations'); if (!fs.existsSync(convDir)) continue; const dirs = fs.readdirSync(convDir, { withFileTypes: true }) .filter(d => d.isDirectory() && d.name.startsWith(scan.prefix)); for (const d of dirs) { const metaPath = path.join(convDir, d.name, 'meta.json'); const histPath = path.join(convDir, d.name, 'history.json'); let meta = {}, history = []; try { meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8')); } catch {} try { history = JSON.parse(fs.readFileSync(histPath, 'utf-8')); } catch {} // 提取最后一条 assistant 消息作为摘要 const lastAssistant = [...history].reverse().find(m => m.role === 'assistant'); logs.push({ conversationId: d.name, agentId: scan.agentId, type: scan.type, label: scan.label, createdAt: meta.createdAt || null, messageCount: history.length, summary: lastAssistant ? (lastAssistant.content || '').slice(0, 500) : '(执行中...)', status: lastAssistant ? 'done' : 'running', }); } } // 按时间倒序 logs.sort((a, b) => new Date(b.createdAt || 0).getTime() - new Date(a.createdAt || 0).getTime()); res.json(logs); } catch (err) { res.status(500).json({ error: err.message }); } }); // GET /api/company/templates/:templateId/files router.get('/templates/:templateId/files', (req, res) => { try { const path = require('path'); const fs = require('fs'); const DATA_DIR = path.resolve(__dirname, '../../system/agents'); const templateDir = path.join(DATA_DIR, req.params.templateId); if (!fs.existsSync(templateDir)) { return res.json({ tree: [], totalFiles: 0 }); } function scanDir(dirPath, relativePath = '') { const entries = fs.readdirSync(dirPath, { withFileTypes: true }); const nodes = []; for (const entry of entries) { const fullPath = path.join(dirPath, entry.name); const relPath = relativePath ? `${relativePath}/${entry.name}` : entry.name; if ((entry.name.startsWith('.') && entry.name !== '.claude') || entry.name === 'node_modules') continue; if (entry.isDirectory()) { const children = scanDir(fullPath, relPath); nodes.push({ name: entry.name, path: relPath, type: 'directory', children, fileCount: children.reduce((sum, c) => sum + (c.type === 'file' ? 1 : (c.fileCount || 0)), 0), }); } else { const stat = fs.statSync(fullPath); nodes.push({ name: entry.name, path: relPath, type: 'file', size: stat.size, modifiedAt: stat.mtime.toISOString(), }); } } return nodes.sort((a, b) => { if (a.type !== b.type) return a.type === 'directory' ? -1 : 1; return a.name.localeCompare(b.name); }); } const tree = scanDir(templateDir); const countFiles = (nodes) => nodes.reduce((sum, n) => sum + (n.type === 'file' ? 1 : countFiles(n.children || [])), 0); res.json({ tree, totalFiles: countFiles(tree) }); } catch (err) { res.status(500).json({ error: err.message }); } }); // GET /api/company/templates/:templateId/files/content router.get('/templates/:templateId/files/content', (req, res) => { try { const path = require('path'); const fs = require('fs'); const DATA_DIR = path.resolve(__dirname, '../../system/agents'); const filePath = req.query.path; if (!filePath) return res.status(400).json({ error: 'path 参数为必填' }); const absPath = resolveInside(path.join(DATA_DIR, req.params.templateId), filePath); if (!absPath) { return res.status(403).json({ error: '路径越界' }); } if (!fs.existsSync(absPath)) { return res.status(404).json({ error: '文件不存在' }); } const stat = fs.statSync(absPath); if (stat.size > 512 * 1024) { return res.json({ content: '(文件过大,跳过预览)', truncated: true, size: stat.size }); } const content = fs.readFileSync(absPath, 'utf-8'); res.json({ content, size: stat.size, path: filePath }); } catch (err) { res.status(500).json({ error: err.message }); } }); // PUT /api/company/templates/:templateId/files/content — 保存模板文件内容 router.put('/templates/:templateId/files/content', (req, res) => { try { const path = require('path'); const fs = require('fs'); const DATA_DIR = path.resolve(__dirname, '../../system/agents'); const filePath = req.body.path; if (!filePath) return res.status(400).json({ error: 'path 参数为必填' }); const absPath = resolveInside(path.join(DATA_DIR, req.params.templateId), filePath); if (!absPath) { return res.status(403).json({ error: '路径越界' }); } // 确保目录存在 const dir = path.dirname(absPath); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(absPath, req.body.content || '', 'utf-8'); console.log(`[Template] 💾 已保存模板文件 ${req.params.templateId}/${filePath}`); res.json({ success: true, path: filePath }); } catch (err) { res.status(500).json({ error: err.message }); } }); // GET /api/company/:companyId router.get('/:companyId', (req, res) => { const company = companyService.get(req.params.companyId); if (!company) return res.status(404).json({ error: '公司不存在' }); res.json(company); }); // PUT /api/company/:companyId router.put('/:companyId', (req, res) => { const company = companyService.update(req.params.companyId, req.body); if (!company) return res.status(404).json({ error: '公司不存在' }); res.json({ success: true, company }); }); // DELETE /api/company/:companyId router.delete('/:companyId', (req, res) => { const company = companyService.delete(req.params.companyId); if (!company) return res.status(404).json({ error: '公司不存在' }); res.json({ success: true, company }); }); // POST /api/company/:companyId/shutdown router.post('/:companyId/shutdown', (req, res) => { const company = companyService.shutdown(req.params.companyId); if (!company) return res.status(404).json({ error: '公司不存在' }); // 停止该公司下所有 Agent 的心跳 try { const heartbeatService = require('../services/heartbeat-service'); const agents = companyService.listAgents(req.params.companyId); for (const agent of agents) { const namespacedId = `${req.params.companyId}:${agent.id}`; if (heartbeatService.timers.has(namespacedId)) { clearInterval(heartbeatService.timers.get(namespacedId)); heartbeatService.timers.delete(namespacedId); console.log(`[Company] 🛑 已停止 ${namespacedId} 的心跳`); } } } catch (err) { console.warn(`[Company] ⚠️ 停止心跳时出错: ${err.message}`); } res.json({ success: true, company }); }); // POST /api/company/:companyId/start — 启动公司 router.post('/:companyId/start', (req, res) => { try { const company = companyService.get(req.params.companyId); if (!company) return res.status(404).json({ error: '公司不存在' }); // 更新状态 const updated = companyService.update(req.params.companyId, { status: 'running', heartbeat: { enabled: true }, }); // 激活该公司下所有 Agent 的心跳 try { const heartbeatService = require('../services/heartbeat-service'); const agents = companyService.listAgents(req.params.companyId); for (const agent of agents) { const namespacedId = `${req.params.companyId}:${agent.id}`; heartbeatService._setupAgentHeartbeat(namespacedId); } console.log(`[Company] ✅ ${req.params.companyId} 已启动,${agents.length} 个 Agent 心跳已激活`); } catch (err) { console.warn(`[Company] ⚠️ 激活心跳时出错: ${err.message}`); } res.json({ success: true, company: updated }); } catch (err) { res.status(500).json({ error: err.message }); } }); // GET /api/company/:companyId/status — 获取公司运行状态(含 Agent 进程信息) router.get('/:companyId/status', (req, res) => { try { const company = companyService.get(req.params.companyId); if (!company) return res.status(404).json({ error: '公司不存在' }); const agents = companyService.listAgents(req.params.companyId); const heartbeatService = require('../services/heartbeat-service'); // 计算运行时长 const uptimeMs = company.status === 'running' && company.startedAt ? Date.now() - new Date(company.startedAt).getTime() : 0; const agentStatuses = agents.map(agent => { const namespacedId = `${req.params.companyId}:${agent.id}`; const hasHeartbeat = heartbeatService.timers.has(namespacedId); // 心跳配置按 `(agent, 公司)` 读:公司态优先,没设置过才回落本体的缺省。 // 走 heartbeatService 的同一个函数 —— 控制台显示的和定时器真正用的必须是同一个答案。 let heartbeatConfig = { enabled: false, intervalMinutes: 15 }; try { heartbeatConfig = heartbeatService.readHeartbeatConfig(namespacedId); } catch {} return { id: agent.id, name: agent.name, type: agent.type, status: agent.status, heartbeatActive: hasHeartbeat, heartbeatEnabled: heartbeatConfig.enabled || false, heartbeatInterval: heartbeatConfig.intervalMinutes || 15, }; }); res.json({ id: company.id, name: company.name, description: company.description, status: company.status, createdAt: company.createdAt, updatedAt: company.updatedAt, startedAt: company.startedAt || null, uptimeMs, heartbeat: company.heartbeat, agents: agentStatuses, agentCount: agents.length, activeAgentCount: agentStatuses.filter(a => a.heartbeatActive).length, }); } catch (err) { res.status(500).json({ error: err.message }); } }); // ═══════════════════════════════════════ // Per-Agent 心跳控制 // ═══════════════════════════════════════ /** * 这家公司雇过这个角色没有(28 批次 4:判据从「公司私有目录在不在」改成「company_agents 有没有行」, * 那个目录整体删掉了,继续判目录的话下面三个心跳端点会无条件 404)。 * * 原来这里返回的是**本体** config.json 的路径,三个端点拿着它读改写 —— 心跳因此是本体级的: * A 公司调成 30 分钟,雇了同一个本体的 B 公司跟着变;更糟的是「读的时候补默认值再整份回写」, * 没人点过开关也能把 `system/agents/{aid}/config.json` 写脏(28 号 E2E 实测)。 * 现在心跳状态落公司态目录,落点与读写口径都收在 heartbeat-service 里,这里只剩「雇没雇」这一问。 */ function isHired(companyId, roleName) { return listCompanyAgents(companyId).some((r) => r.role_name === roleName); } // PUT /api/company/:companyId/agents/:agentId/heartbeat — 更新 Agent 心跳配置 router.put('/:companyId/agents/:agentId/heartbeat', (req, res) => { try { const { intervalMinutes, enabled } = req.body; // 非正数 / NaN 会让 setInterval 退化成每 tick 触发一次 —— 拦在入口,别让它落盘(`> 0` 对 NaN 为 false) if (intervalMinutes !== undefined && !(Number(intervalMinutes) > 0)) { return res.status(400).json({ error: 'intervalMinutes 必须是正数' }); } if (!isHired(req.params.companyId, req.params.agentId)) { return res.status(404).json({ error: `Agent ${req.params.agentId} 不存在` }); } const heartbeatService = require('../services/heartbeat-service'); const namespacedId = `${req.params.companyId}:${req.params.agentId}`; // 只写公司态(`system/agents/{aid}/{cid}/heartbeat.json`),本体一个字节都不动 const heartbeat = heartbeatService.writeHeartbeatConfig(namespacedId, { intervalMinutes, enabled }); // 如果正在运行,重新挂载心跳 if (heartbeat.enabled) { heartbeatService._setupAgentHeartbeat(namespacedId); } else if (heartbeatService.timers.has(namespacedId)) { clearInterval(heartbeatService.timers.get(namespacedId)); heartbeatService.timers.delete(namespacedId); } console.log(`[Company] ⚙️ ${namespacedId} 心跳配置更新: interval=${heartbeat.intervalMinutes}min, enabled=${heartbeat.enabled}`); res.json({ success: true, heartbeat }); } catch (err) { res.status(500).json({ error: err.message }); } }); // POST /api/company/:companyId/agents/:agentId/heartbeat/start — 启动单个 Agent 心跳 router.post('/:companyId/agents/:agentId/heartbeat/start', (req, res) => { try { if (!isHired(req.params.companyId, req.params.agentId)) { return res.status(404).json({ error: `Agent ${req.params.agentId} 不存在` }); } const heartbeatService = require('../services/heartbeat-service'); const namespacedId = `${req.params.companyId}:${req.params.agentId}`; heartbeatService.writeHeartbeatConfig(namespacedId, { enabled: true }); // 激活心跳 heartbeatService._setupAgentHeartbeat(namespacedId); console.log(`[Company] 💓 ${namespacedId} 心跳已启动`); res.json({ success: true, message: `${namespacedId} 心跳已启动` }); } catch (err) { res.status(500).json({ error: err.message }); } }); // POST /api/company/:companyId/agents/:agentId/heartbeat/stop — 停止单个 Agent 心跳 router.post('/:companyId/agents/:agentId/heartbeat/stop', (req, res) => { try { if (!isHired(req.params.companyId, req.params.agentId)) { return res.status(404).json({ error: `Agent ${req.params.agentId} 不存在` }); } const heartbeatService = require('../services/heartbeat-service'); const namespacedId = `${req.params.companyId}:${req.params.agentId}`; heartbeatService.writeHeartbeatConfig(namespacedId, { enabled: false }); // 停止心跳 if (heartbeatService.timers.has(namespacedId)) { clearInterval(heartbeatService.timers.get(namespacedId)); heartbeatService.timers.delete(namespacedId); } console.log(`[Company] 🛑 ${namespacedId} 心跳已停止`); res.json({ success: true, message: `${namespacedId} 心跳已停止` }); } catch (err) { res.status(500).json({ error: err.message }); } }); // ═══════════════════════════════════════ // Agent 技能查询 // ═══════════════════════════════════════ // GET /api/company/:companyId/agents/:agentId/skills — 获取 Agent 技能列表 router.get('/:companyId/agents/:agentId/skills', (req, res) => { try { const path = require('path'); const fs = require('fs'); // Skill 挂在本体的 `.claude/skills/`(agent-manager 的 scoped skill 遍历找的就是这里), // 不是公司私有目录下那个从来没被 CLI 发现过的 `skills/`。 const hired = listCompanyAgents(req.params.companyId).find((r) => r.role_name === req.params.agentId); if (!hired) return res.json([]); const skillsDir = path.join(locator.bodyDir(hired.system_agent_id), '.claude', 'skills'); if (!fs.existsSync(skillsDir)) { return res.json([]); } const skills = fs.readdirSync(skillsDir, { withFileTypes: true }) .filter(d => d.isDirectory()) .map(d => d.name); res.json(skills); } catch (err) { res.status(500).json({ error: err.message }); } }); // ═══════════════════════════════════════ // Agent 招聘 // ═══════════════════════════════════════ // POST /api/company/:companyId/hire router.post('/:companyId/hire', async (req, res) => { try { const { role, type, name, description, customAgent, customSoul, customUser } = req.body; if (!role) return res.status(400).json({ error: 'role 为必填项' }); const result = await companyService.hireWithTraining(req.params.companyId, role, { type, name: name || role, description, customAgent, customSoul, customUser, }); // ── 核心逻辑扩展:自动补充定义 + 搜索注入 3 个初始 Skill + Innovator 训练 ── if (result.status === 'training' || result.needsTraining) { (async () => { try { console.log(`[Hire] 🚀 自动流水线启动: 为新员工 ${role} 安排 [定义 → 技能注入 → 训练]`); const fs = require('fs'); const path = require('path'); const config = require('../config'); const skillDb = require('../services/skill-db'); const namespacedId = `${req.params.companyId}:${role}`; // result.agentDir 是**公司态**目录;定义文件与 skill 都写本体(28 批次 4: // 公司不再持有 agent 实体,往公司侧写等于刚清完又造一个新的死角)。 const bodyPath = locator.bodyDir(role); const PORT = config.port || 3000; // 1. 发起 Agent-Creator 编写骨架定义 (异步不等待) // ⚠️ 给的是**绝对路径**:agent-creator 的 cwd 现在是它自己的任务态目录 // (system/agents/agent-creator/_global/{taskId}/),原来那个 `../{公司}/agents/{角色}` // 相对路径从那里数出去指向的是一个不存在的位置 —— 它会照着写,且不报错。 fetch(`http://127.0.0.1:${PORT}/api/agent/agent-creator/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ // 产出契约(28 批次 5-23)逐字点名三个必填文件:本体填不满就停在 // status='draft'、别家公司雇不走。以前只写「补充完整的定义文件(…等)」, // 「等」里那份 SKILL.md 谁也没保证过 —— org-coder 就是这么变成空定义的。 // 契约文本(含运行期布局那半段)单点定义在 company-service,见其注释。 message: buildAgentDefinitionBrief({ agentId: role, bodyPath, description: description || name || role, intro: `新员工 ${namespacedId} 已由脚手架创建空白目录在 \`${bodyPath}\`。请你按照标准模板,使用 Write 工具为该员工补充定义文件。写完后请答复。`, }), conversationId: `create-${req.params.companyId}-${role}-${Date.now().toString(36)}` }) }).catch(e => console.error('[Hire] Agent-Creator 请求错误:', e.message)); // 2. 去网上/Arena搜索并注入 3 个基础 Skill。 // 落 `.claude/skills/` 而不是 `skills/`:只有前者会被 CLI(和 agent-manager // 的 scoped skill 遍历)发现,后者是一个从来没人读过的目录。 const skills = skillDb.searchSkills(role, { limit: 3 }); const skillsDir = path.join(bodyPath, '.claude', 'skills'); if (skills && skills.length > 0) { if (!fs.existsSync(skillsDir)) fs.mkdirSync(skillsDir, { recursive: true }); const copyDir = (src, dest) => { fs.mkdirSync(dest, { recursive: true }); const entries = fs.readdirSync(src, { withFileTypes: true }); for (const e of entries) { if (e.isDirectory()) copyDir(path.join(src, e.name), path.join(dest, e.name)); else fs.copyFileSync(path.join(src, e.name), path.join(dest, e.name)); } }; for (const skill of skills) { if (skill.directory && fs.existsSync(skill.directory)) { const targetPath = path.join(skillsDir, skill.skillName); if (!fs.existsSync(targetPath)) { try { copyDir(skill.directory, targetPath); console.log(`[Hire] 💉 成功为其注入基础技能: ${skill.skillName}`); } catch (e) { console.error(`[Hire] 注入技能 ${skill.skillName} 失败:`, e.message); } } } } } else { console.log(`[Hire] ⚠️ 网上/Arena 未找到该角色的匹配 Skill,将交由 Innovator 凭空生成`); } // 3. 延时 5 秒触发 Innovator 进行评估与训练 (等 creator 写入完毕) setTimeout(() => { fetch(`http://127.0.0.1:${PORT}/api/agent/innovator/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: `公司 ${req.params.companyId} 的员工 ${role} 骨架和基础技能已注入完毕。请你使用 FDE pipeline 对该 Agent ${namespacedId} (特别是它的技能)进行评测和进化训练,直到 Benchmark 达标 (Score ≥ 95)。训练过程包括 Hunt(验证数据), Digest, Evolver 等。请立即开始评估和训练,并保持向我汇报。`, conversationId: `train-${req.params.companyId}-${role}-${Date.now().toString(36)}` }) }).catch(e => console.error('[Hire] Innovator 请求错误:', e.message)); }, 5000); } catch (err) { console.error(`[Hire Orchestration] 自动流水线编排异常: ${err.message}`); } })(); } // ── P0 FIX: 动态心跳挂载 ── // 招聘成功后,立即为这个新 Agent 注册心跳定时器(如果它的 config.json 里 heartbeat.enabled = true) try { const heartbeatService = require('../services/heartbeat-service'); const namespacedId = `${req.params.companyId}:${role}`; heartbeatService._setupAgentHeartbeat(namespacedId); console.log(`[Hire] 💓 已为 ${namespacedId} 动态挂载心跳`); } catch (hbErr) { console.warn(`[Hire] ⚠️ 心跳挂载跳过: ${hbErr.message}`); } res.json(result); } catch (err) { res.status(400).json({ error: err.message }); } }); // POST /api/company/:companyId/fire — 辞退 Agent(招留管退之"退") router.post('/:companyId/fire', (req, res) => { try { // archive 参数保留在协议里(前端还在传),但已无实际作用 —— 见下方注释 const { role } = req.body; if (!role) return res.status(400).json({ error: 'role 为必填项' }); const companyId = req.params.companyId; const namespacedId = `${companyId}:${role}`; // 1. 解雇 = 把 company_agents 那一行标成 removed,同时把 company.json.agents[] 同步掉 // (companyService.fire() 两件事都做)。**不动本体、也不动公司态目录**: // 本体是全系统共享的,删了别家公司的 agent 就没了;公司态目录里是记忆, // 28 §5.6 明确「fire 只软删,记忆不随解雇删除」。 // archive 参数因此退化成无意义 —— 没有目录可搬了,原地保留就是归档。 const fired = companyService.fire(companyId, role); if (!fired) { return res.status(404).json({ error: `Agent ${role} 不存在于公司 ${companyId}` }); } // 2. 停止心跳 const heartbeatService = require('../services/heartbeat-service'); if (heartbeatService.timers.has(namespacedId)) { clearInterval(heartbeatService.timers.get(namespacedId)); heartbeatService.timers.delete(namespacedId); } // 3. P2 T7:org_graph 里对应节点软删除(级联失效它的进出边) try { orgGraphService.removeNode(companyId, role); } catch (e) { console.warn('[Fire] org_graph 清理失败(不影响辞退结果):', e.message); } console.log(`[Fire] 🔴 ${namespacedId} 已辞退`); // archived 恒为 true:公司态目录(记忆 / 会话)原地保留,本来就等于归档 res.json({ success: true, message: `${namespacedId} 已辞退`, archived: true }); } catch (err) { res.status(500).json({ error: err.message }); } }); // ═══════════════════════════════════════ // Org Graph — 公司级 workflow 图(P2 / 27-P2 §4.3) // ═══════════════════════════════════════ // POST /api/company/:companyId/org-graph — 整体写入(规划产物落库) // body: { nodes: [{roleName, systemAgentId?}], edges: [{from, to, condition, edgeKind?}] }(边的 from/to 是 roleName) router.post('/:companyId/org-graph', (req, res) => { try { const { nodes, edges } = req.body || {}; const result = orgGraphService.writeGraph(req.params.companyId, { nodes, edges }); // 校验失败返回 400 + errors[{rule, detail}],供 orggraph-planning 自行修正后重试 if (!result.ok) return res.status(400).json({ error: '图不合法', errors: result.errors }); res.json({ success: true, ...result }); } catch (err) { res.status(500).json({ error: err.message }); } }); // PATCH /api/company/:companyId/org-graph — 增量修改(改完整张图仍须合法,否则整体回滚) // body: { addNodes?, removeNodes?, addEdges?, removeEdges? } router.patch('/:companyId/org-graph', (req, res) => { try { const result = orgGraphService.patchGraph(req.params.companyId, req.body || {}); if (!result.ok) return res.status(400).json({ error: '图不合法', errors: result.errors }); res.json({ success: true }); } catch (err) { res.status(500).json({ error: err.message }); } }); // GET /api/company/:companyId/org-graph — 只读;公司无图时返回空数组,不报错 // 返回 { nodes: [{id, roleName, systemAgentId, status}], edges: [{from, to, condition, edgeKind}] }(边的 from/to 是节点 id) router.get('/:companyId/org-graph', (req, res) => { try { res.json(orgGraphService.getGraph(req.params.companyId)); } catch (err) { res.status(500).json({ error: err.message }); } }); // POST /api/company/:companyId/org-graph/replan — 对已有公司手动重跑规划(存量公司补图入口) // 异步:立即返回,agent 跑完后自己调上面的 POST 写入新图 router.post('/:companyId/org-graph/replan', (req, res) => { try { const company = companyService.get(req.params.companyId); if (!company) return res.status(404).json({ error: `公司 ${req.params.companyId} 不存在` }); try { // 幂等:已经雇过就是 already_exists,不重复建目录 companyService.hire(company.id, 'orggraph-planning', {}); } catch (e) { console.warn('[Replan] orggraph-planning 招聘跳过:', e.message); } triggerOrgGraphPlanning(company, 'Replan'); res.json({ status: 'replanning', companyId: company.id }); } catch (err) { res.status(500).json({ error: err.message }); } }); // ═══════════════════════════════════════ // 任务入口 — 用户 → 公司(T12 / 27-P3P4 §五 T12、27-E2E §三 阶段 3) // ═══════════════════════════════════════ // // 为什么必须新开一个入口:既有 POST /api/agent/task/dispatch 的 from / to 都要求是 // 已存在的 agent 目录,**只支持 agent → agent**(27-E2E §三 阶段 3 的 ❌ 缺口)。 // 用户把一句自然语言任务交给「一家公司」时没有 from agent,走不通那条路。 // 收敛侧的 POST /api/agent/task/report 则已经实现了完整回调链路,本入口复用它,见 reportRunOutcome。 /** * v1 约束:一家公司同时只有一个 active root run(27-P3P4 §一非目标)。 * 取值域与 work-graph-db 的唯一索引 idx_graph_runs_one_active_root 严格一致—— * 这里只是提前给出可读的 409,真正的守卫是那条索引(并发下靠它兜底,见下方 catch)。 */ const ACTIVE_ROOT_RUN_STATUS = ['ready', 'running', 'paused']; /** 任务级总预算默认值(27-P3P4 §四.A.3:中等任务粗估 60-100 次调用、1-2 小时) */ const DEFAULT_TASK_BUDGET = { total_agent_calls: 100, total_tokens: 5000000, wallclock_ms: 7200000, spent: { agent_calls: 0, tokens: 0 }, }; /** * 任务描述落盘到 root run 的 artifacts/(§四.A.5 的任务级共享产物区,跨 org node 只读)。 * graph_runs 上没有描述列,而 advanceOne 的任务描述是调用时透传的 opts.task; * 手动模式下每次 /next 由控制台发起,需要能把原始 task-goal 读回来。 */ function writeTaskGoalArtifact(companyId, rootRunId, payload) { const dir = path.resolve(__dirname, '../..', 'data', 'companies', companyId, 'runs', rootRunId, 'artifacts'); fs.mkdirSync(dir, { recursive: true }); const fp = path.join(dir, 'task-goal.json'); fs.writeFileSync(fp, JSON.stringify(payload, null, 2), 'utf-8'); return fp; } /** org graph 的末节点(没有 forward+success 出边)= 这次任务的实际交付者,汇报时当 from */ function terminalOrgAgent(companyId, graph) { const hasForwardOut = new Set( (graph.edges || []) .filter((e) => e.condition === 'success' && e.edgeKind !== 'backward') .map((e) => e.from) ); const nodes = graph.nodes || []; const tail = nodes.filter((n) => !hasForwardOut.has(n.id)); const pick = (tail[0] || nodes[0] || {}).systemAgentId; return pick ? `${companyId}:${pick}` : null; } /** * root run 收敛 → **复用既有回调链路** POST /api/agent/task/report(agent-chat.js:1659)。 * 那条链路已实现「更新 todo/plan → 更新 OKR 节点 → 自动推进 Pipeline → 回调唤醒上级 agent」 * 四步,这里不重造,只把 graph run 的收敛结果翻译成它的入参。 * * 只有传了 reportTo(确实有上级 agent 在等)时才走那条 HTTP:用户直接下达的任务没有上级, * 硬填一个 to 会让第 5 步「回调唤醒」白起一轮 LLM 对话。这种情况下若给了 okr_node_id, * 就直接用 OkrService 更新节点(复用同一个服务,不是另造一条链路)。 * * ponytail: 目前只挂得上 auto 模式的 driver(startRun 返回的那个 promise)。 * manual 模式的收敛发生在 graph-engine.settleRun 里,那不是本文件能改的; * 升级路径是 settleRun 在 root run 转终态时调一次本函数(一行)。 */ function reportRunOutcome(runId, ctx = {}) { try { const { getRun } = require('../services/work-graph-db'); const run = getRun(runId); if (!run) return; // completed → done;failed → failed;blocked(预算耗尽 / 熔断挂起)→ blocked。 // 用 task/report 自己的三值词表:blocked 不触发它第 5 步的「回调唤醒上级」—— // 那种情况要的是人工介入,不是再起一轮 LLM 对话。 // paused / stopped 不是收敛,不汇报。 const status = { completed: 'done', failed: 'failed', blocked: 'blocked' }[run.run_status] || null; if (!status) return; const summary = `graph run ${runId} 收敛为 ${run.run_status}:${ctx.task || ''}`.slice(0, 500); // okr_node_id 形如 O-001.1.1,第一段就是 objectiveId(同 OkrService.parseNodeId 口径) const okrNodeId = run.okr_node_id || null; const objectiveId = okrNodeId ? String(okrNodeId).split('.')[0] : null; if (ctx.reportTo) { const PORT = require('../config').port || 3000; fetch(`http://127.0.0.1:${PORT}/api/agent/task/report`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ taskId: run.task_id, from: ctx.reporterAgent || ctx.reportTo, to: ctx.reportTo, status, summary, objectiveId, nodeId: okrNodeId, }), }) // fetch 不因 4xx/5xx reject,不显式看 res.ok 会把地址写错这类问题完全静默吞掉 .then((r) => { if (!r.ok) console.error(`[TaskEntry] task/report 汇报失败: HTTP ${r.status} run=${runId}`); }) .catch((e) => console.error(`[TaskEntry] task/report 汇报失败(网络): ${e.message}`)); return; } if (objectiveId && okrNodeId) { const { OkrService } = require('../services/okr-service'); new OkrService(run.company_id).updateNode(objectiveId, okrNodeId, { status, summary }); console.log(`[TaskEntry] 🎯 ${run.company_id} OKR ${okrNodeId} → ${status}`); } } catch (err) { console.error(`[TaskEntry] 收敛汇报异常 run=${runId}: ${err.message}`); } } /** * POST /api/company/:companyId/tasks — 用户给公司下达一个任务 * * body: { * task | description, 必填,一句自然语言任务(= task-goal,27-NAMING §四) * goal_id?, okr_node_id?, 三者基数不同不可混用(goal 1 : task N : okr 跨 goal 度量树) * mode?: 'manual' | 'auto', 默认 manual;auto 前端必须先弹预估成本确认(§四.B.4) * budget?, 覆盖 DEFAULT_TASK_BUDGET,只挂 root run * report_to? 收敛后要唤醒的上级 agent(Master/CEO 代下单时传) * } * * 动作:建 root graph_run(graph_kind='org'、graph_id=company_id、root_run_id=自己) * → markRunReady → startRun 点亮入口节点 → **同步返回 run id,不等图跑完**。 * task_id 由本入口生成(沿用 task/dispatch 的 `T-{base36}` 格式,保持任务号一致)。 */ router.post('/:companyId/tasks', (req, res) => { const companyId = req.params.companyId; try { const body = req.body || {}; const task = String(body.task || body.description || '').trim(); if (!task) return res.status(400).json({ error: 'task(任务描述)为必填项' }); const company = companyService.get(companyId); if (!company) return res.status(404).json({ error: `公司 ${companyId} 不存在` }); const mode = body.mode || body.execution_mode || 'manual'; if (mode !== 'manual' && mode !== 'auto') { return res.status(400).json({ error: `mode 只能是 'manual' 或 'auto',收到: ${mode}` }); } // 没有 org graph 就没有可执行的图(graph_kind='org' 的定义层就是它) const graph = orgGraphService.getGraph(companyId); if (!graph.nodes || graph.nodes.length === 0) { return res.status(409).json({ error: `公司 ${companyId} 还没有 org graph,无法接任务`, hint: `先调 POST /api/company/${companyId}/org-graph/replan 让 orggraph-planning 规划组织结构`, }); } const { createRun, listCompanyRuns, getRun } = require('../services/work-graph-db'); const engine = require('../services/graph-engine'); // v1 并发约束:已有 active root run 时**拒绝**(不排队)。 // 选拒绝的理由:仓库里没有任何出队/唤醒的 worker,排进去的任务不会被自动消费, // 那是一条会静默丢任务的路;而唯一索引本身就是拒绝语义,两边保持一致。 const active = listCompanyRuns(companyId, { rootOnly: true }) .filter((r) => ACTIVE_ROOT_RUN_STATUS.includes(r.run_status)); if (active.length > 0) { return res.status(409).json({ error: `公司 ${companyId} 已有进行中的任务(v1 约束:一家公司同时只有一个 active root run)`, activeRunId: active[0].id, activeTaskId: active[0].task_id, activeRunStatus: active[0].run_status, hint: `先 POST /api/graph-runs/${active[0].id}/stop 停止它,或等它收敛`, }); } const taskId = `T-${Date.now().toString(36)}`; let created; try { created = createRun({ graphKind: 'org', graphId: companyId, // kind='org' 时 graph_id 就是 company_id companyId, goalId: body.goal_id || null, taskId, okrNodeId: body.okr_node_id || null, executionMode: mode, budget: body.budget || DEFAULT_TASK_BUDGET, }); } catch (e) { // 并发下唯一索引才是真正的守卫(上面的预检查会有竞态窗口) if (/UNIQUE|constraint/i.test(e.message)) { return res.status(409).json({ error: `公司 ${companyId} 已有进行中的任务(唯一索引拦截)` }); } throw e; } const runId = created.id; const artifact = writeTaskGoalArtifact(companyId, runId, { runId, companyId, taskId, goalId: body.goal_id || null, okrNodeId: body.okr_node_id || null, task, createdAt: new Date().toISOString(), }); engine.markRunReady(runId); // startRun:manual 只标 running + 点亮入口节点,停在原地等 /next; // auto 挂一个自我调度的 driver,同样立即返回(一次跑到底是分钟~小时级)。 const { driver } = engine.startRun(runId, { mode, task }); if (driver) { const ctx = { task, reportTo: body.report_to || null, reporterAgent: terminalOrgAgent(companyId, graph) }; driver.then(() => reportRunOutcome(runId, ctx)).catch((e) => console.error(`[TaskEntry] auto driver 异常: ${e.message}`)); } const run = getRun(runId) || {}; res.status(201).json({ success: true, runId, taskId, companyId, goalId: run.goal_id, okrNodeId: run.okr_node_id, executionMode: mode, runStatus: run.run_status, task, taskGoalArtifact: path.relative(path.resolve(__dirname, '../..'), artifact), budget: body.budget || DEFAULT_TASK_BUDGET, links: { state: `/api/graph-runs/${runId}`, next: `/api/graph-runs/${runId}/next`, stream: `/api/graph-runs/${runId}/stream`, }, }); } catch (err) { console.error('[TaskEntry] Error:', err.message); res.status(500).json({ error: err.message }); } }); // POST /api/company/:companyId/deposit-template — 训练合格后沉淀为模板 router.post('/:companyId/deposit-template', (req, res) => { try { const { agentId, benchmarkScore } = req.body; if (!agentId || benchmarkScore === undefined) { return res.status(400).json({ error: 'agentId 和 benchmarkScore 为必填项' }); } const result = companyService.depositTemplate( req.params.companyId, agentId, Number(benchmarkScore) ); res.json(result); } catch (err) { res.status(400).json({ error: err.message }); } }); // GET /api/company/:companyId/agents router.get('/:companyId/agents', (req, res) => { const agents = companyService.listAgents(req.params.companyId); res.json(agents); }); // ═══════════════════════════════════════ // Company Goals / OKR (公司级 OKR) // ═══════════════════════════════════════ const { OkrService } = require('../services/okr-service'); // 公司级 OKR 路由。每条同时挂 /okr(新,27-NAMING §六)与 /goals(旧,向后兼容别名)。 // 路径参数统一叫 :objectiveId(原 :goalId)——第 1 层节点已正名为 Objective。 const okrPath = (suffix = '') => [ `/:companyId/okr${suffix}`, `/:companyId/goals${suffix}`, ]; // GET /api/company/:companyId/okr router.get(okrPath(), (req, res) => { const okr = new OkrService(req.params.companyId); res.json(okr.list()); }); // POST /api/company/:companyId/okr router.post(okrPath(), (req, res) => { try { const { title, description, owner } = req.body; if (!title || !owner) { return res.status(400).json({ error: 'title 和 owner 为必填项' }); } const okr = new OkrService(req.params.companyId); res.status(201).json(okr.create({ title, description, owner })); } catch (err) { res.status(500).json({ error: err.message }); } }); // GET /api/company/:companyId/okr/:objectiveId router.get(okrPath('/:objectiveId'), (req, res) => { const okr = new OkrService(req.params.companyId); const objective = okr.get(req.params.objectiveId); if (!objective) return res.status(404).json({ error: 'Objective not found' }); res.json(objective); }); // PUT /api/company/:companyId/okr/:objectiveId router.put(okrPath('/:objectiveId'), (req, res) => { const okr = new OkrService(req.params.companyId); const result = okr.updateObjective(req.params.objectiveId, req.body); if (!result) return res.status(404).json({ error: 'Objective not found' }); res.json(result); }); // POST /api/company/:companyId/okr/:objectiveId/nodes router.post(okrPath('/:objectiveId/nodes'), (req, res) => { try { const { parentNodeId, title, owner, priority } = req.body; if (!title || !owner) { return res.status(400).json({ error: 'title 和 owner 为必填项' }); } const okr = new OkrService(req.params.companyId); const result = okr.addNode(req.params.objectiveId, parentNodeId || null, { title, owner, priority, }); if (!result) return res.status(404).json({ error: 'Objective not found' }); res.status(201).json({ nodeId: result.nodeId, node: result.objective.nodes[result.nodeId] }); } catch (err) { res.status(500).json({ error: err.message }); } }); // PUT /api/company/:companyId/okr/:objectiveId/nodes/:nodeId router.put(okrPath('/:objectiveId/nodes/:nodeId'), (req, res) => { const okr = new OkrService(req.params.companyId); const result = okr.updateNode(req.params.objectiveId, req.params.nodeId, req.body); if (!result) return res.status(404).json({ error: 'Node not found' }); res.json({ nodeId: result.nodeId, node: result.node }); }); // GET /api/company/:companyId/okr/:objectiveId/progress router.get(okrPath('/:objectiveId/progress'), (req, res) => { const okr = new OkrService(req.params.companyId); const progress = okr.getProgress(req.params.objectiveId); if (!progress) return res.status(404).json({ error: 'Objective not found' }); res.json(progress); }); // ═══════════════════════════════════════ // Pipeline / SOP 流水线 // ═══════════════════════════════════════ const { PipelineService } = require('../services/pipeline-service'); // POST /api/company/:companyId/pipelines — 创建流水线 router.post('/:companyId/pipelines', (req, res) => { try { const pSvc = new PipelineService(req.params.companyId); const pipeline = pSvc.createPipeline(req.body); res.status(201).json(pipeline); } catch (err) { res.status(400).json({ error: err.message }); } }); // GET /api/company/:companyId/pipelines — 列出流水线 router.get('/:companyId/pipelines', (req, res) => { const pSvc = new PipelineService(req.params.companyId); res.json(pSvc.listPipelines()); }); // GET /api/company/:companyId/pipelines/:pipelineId — 获取流水线定义 router.get('/:companyId/pipelines/:pipelineId', (req, res) => { const pSvc = new PipelineService(req.params.companyId); const pipeline = pSvc.getPipeline(req.params.pipelineId); if (!pipeline) return res.status(404).json({ error: '流水线不存在' }); res.json(pipeline); }); // PUT /api/company/:companyId/pipelines/:pipelineId — 更新流水线 router.put('/:companyId/pipelines/:pipelineId', (req, res) => { const pSvc = new PipelineService(req.params.companyId); const pipeline = pSvc.updatePipeline(req.params.pipelineId, req.body); if (!pipeline) return res.status(404).json({ error: '流水线不存在' }); res.json(pipeline); }); // POST /api/company/:companyId/pipelines/:pipelineId/run — 启动运行 router.post('/:companyId/pipelines/:pipelineId/run', (req, res) => { try { const pSvc = new PipelineService(req.params.companyId); const run = pSvc.startRun(req.params.pipelineId, req.body); res.status(201).json(run); } catch (err) { res.status(400).json({ error: err.message }); } }); // GET /api/company/:companyId/pipelines/runs/list — 列出运行记录 router.get('/:companyId/pipelines/runs/list', (req, res) => { const pSvc = new PipelineService(req.params.companyId); res.json(pSvc.listRuns(req.query.pipelineId)); }); // GET /api/company/:companyId/pipelines/runs/:runId — 获取运行详情 router.get('/:companyId/pipelines/runs/:runId', (req, res) => { const pSvc = new PipelineService(req.params.companyId); const run = pSvc.getRun(req.params.runId); if (!run) return res.status(404).json({ error: '运行记录不存在' }); res.json(run); }); // POST /api/company/:companyId/pipelines/runs/:runId/advance — 推进步骤 router.post('/:companyId/pipelines/runs/:runId/advance', (req, res) => { try { const pSvc = new PipelineService(req.params.companyId); const { stepIndex, output, status } = req.body; if (stepIndex === undefined) return res.status(400).json({ error: 'stepIndex 为必填项' }); const run = pSvc.advanceStep(req.params.runId, Number(stepIndex), { output, status }); res.json(run); } catch (err) { res.status(400).json({ error: err.message }); } }); // GET /api/company/:companyId/pipelines/runs/:runId/current — 获取当前任务 router.get('/:companyId/pipelines/runs/:runId/current', (req, res) => { const pSvc = new PipelineService(req.params.companyId); const task = pSvc.getCurrentTask(req.params.runId); if (!task) return res.json({ message: '无就绪任务' }); res.json(task); }); // POST /api/company/:companyId/pipelines/runs/:runId/reject — COO 驳回步骤 router.post('/:companyId/pipelines/runs/:runId/reject', (req, res) => { try { const pSvc = new PipelineService(req.params.companyId); const { stepIndex, reason, reviewedBy } = req.body; if (stepIndex === undefined) return res.status(400).json({ error: 'stepIndex 为必填项' }); const run = pSvc.rejectStep(req.params.runId, Number(stepIndex), { reason: reason || '', reviewedBy: reviewedBy || 'coo', }); res.json(run); } catch (err) { res.status(400).json({ error: err.message }); } }); // Schedule (日程管理) // ═══════════════════════════════════════ // GET /api/company/:companyId/schedule — 获取日程 router.get('/:companyId/schedule', (req, res) => { res.json(scheduleService.getSchedule(req.params.companyId)); }); // POST /api/company/:companyId/schedule/slots — 添加日程 router.post('/:companyId/schedule/slots', (req, res) => { try { const slot = scheduleService.addSlot(req.params.companyId, req.body); res.json(slot); } catch (err) { res.status(400).json({ error: err.message }); } }); // PUT /api/company/:companyId/schedule/slots/:slotId — 更新日程 router.put('/:companyId/schedule/slots/:slotId', (req, res) => { const slot = scheduleService.updateSlot(req.params.companyId, req.params.slotId, req.body); if (!slot) return res.status(404).json({ error: '日程不存在' }); res.json(slot); }); // DELETE /api/company/:companyId/schedule/slots/:slotId — 删除日程 router.delete('/:companyId/schedule/slots/:slotId', (req, res) => { scheduleService.removeSlot(req.params.companyId, req.params.slotId); res.json({ success: true }); }); // ═══════════════════════════════════════ // DataSource (运营数据接入) // ═══════════════════════════════════════ const { DataSourceService } = require('../services/datasource-service'); const dataSourceService = new DataSourceService(); // GET /api/company/:companyId/data/metrics — 获取运营指标 router.get('/:companyId/data/metrics', async (req, res) => { try { const { source = 'mock', startDate, endDate } = req.query; const sd = startDate || new Date(Date.now() - 7 * 86400000).toISOString().slice(0, 10); const ed = endDate || new Date().toISOString().slice(0, 10); const data = await dataSourceService.fetchMetrics(source, { startDate: sd, endDate: ed }); res.json(data); } catch (err) { res.status(400).json({ error: err.message }); } }); // GET /api/company/:companyId/data/trending — 获取热点趋势 router.get('/:companyId/data/trending', async (req, res) => { try { const { source = 'mock', platform = 'weibo', count = '10' } = req.query; const data = await dataSourceService.fetchTrending(source, { platform, count: parseInt(count, 10) }); res.json(data); } catch (err) { res.status(400).json({ error: err.message }); } }); // GET /api/company/:companyId/data/attribution — 归因分析 router.get('/:companyId/data/attribution', async (req, res) => { try { const { source = 'mock' } = req.query; const now = new Date(); const currentEnd = now.toISOString().slice(0, 10); const currentStart = new Date(now.getTime() - 7 * 86400000).toISOString().slice(0, 10); const previousEnd = new Date(now.getTime() - 7 * 86400000).toISOString().slice(0, 10); const previousStart = new Date(now.getTime() - 14 * 86400000).toISOString().slice(0, 10); const analysis = await dataSourceService.analyzeAttribution( source, { startDate: currentStart, endDate: currentEnd }, { startDate: previousStart, endDate: previousEnd } ); res.json(analysis); } catch (err) { res.status(400).json({ error: err.message }); } }); // ═══════════════════════════════════════ // Company Files — 公司级统一文件树 // ═══════════════════════════════════════ // GET /api/company/:companyId/files — 获取公司下所有 Agent 的文件树 router.get('/:companyId/files', (req, res) => { try { const path = require('path'); const fs = require('fs'); const DATA_DIR = path.resolve(__dirname, '../../data/companies'); const companyDir = path.join(DATA_DIR, req.params.companyId); if (!fs.existsSync(companyDir)) { return res.json({ tree: [], totalFiles: 0 }); } // 递归扫描目录 function scanDir(dirPath, relativePath = '') { const entries = fs.readdirSync(dirPath, { withFileTypes: true }); const nodes = []; for (const entry of entries) { const fullPath = path.join(dirPath, entry.name); const relPath = relativePath ? `${relativePath}/${entry.name}` : entry.name; // 跳过隐藏文件和 node_modules if (entry.name.startsWith('.') || entry.name === 'node_modules') continue; // 跳过 conversations 目录(太大) if (entry.name === 'conversations') continue; if (entry.isDirectory()) { const children = scanDir(fullPath, relPath); nodes.push({ name: entry.name, path: relPath, type: 'directory', children, fileCount: children.reduce((sum, c) => sum + (c.type === 'file' ? 1 : (c.fileCount || 0)), 0), }); } else { const stat = fs.statSync(fullPath); nodes.push({ name: entry.name, path: relPath, type: 'file', size: stat.size, modifiedAt: stat.mtime.toISOString(), }); } } // 目录排前面, 然后按名字排序 return nodes.sort((a, b) => { if (a.type !== b.type) return a.type === 'directory' ? -1 : 1; return a.name.localeCompare(b.name); }); } const tree = scanDir(companyDir); const countFiles = (nodes) => nodes.reduce((sum, n) => sum + (n.type === 'file' ? 1 : countFiles(n.children || [])), 0); res.json({ tree, totalFiles: countFiles(tree) }); } catch (err) { res.status(500).json({ error: err.message }); } }); // GET /api/company/:companyId/files/content?path=xxx — 读取文件内容 router.get('/:companyId/files/content', (req, res) => { try { const path = require('path'); const fs = require('fs'); const DATA_DIR = path.resolve(__dirname, '../../data/companies'); const filePath = req.query.path; if (!filePath) return res.status(400).json({ error: 'path 参数为必填' }); // 安全校验:防止路径穿越 const absPath = resolveInside(path.join(DATA_DIR, req.params.companyId), filePath); if (!absPath) { return res.status(403).json({ error: '路径越界' }); } if (!fs.existsSync(absPath)) { return res.status(404).json({ error: '文件不存在' }); } const stat = fs.statSync(absPath); if (stat.size > 512 * 1024) { return res.json({ content: '(文件过大,跳过预览)', truncated: true, size: stat.size }); } const content = fs.readFileSync(absPath, 'utf-8'); res.json({ content, size: stat.size, path: filePath }); } catch (err) { res.status(500).json({ error: err.message }); } }); // PUT /api/company/:companyId/files/content — 保存文件内容 router.put('/:companyId/files/content', (req, res) => { try { const DATA_DIR = path.resolve(__dirname, '../../data/companies'); const filePath = req.body.path; if (!filePath) return res.status(400).json({ error: 'path 参数为必填' }); const absPath = resolveInside(path.join(DATA_DIR, req.params.companyId), filePath); if (!absPath) { return res.status(403).json({ error: '路径越界' }); } const dir = path.dirname(absPath); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(absPath, req.body.content || '', 'utf-8'); console.log(`[Company] 💾 已保存文件 ${req.params.companyId}/${filePath}`); res.json({ success: true, path: filePath }); } catch (err) { res.status(500).json({ error: err.message }); } }); // ═══════════════════════════════════════ // Company Workspace — 工作区文件管理 // ═══════════════════════════════════════ // GET /api/company/:companyId/workspace/files — 列出 workspace 文件树 router.get('/:companyId/workspace/files', (req, res) => { try { const DATA_DIR = path.resolve(__dirname, '../../data/companies'); const wsDir = path.join(DATA_DIR, req.params.companyId, 'workspace'); if (!fs.existsSync(wsDir)) { return res.json({ tree: [], totalFiles: 0 }); } function scanDir(dirPath, relativePath = '') { const entries = fs.readdirSync(dirPath, { withFileTypes: true }); const nodes = []; for (const entry of entries) { const fullPath = path.join(dirPath, entry.name); const relPath = relativePath ? `${relativePath}/${entry.name}` : entry.name; if (entry.name.startsWith('.') || entry.name === 'node_modules') continue; if (entry.isDirectory()) { const children = scanDir(fullPath, relPath); nodes.push({ name: entry.name, path: relPath, type: 'directory', children, fileCount: children.reduce((sum, c) => sum + (c.type === 'file' ? 1 : (c.fileCount || 0)), 0), }); } else { const stat = fs.statSync(fullPath); nodes.push({ name: entry.name, path: relPath, type: 'file', size: stat.size, createdAt: stat.birthtime.toISOString(), modifiedAt: stat.mtime.toISOString(), }); } } return nodes.sort((a, b) => { if (a.type !== b.type) return a.type === 'directory' ? -1 : 1; return a.name.localeCompare(b.name); }); } const tree = scanDir(wsDir); const countFiles = (nodes) => nodes.reduce((sum, n) => sum + (n.type === 'file' ? 1 : countFiles(n.children || [])), 0); res.json({ tree, totalFiles: countFiles(tree) }); } catch (err) { res.status(500).json({ error: err.message }); } }); // GET /api/company/:companyId/workspace/files/content?path=xxx — 读取 workspace 文件内容 router.get('/:companyId/workspace/files/content', (req, res) => { try { const DATA_DIR = path.resolve(__dirname, '../../data/companies'); const filePath = String(req.query.path || ''); if (!filePath) return res.status(400).json({ error: 'path 参数为必填' }); const absPath = resolveInside(path.join(DATA_DIR, req.params.companyId, 'workspace'), filePath); if (!absPath) { return res.status(403).json({ error: '路径越界' }); } if (!fs.existsSync(absPath)) { return res.status(404).json({ error: '文件不存在' }); } const stat = fs.statSync(absPath); if (stat.size > 512 * 1024) { return res.json({ content: '(文件过大,跳过预览)', truncated: true, size: stat.size }); } const content = fs.readFileSync(absPath, 'utf-8'); res.json({ content, size: stat.size, path: filePath, createdAt: stat.birthtime.toISOString(), modifiedAt: stat.mtime.toISOString(), }); } catch (err) { res.status(500).json({ error: err.message }); } }); // PUT /api/company/:companyId/workspace/files/content — 保存 workspace 文件 router.put('/:companyId/workspace/files/content', (req, res) => { try { const DATA_DIR = path.resolve(__dirname, '../../data/companies'); const filePath = req.body.path; if (!filePath) return res.status(400).json({ error: 'path 参数为必填' }); const absPath = resolveInside(path.join(DATA_DIR, req.params.companyId, 'workspace'), filePath); if (!absPath) { return res.status(403).json({ error: '路径越界' }); } const dir = path.dirname(absPath); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(absPath, req.body.content || '', 'utf-8'); console.log(`[Workspace] 💾 已保存 ${req.params.companyId}/workspace/${filePath}`); res.json({ success: true, path: filePath }); } catch (err) { res.status(500).json({ error: err.message }); } }); // ═══════════════════════════════════════ // Company Messages — 全局消息时间线 // ═══════════════════════════════════════ // GET /api/company/:companyId/messages?limit=100 — 获取公司所有 Agent 的消息时间线 router.get('/:companyId/messages', (req, res) => { try { const path = require('path'); const fs = require('fs'); const limit = Math.min(parseInt(req.query.limit) || 200, 500); const allMessages = []; // 名册查 company_agents,会话在公司态目录 system/agents/{aid}/{cid}/conversations/(28 批次 4-19) for (const row of listCompanyAgents(req.params.companyId)) { const agentId = row.role_name; const convDir = path.join(locator.companyDir(row.system_agent_id, req.params.companyId), 'conversations'); if (!fs.existsSync(convDir)) continue; const conversations = fs.readdirSync(convDir, { withFileTypes: true }).filter(d => d.isDirectory()); for (const conv of conversations) { const convId = conv.name; // 读 meta let meta = null; try { meta = JSON.parse(fs.readFileSync(path.join(convDir, convId, 'meta.json'), 'utf-8')); } catch { continue; } // 读 history let history = []; try { history = JSON.parse(fs.readFileSync(path.join(convDir, convId, 'history.json'), 'utf-8')); } catch { continue; } for (const msg of history) { allMessages.push({ agentId: `${req.params.companyId}:${agentId}`, agentName: agentId, conversationId: convId, conversationTitle: meta?.title || convId, role: msg.role, content: (msg.content || '').slice(0, 800), timestamp: msg.timestamp || meta?.createdAt, messageId: msg.messageId, taskId: convId.startsWith('task-') ? convId.replace('task-', '') : null, }); } } } // 按时间倒序 allMessages.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); res.json(allMessages.slice(0, limit)); } catch (err) { res.status(500).json({ error: err.message }); } }); // ═══════════════════════════════════════ // Company Stats — 统计 API (skills count, jobs status) // ═══════════════════════════════════════ // GET /api/company/:companyId/stats — 获取公司统计信息 router.get('/:companyId/stats', (req, res) => { try { const path = require('path'); const fs = require('fs'); const DATA_DIR = path.resolve(__dirname, '../../data'); const companyId = req.params.companyId; // 1. Agents 统计(名册查 company_agents) const agents = companyService.listAgents(companyId); const agentCount = agents.length; // 2. Skills 统计 —— Skill 挂在本体的 `.claude/skills/` 下(28 批次 4-19)。 // 注意这份计数因此是**本体级**的:两家公司雇同一个本体,各自的统计里都算它一次。 let totalSkills = 0; const agentSkills = []; for (const agent of agents) { const skillsDir = path.join(locator.bodyDir(agent.bodyId), '.claude', 'skills'); if (!fs.existsSync(skillsDir)) continue; const skillCount = fs.readdirSync(skillsDir, { withFileTypes: true }).filter(d => d.isDirectory()).length; totalSkills += skillCount; agentSkills.push({ agentId: agent.id, skillCount }); } // 3. Jobs 统计 - 从 jobs 持久化文件读取 let jobsStats = { total: 0, queued: 0, running: 0, completed: 0, failed: 0 }; const jobsFilePath = path.join(DATA_DIR, '.data', 'jobs-openapi.json'); if (fs.existsSync(jobsFilePath)) { try { const jobsData = JSON.parse(fs.readFileSync(jobsFilePath, 'utf-8')); if (Array.isArray(jobsData)) { jobsStats.total = jobsData.length; for (const job of jobsData) { if (jobsStats[job.status] !== undefined) { jobsStats[job.status]++; } } } } catch (e) { console.warn('[Stats] 读取 Jobs 文件失败:', e.message); } } // 4. Pipelines 统计 const pSvc = new PipelineService(companyId); const pipelines = pSvc.listPipelines(); const runs = pSvc.listRuns(); const pipelinesStats = { total: pipelines.length, active: pipelines.filter(p => p.status === 'active').length, runs: { total: runs.length, running: runs.filter(r => r.status === 'running').length, completed: runs.filter(r => r.status === 'completed').length, failed: runs.filter(r => r.status === 'failed').length, } }; res.json({ companyId, timestamp: new Date().toISOString(), skills: { total: totalSkills, byAgent: agentSkills }, agents: { total: agentCount }, jobs: jobsStats, pipelines: pipelinesStats, }); } catch (err) { res.status(500).json({ error: err.message }); } }); // GET /api/company/:companyId/jobs — 获取公司相关的 Job 列表 router.get('/:companyId/jobs', (req, res) => { try { const path = require('path'); const fs = require('fs'); const DATA_DIR = path.resolve(__dirname, '../../data'); const jobsFilePath = path.join(DATA_DIR, '.data', 'jobs-openapi.json'); if (!fs.existsSync(jobsFilePath)) { return res.json([]); } const jobsData = JSON.parse(fs.readFileSync(jobsFilePath, 'utf-8')); if (!Array.isArray(jobsData)) { return res.json([]); } // 过滤与公司相关的 Jobs (通过 domain 或 conversationId 包含 companyId) const limit = Math.min(parseInt(req.query.limit) || 50, 100); const companyId = req.params.companyId; const filtered = jobsData .filter(job => !job.domain || job.domain.includes(companyId)) .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) .slice(0, limit); res.json(filtered); } catch (err) { res.status(500).json({ error: err.message }); } }); // GET /api/company/:companyId/jobs/:jobId — 获取 Job 详情 router.get('/:companyId/jobs/:jobId', (req, res) => { try { const path = require('path'); const fs = require('fs'); const DATA_DIR = path.resolve(__dirname, '../../data'); const jobsFilePath = path.join(DATA_DIR, '.data', 'jobs-openapi.json'); if (!fs.existsSync(jobsFilePath)) { return res.status(404).json({ error: 'Job 不存在' }); } const jobsData = JSON.parse(fs.readFileSync(jobsFilePath, 'utf-8')); if (!Array.isArray(jobsData)) { return res.status(404).json({ error: 'Job 不存在' }); } const job = jobsData.find(j => j.jobId === req.params.jobId); if (!job) { return res.status(404).json({ error: 'Job 不存在' }); } // 返回安全版本 (隐藏 apiKey, webhookUrl) const { apiKey, webhookUrl, ...safeJob } = job; res.json(safeJob); } catch (err) { res.status(500).json({ error: err.message }); } }); module.exports = router; // T12 收敛汇报桥(复用 POST /api/agent/task/report)。挂在 router 上而不是单开文件, // 供 graph-engine.settleRun 在 root run 转终态时一行调用(manual 模式的收敛钩子,见函数注释)。 module.exports.reportRunOutcome = reportRunOutcome;