Commit d34176796a7f7148ee025590f4b72614b0942174

Authored by yibo
1 parent 62ce384c

fix: 修复前端路由遮蔽、Org Graph 空边误画、OKR 目录遗漏;修好 test-company-e2e

■ 前端路由遮蔽(既有 bug,被 P2 的 T8 暴露)
web/src/router/index.js: CompanyAgentChat 的 path 是 /:id/agents/:agentId? ——
agentId 可选,导致它抢占 /:id/agents,而导航栏「Team」正是指向这个地址。
结果 CompanyDashboard 的团队 tab(连同 P2 新增的 Org Graph 区块)完全不可达,
点 Team 打开的一直是 AgentChat。改为 :agentId 必填。

■ Org Graph 空边时凭空画出流程链(P2 T8 的 bug)
0 条 forward 边时所有节点入度都是 0,全部落进拓扑序且 degraded=false,
模板照常在节点间渲染 → 箭头,凭空捏造出一条并不存在的流转顺序。
P0 迁移进来的存量公司(有节点无边)全部中招。新增 orgGraphNoEdges 判定:
这种情况只平铺、不画箭头,并明确提示「尚未定义流转顺序」。

■ create() 仍在建 goals/ 目录(我在 OKR 正名 74ea1ff 里的遗漏)
company-service.js 的 subdirs 列表没跟着 goals→okr 一起改,新建公司仍然产出
goals/ 空目录。改为 okr/。

■ test-company-e2e.js 从「第 4 个断言就崩」修到 114/114 全绿
该脚本长期整体失效,HEAD 版本同样全断言失败。四类问题:
1. 路径过期:data/{id}/ → data/companies/{id}/;data/templates/ →
   system/templates/;data/agents/master → system/agents/master
2. Phase 4 用 ceo 模板测「骨架是否完整复制」——但 system/templates/ceo/ 只有
   plan.md + todo.md,没有 agent.md/soul.md/config.json,测不出东西,且缺
   config.json 就没有 type='cxo',company.ceo 也标不上。改用同为 cxo 且文件
   完整的 coo。(ceo 模板本身的残缺是另一个待补的问题)
3. 断言停留在 P1 之前:P1 虚拟引用(226489d)之后 agent.md 不再物理拷贝,
   改为 config.json.systemAgentRef + company-context.md。按新模型改写
4. 断言停留在 T5b 之前:模板不存在时返回 hired_from_base 而非 training

顺带记录一个设计缺口(未改,仅在测试注释里说明):system/templates/base/
目前只有 agent.md + config.json,所以 base 继承出的骨架不含 soul.md /
identity.md / tools.md / todo.md,也不含 AgentScaffold 的标准子目录结构
(memory/short-term、knowledge、workspace 等)。这些依赖 agent-creator 填充。

另删除 OKR 迁移的 *.bak-goals 备份目录及其 .gitignore 规则。

验证:npm run test:unit 676 全绿;test-company-e2e 114/114;web:build 通过;
Chrome 实机确认 Team tab 可达、空边提示正确。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
.gitignore
... ... @@ -177,5 +177,3 @@ __pycache__/
177 177 # Environment (secrets)
178 178 .env
179 179  
180   -# OKR 正名迁移的备份目录(scripts/migrate-goals-to-okr.js)
181   -*.bak-goals/
... ...
server/scripts/test-company-e2e.js
... ... @@ -23,8 +23,14 @@ const { PipelineService } = require(&#39;../services/pipeline-service&#39;);
23 23 const { CompanyHeartbeatService } = require('../services/company-heartbeat-service');
24 24  
25 25 const DATA_DIR = path.resolve(__dirname, '../../data');
  26 +// 公司数据在 data/companies/{id}/ 下(company-service.js:18),不是 data/{id}/。
  27 +// 本脚本长期按后者找,导致所有断言全失败——这是 data/companies/ 那次目录调整时漏改的。
  28 +const COMPANIES_DIR = path.join(DATA_DIR, 'companies');
  29 +// 模板库在 system/templates/(company-service.js:18),不是 data/templates/。
  30 +// 与 COMPANIES_DIR 同属那次目录调整时漏改的路径。
  31 +const TEMPLATES_DIR = path.resolve(__dirname, '../../system/templates');
26 32 const TEST_COMPANY_ID = 'test-media-co';
27   -const TEST_COMPANY_DIR = path.join(DATA_DIR, TEST_COMPANY_ID);
  33 +const TEST_COMPANY_DIR = path.join(COMPANIES_DIR, TEST_COMPANY_ID);
28 34  
29 35 let passed = 0;
30 36 let failed = 0;
... ... @@ -90,22 +96,34 @@ async function main() {
90 96 const list = svc.list();
91 97 assert(list.some(c => c.id === TEST_COMPANY_ID), 'list() 中包含新公司');
92 98  
93   - // ─── 4. 招聘 CEO (从模板) ───
94   - console.log('\n👔 Phase 4: 招聘 CEO (模板命中)');
95   - const hireCeo = svc.hire(TEST_COMPANY_ID, 'ceo');
  99 + // ─── 4. 招聘 CXO (从模板) ───
  100 + // ⚠️ 这里刻意用 coo 而不是 ceo:system/templates/ceo/ 只有 plan.md + todo.md,
  101 + // 没有 agent.md / soul.md / config.json,拿它测"模板骨架是否被完整复制"测不出东西
  102 + // (而且缺 config.json 就没有 type='cxo',company.ceo 也标不上)。
  103 + // coo 是同为 type='cxo' 且文件完整的模板。ceo 模板本身的残缺是另一个待补的问题。
  104 + const CXO_ROLE = 'coo';
  105 + console.log(`\n👔 Phase 4: 招聘 ${CXO_ROLE} (模板命中)`);
  106 + const hireCeo = svc.hire(TEST_COMPANY_ID, CXO_ROLE);
96 107 assert(hireCeo.status === 'hired_from_template', '从模板招聘成功');
97 108  
98   - const ceoDir = path.join(TEST_COMPANY_DIR, 'agents', 'ceo');
99   - assert(fs.existsSync(ceoDir), 'CEO Agent 目录已创建');
100   - assert(fs.existsSync(path.join(ceoDir, 'agent.md')), 'agent.md 已复制');
  109 + const ceoDir = path.join(TEST_COMPANY_DIR, 'agents', CXO_ROLE);
  110 + assert(fs.existsSync(ceoDir), 'CXO Agent 目录已创建');
101 111 assert(fs.existsSync(path.join(ceoDir, 'soul.md')), 'soul.md 已复制');
102 112 assert(fs.existsSync(path.join(ceoDir, 'config.json')), 'config.json 已复制');
103 113 assert(fs.existsSync(path.join(ceoDir, 'memory', 'memory.md')), 'memory/memory.md 已初始化');
104 114  
105   - // 检查上下文注入
106   - const ceoAgentMd = fs.readFileSync(path.join(ceoDir, 'agent.md'), 'utf-8');
107   - assert(ceoAgentMd.includes(TEST_COMPANY_ID), 'agent.md 注入了公司 ID');
108   - assert(ceoAgentMd.includes('测试自媒体公司'), 'agent.md 注入了公司名称');
  115 + // ⚠️ P1 虚拟引用寻址(commit 226489d)之后,agent.md 不再物理拷贝到私有目录,
  116 + // 改为 config.json 里的 systemAgentRef 指向共享模板,公司上下文单独落 company-context.md。
  117 + // 本段断言在 P1 之前是"agent.md 已复制 + 注入了公司 ID/名称",已按新模型改写。
  118 + assert(!fs.existsSync(path.join(ceoDir, 'agent.md')), 'agent.md 不再物理拷贝(P1 引用模式)');
  119 + const ceoCfgRef = JSON.parse(fs.readFileSync(path.join(ceoDir, 'config.json'), 'utf-8'));
  120 + assert(ceoCfgRef.systemAgentRef === CXO_ROLE, `config.json 记录了 systemAgentRef=${CXO_ROLE}`);
  121 +
  122 + const ceoCtxPath = path.join(ceoDir, 'company-context.md');
  123 + assert(fs.existsSync(ceoCtxPath), 'company-context.md 已生成');
  124 + const ceoCtx = fs.readFileSync(ceoCtxPath, 'utf-8');
  125 + assert(ceoCtx.includes(TEST_COMPANY_ID), 'company-context.md 注入了公司 ID');
  126 + assert(ceoCtx.includes('测试自媒体公司'), 'company-context.md 注入了公司名称');
109 127  
110 128 const ceoConfig = JSON.parse(fs.readFileSync(path.join(ceoDir, 'config.json'), 'utf-8'));
111 129 assert(ceoConfig.companyId === TEST_COMPANY_ID, 'config.json 注入了 companyId');
... ... @@ -113,8 +131,8 @@ async function main() {
113 131  
114 132 // 检查公司元数据更新
115 133 const companyAfterCeo = svc.get(TEST_COMPANY_ID);
116   - assert(companyAfterCeo.ceo === 'ceo', 'company.json 中 CEO 已标记');
117   - assert(companyAfterCeo.agents.includes('ceo'), 'company.json agents 列表包含 ceo');
  134 + assert(companyAfterCeo.ceo === CXO_ROLE, 'company.json 中 CEO 已标记(现有实现:任何 type=cxo 的角色都会覆盖此字段)');
  135 + assert(companyAfterCeo.agents.includes(CXO_ROLE), `company.json agents 列表包含 ${CXO_ROLE}`);
118 136  
119 137 // ─── 5. 招聘 qa-judge (从模板) ───
120 138 console.log('\n🔍 Phase 5: 招聘 qa-judge (模板命中)');
... ... @@ -128,14 +146,19 @@ async function main() {
128 146 name: '热点搜索员',
129 147 description: '全网热点监控、选题建议',
130 148 });
131   - assert(hireNew.status === 'training', '返回 training 状态');
  149 + // T5b(27-p2-org-graph.md §4.1 第 7 步)起:模板不存在时不再走 needs_training 全量培养,
  150 + // 改为先从 system/templates/base/ 元 agent 继承骨架,再交 agent-creator 填充。
  151 + assert(hireNew.status === 'hired_from_base', '返回 hired_from_base 状态(从 base 元 agent 继承)');
132 152  
133 153 const hthDir = path.join(TEST_COMPANY_DIR, 'agents', 'hot-topic-hunter');
134 154 assert(fs.existsSync(hthDir), '热点搜索员骨架目录已创建');
135 155 assert(fs.existsSync(path.join(hthDir, 'agent.md')), 'agent.md 已生成');
136   - assert(fs.existsSync(path.join(hthDir, 'soul.md')), 'soul.md 已生成');
  156 + // ⚠️ base 元 agent(system/templates/base/)目前只有 agent.md + config.json,
  157 + // 所以继承出来的骨架不含 soul.md / identity.md / tools.md / todo.md —— 这些由
  158 + // agent-creator 后续填充。这里只断言 base 继承本身保证的东西,不断言 agent-creator 的产物。
  159 + const hthCfg = JSON.parse(fs.readFileSync(path.join(hthDir, 'config.json'), 'utf-8'));
  160 + assert(hthCfg.inheritedFrom === 'base', 'config.json 标记 inheritedFrom=base');
137 161 assert(fs.existsSync(path.join(hthDir, 'config.json')), 'config.json 已生成');
138   - assert(fs.existsSync(path.join(hthDir, 'todo.md')), 'todo.md 已生成');
139 162 assert(fs.existsSync(path.join(hthDir, 'memory', 'memory.md')), 'memory/ 已初始化');
140 163  
141 164 const hthConfig = JSON.parse(fs.readFileSync(path.join(hthDir, 'config.json'), 'utf-8'));
... ... @@ -160,7 +183,7 @@ async function main() {
160 183 assert(depositResult.templateId === 'hot-topic-hunter', '模板 ID 正确');
161 184  
162 185 // 检查模板已创建
163   - const tplDir = path.join(DATA_DIR, 'templates', 'hot-topic-hunter');
  186 + const tplDir = path.join(TEMPLATES_DIR, 'hot-topic-hunter');
164 187 assert(fs.existsSync(tplDir), '模板目录已创建');
165 188 assert(fs.existsSync(path.join(tplDir, 'config.json')), '模板 config.json 存在');
166 189  
... ... @@ -182,7 +205,7 @@ async function main() {
182 205 // ─── 8. Phase 2: 新模板可被第二家公司复用 ───
183 206 console.log('\n🔄 Phase 8: 验证新模板可被第二家公司复用');
184 207 const SECOND_COMPANY = 'test-manga-co';
185   - const secondDir = path.join(DATA_DIR, SECOND_COMPANY);
  208 + const secondDir = path.join(COMPANIES_DIR, SECOND_COMPANY);
186 209 try {
187 210 svc.create({ id: SECOND_COMPANY, name: '测试漫剧公司', description: '漫剧测试' });
188 211 const hireFromNewTpl = svc.hire(SECOND_COMPANY, 'hot-topic-hunter');
... ... @@ -198,26 +221,27 @@ async function main() {
198 221  
199 222 // ─── 9. 重复招聘检测 ───
200 223 console.log('\n🔁 Phase 9: 重复招聘检测');
201   - const hireDup = svc.hire(TEST_COMPANY_ID, 'ceo');
  224 + const hireDup = svc.hire(TEST_COMPANY_ID, CXO_ROLE);
202 225 assert(hireDup.status === 'already_exists', '重复招聘返回 already_exists');
203 226  
204 227 // ─── 10. 公司 Agent 列表 ───
205 228 console.log('\n📋 Phase 10: 公司 Agent 列表');
206 229 const agents = svc.listAgents(TEST_COMPANY_ID);
207 230 assert(agents.length === 3, `Agent 数量 = 3 (实际: ${agents.length})`);
208   - assert(agents.some(a => a.id === 'ceo'), '列表包含 CEO');
  231 + assert(agents.some(a => a.id === CXO_ROLE), '列表包含 CXO');
209 232 assert(agents.some(a => a.id === 'qa-judge'), '列表包含 qa-judge');
210 233 assert(agents.some(a => a.id === 'hot-topic-hunter'), '列表包含 hot-topic-hunter');
211 234  
212 235 // ─── 11. resolveAgentDir ───
213 236 console.log('\n📍 Phase 11: resolveAgentDir 统一寻址');
214   - const companyAgentDir = CompanyService.resolveAgentDir(TEST_COMPANY_ID, 'ceo');
  237 + const companyAgentDir = CompanyService.resolveAgentDir(TEST_COMPANY_ID, CXO_ROLE);
215 238 assert(companyAgentDir !== null, '公司内 CEO 可定位');
216 239 assert(companyAgentDir.includes(TEST_COMPANY_ID), '路径包含公司 ID');
217 240  
218 241 const globalMasterDir = CompanyService.resolveAgentDir(null, 'master');
219 242 assert(globalMasterDir !== null, '全局 Master 可定位');
220   - assert(globalMasterDir.includes('data/agents/master'), '全局路径正确');
  243 + // 全局 agent 在 system/agents/ 下,不是 data/agents/(同属那次目录调整时漏改的断言)
  244 + assert(globalMasterDir.includes('system/agents/master'), '全局路径正确');
221 245  
222 246 const notExist = CompanyService.resolveAgentDir(TEST_COMPANY_ID, 'nonexistent-agent');
223 247 assert(notExist === null, '不存在的 Agent 返回 null');
... ... @@ -436,10 +460,10 @@ async function main() {
436 460 // ─── 清理 ───
437 461 cleanup();
438 462 // 清理测试生成的模板
439   - const testTplDir = path.join(DATA_DIR, 'templates', 'hot-topic-hunter');
  463 + const testTplDir = path.join(TEMPLATES_DIR, 'hot-topic-hunter');
440 464 if (fs.existsSync(testTplDir)) fs.rmSync(testTplDir, { recursive: true, force: true });
441 465 // 恢复 registry (移除测试模板)
442   - const regPath = path.join(DATA_DIR, 'templates', '_registry.json');
  466 + const regPath = path.join(TEMPLATES_DIR, '_registry.json');
443 467 if (fs.existsSync(regPath)) {
444 468 const reg = JSON.parse(fs.readFileSync(regPath, 'utf-8'));
445 469 const cleaned = reg.filter(t => t.id !== 'hot-topic-hunter');
... ...
server/services/company-service.js
... ... @@ -51,7 +51,7 @@ class CompanyService {
51 51 }
52 52  
53 53 // 创建目录结构
54   - const subdirs = ['agents', 'goals', 'pipelines', 'workspace', 'workspace/assets', 'workspace/output', 'docs', 'logs/pipeline_runs', 'logs/audit'];
  54 + const subdirs = ['agents', 'okr', 'pipelines', 'workspace', 'workspace/assets', 'workspace/output', 'docs', 'logs/pipeline_runs', 'logs/audit'];
55 55 for (const sub of subdirs) {
56 56 fs.mkdirSync(path.join(companyDir, sub), { recursive: true });
57 57 }
... ...
web/src/router/index.js
... ... @@ -16,7 +16,10 @@ const routes = [
16 16 props: true,
17 17 },
18 18 {
19   - path: '/:id/agents/:agentId?/:convId?',
  19 + // agentId 必填:`/:id/agents`(无 agentId)要落到下面的 CompanyDetail 去渲染团队 tab。
  20 + // 之前写成 :agentId? 时,这条路由会抢占 /:id/agents,导致导航栏「Team」永远打开
  21 + // AgentChat,CompanyDashboard 的团队 tab(含 Org Graph 区块)完全不可达。
  22 + path: '/:id/agents/:agentId/:convId?',
20 23 name: 'CompanyAgentChat',
21 24 component: () => import('@/views/AgentChat.vue'),
22 25 props: true,
... ...
web/src/views/CompanyDashboard.vue
... ... @@ -231,6 +231,9 @@
231 231 <span style="font-size:12px; opacity:0.7;">该公司还没有生成 workflow(存量公司或规划仍在进行中)</span>
232 232 </div>
233 233 <template v-else>
  234 + <div v-if="orgGraphNoEdges" class="org-graph-warn">
  235 + ℹ️ 该公司只有成员、尚未定义流转顺序(存量公司未经 orggraph-planning 规划),下方仅平铺列出,不代表执行次序
  236 + </div>
234 237 <div v-if="orgGraphDegraded" class="org-graph-warn">
235 238 ⚠️ 拓扑序解析异常(图中可能存在环或孤立节点),已退化为平铺列表
236 239 </div>
... ... @@ -244,7 +247,7 @@
244 247 </span>
245 248 <span v-if="n.agent && n.agent.status" class="agent-status" :class="n.agent.status">{{ n.agent.status }}</span>
246 249 </div>
247   - <span v-if="!orgGraphDegraded && idx < orgGraphChain.length - 1" class="org-arrow">→</span>
  250 + <span v-if="!orgGraphDegraded && !orgGraphNoEdges && idx < orgGraphChain.length - 1" class="org-arrow">→</span>
248 251 </template>
249 252 </div>
250 253 <div v-if="orgGraphBackEdges.length > 0" class="org-back-edges">
... ... @@ -1126,6 +1129,13 @@ const orgGraphTopo = computed(() =&gt; {
1126 1129  
1127 1130 const orgGraphChain = computed(() => orgGraphTopo.value.chain)
1128 1131 const orgGraphDegraded = computed(() => orgGraphTopo.value.degraded)
  1132 +// 有节点但一条 forward 边都没有(P0 迁移进来的存量公司就是这样)。
  1133 +// 此时拓扑序退化成"声明顺序",若照常画 → 箭头会凭空捏造出一条并不存在的流程,
  1134 +// 所以这种情况只平铺节点、不画箭头,并明确告诉用户「尚未定义流转顺序」。
  1135 +const orgGraphNoEdges = computed(() =>
  1136 + (orgGraph.value.nodes || []).length > 0 &&
  1137 + (orgGraph.value.edges || []).filter(e => (e.edgeKind || 'forward') === 'forward').length === 0
  1138 +)
1129 1139  
1130 1140 // 回退边单独列一行,不参与拓扑序
1131 1141 const orgGraphBackEdges = computed(() => {
... ...