diff --git a/docs/todo/system-design/42-org-work-graph-reliability-followups.md b/docs/todo/system-design/42-org-work-graph-reliability-followups.md index 77bf804..0c3ed2a 100644 --- a/docs/todo/system-design/42-org-work-graph-reliability-followups.md +++ b/docs/todo/system-design/42-org-work-graph-reliability-followups.md @@ -1,6 +1,6 @@ # 42. Org/Work Graph 可靠性收尾 —— 39 号文档真跑复盘的待办 -> **状态**:Phase 1-2 ✅ 已实现(2026-08-30);Phase 3-4 📝 待开工。 +> **状态**:Phase 1-3 ✅ 已实现(2026-08-30,Phase 3 前端缺一次真实浏览器验证,见该节说明);Phase 4 📝 待开工。 > **日期**:2026-08-30 > **背景**:[39-boss-agent-task-supervision.md](./39-boss-agent-task-supervision.md) 的 P1-P5 落地后,用它做了一次真实端到端测试(建公司 → 下 goal → `ceo` 监督 → 真实跑出一个可玩的贪吃蛇游戏,Playwright 5/5 验证通过)。**1469 个单测全绿,这次真跑却测出至少 3 个单测完全没覆盖到的真问题**——这份文档是那次复盘沉淀下来的待办,按开工顺序分四个 phase。 > **读者须知**:每个 phase 的现状描述都来自那次真实测试里实际撞到的场景,不是推测;具体文件/行号落地前按惯例重新核实。 @@ -73,7 +73,15 @@ Phase 4:timeline.ndjson 承接 planning/reflection 轨迹 —— 结构性改å --- -## 四、Phase 3:`target_dir` 的 API/UI 入口 +## 四、Phase 3:`target_dir` 的 API/UI 入口 —— ✅ 已实现(2026-08-30) + +**落地结果**: +- 后端复用了已存在的 `PUT /api/company/:companyId`(`companyService.update()`),没有新建端点——设计里"如果已存在就扩展"这条命中了。`update()` 新增 `target_dir`/`target_mode` 分支,校验直接调 `path-resolver.validateLinkedTarget`(39 号文档 P5 抽出来的共享函数,和 `graph-engine.js` 运行时校验同一份判据),校验失败抛出,路由层 `try/catch` 转 400;`target_dir` 传空字符串清空配置;不传 `target_mode` 时默认 `linked-existing`,与 `graph-engine.js` 的 `readCompanyTargetConfig` 同一口径。测试:`server/tests/unit/company-target-dir-config.test.js`(8 例:`linked-existing`/`linked-new` 各自成功路径、默认 mode、敏感路径黑名单、`linked-new` 非空目录、`linked-existing` 不存在、清空配置、公司不存在)。 +- 前端:`CompanyDashboard.vue` 的"概览"tab 加一张卡片(目标目录输入框 + 模式下拉 + 保存按钮),本地表单状态不直接双向绑定 `selectedCompany`(保存失败时不该让用户刚打的字被冲掉)。 + +**验证方式的取舍**:`npm run web:build` 编译通过 + 后端测试直接打真实路由层(不 mock service)。**没有做真实浏览器点击验证**——当时服务器上有其它并行会话真实在跑的 agent 进程(`ps` 能看到 3 个 `claude` 子进程),而重启服务器才能让后端改动生效,42 号文档 §二 已经记过一次"重启服务器杀掉别人真实进程"的教训,这次选择不重复。**这是一个已知的验证缺口,不是"做完了"**,下次服务器有安全窗口(没有并行会话在跑真实进程)时应该补一次真实点击验证。 + +--- **现状**:P5 落地的是"配了 `company.json.target_dir` 就生效"这一半,"人怎么把这两个字段填进去"这一半没做——真实测试里是手工编辑公司态目录下的 JSON 文件配的。 diff --git a/server/routes/company.js b/server/routes/company.js index 6e30632..f47a210 100644 --- a/server/routes/company.js +++ b/server/routes/company.js @@ -191,8 +191,16 @@ router.get('/:companyId', (req, res) => { }); // PUT /api/company/:companyId +// 42 号文档 Phase 3:body 里带 target_dir 时,companyService.update() 会做真实的路径校验 +// (复用 path-resolver.validateLinkedTarget,敏感路径黑名单 / linked-new 非空 / linked-existing +// 不存在等都在这一步拦,不用等到真正跑任务分配项目根才发现),校验失败抛出,这里接住转 400。 router.put('/:companyId', (req, res) => { - const company = companyService.update(req.params.companyId, req.body); + let company; + try { + company = companyService.update(req.params.companyId, req.body); + } catch (err) { + return res.status(400).json({ error: err.message }); + } if (!company) return res.status(404).json({ error: '公司不存在' }); res.json({ success: true, company }); }); diff --git a/server/services/company-service.js b/server/services/company-service.js index 6ccdb0d..d33415d 100644 --- a/server/services/company-service.js +++ b/server/services/company-service.js @@ -357,6 +357,26 @@ class CompanyService { } if (updates.sop !== undefined) company.sop = updates.sop; if (updates.heartbeat !== undefined) company.heartbeat = { ...company.heartbeat, ...updates.heartbeat }; + + // 42 号文档 Phase 3:target_dir 的配置入口。校验复用 path-resolver 的 validateLinkedTarget + // ——39 号文档 P5 落地时就是从这里抽出来的共享函数,graph-engine.js 的 ensureProjectRoot + // 用它做运行时校验,这里在配置阶段就先做一遍,不用等第一次真的分配项目根才发现路径不对。 + // 校验失败直接抛出(调用方 try/catch 转 400),不静默吞掉、不写进一半的脏状态—— + // 抛出发生在 _saveCompany() 之前,本次 update 里其它字段也不会被落盘(同一次保存,要么全写要么不写)。 + if (updates.target_dir !== undefined) { + if (updates.target_dir === null || updates.target_dir === '') { + company.target_dir = null; + company.target_mode = null; + } else { + // 缺省 'linked-existing':与 graph-engine.js 的 readCompanyTargetConfig 同一条默认值口径, + // 两处判据分叉是这个仓库反复栽过的坑("两套体系不统一"),不能各写一份。 + const mode = updates.target_mode === 'linked-new' ? 'linked-new' : 'linked-existing'; + const { validateLinkedTarget } = require('./devloop/path-resolver'); + company.target_dir = validateLinkedTarget(updates.target_dir, mode); + company.target_mode = mode; + } + } + company.updatedAt = new Date().toISOString(); this._saveCompany(company); diff --git a/server/tests/unit/company-target-dir-config.test.js b/server/tests/unit/company-target-dir-config.test.js new file mode 100644 index 0000000..9d18f1f --- /dev/null +++ b/server/tests/unit/company-target-dir-config.test.js @@ -0,0 +1,158 @@ +/** + * Unit Tests — PUT /api/company/:companyId 的 target_dir 配置入口(42 号文档 Phase 3) + * + * 39 号文档 P5 落地的是"配了 company.json.target_dir 就生效"这一半,"人怎么把这两个字段 + * 填进去"这一半此前只能手工编辑 company.json。这条测试钉住新入口的校验分支——校验复用 + * path-resolver.validateLinkedTarget(P5 已经抽出来的共享函数),跟 graph-engine.js 运行时 + * 用的是同一份判据,不重新写一遍。 + * + * 走真入口:把 router 挂到真 express 上 listen(0) 再 fetch,不 mock company-service。 + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import express from 'express'; + +const COMPANIES_DIR = path.resolve(__dirname, '../../../data/companies'); + +describe('PUT /api/company/:companyId — target_dir 配置(42 号文档 Phase 3)', () => { + let server; + let base; + const createdCompanies = []; + const createdDirs = []; + + beforeAll(async () => { + const app = express(); + app.use(express.json()); + app.use('/api/company', require('../../routes/company')); + await new Promise((resolve) => { server = app.listen(0, resolve); }); + base = `http://127.0.0.1:${server.address().port}/api/company`; + }); + + afterAll(() => { + if (server) server.close(); + for (const cid of createdCompanies) { + fs.rmSync(path.join(COMPANIES_DIR, cid), { recursive: true, force: true }); + } + for (const d of createdDirs) { + try { fs.rmSync(d, { recursive: true, force: true }); } catch { /* ignore */ } + } + }); + + function makeCompany(companyId) { + const dir = path.join(COMPANIES_DIR, companyId); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'company.json'), JSON.stringify({ id: companyId, name: companyId })); + createdCompanies.push(companyId); + return dir; + } + + async function put(companyId, body) { + const r = await fetch(`${base}/${companyId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + return [r.status, await r.json()]; + } + + it('linked-existing:合法的已存在空目录,写入成功', async () => { + const cid = 'target-dir-cfg-existing'; + makeCompany(cid); + const target = fs.mkdtempSync(path.join(os.tmpdir(), 'target-dir-cfg-existing-')); + createdDirs.push(target); + + const [status, body] = await put(cid, { target_dir: target, target_mode: 'linked-existing' }); + expect(status).toBe(200); + expect(body.company.target_dir).toBe(path.resolve(target)); + expect(body.company.target_mode).toBe('linked-existing'); + + // 真的落盘了,不是只回了个内存对象 + const onDisk = JSON.parse(fs.readFileSync(path.join(COMPANIES_DIR, cid, 'company.json'), 'utf8')); + expect(onDisk.target_dir).toBe(path.resolve(target)); + }); + + it('linked-new:目录不存在时自动创建,写入成功', async () => { + const cid = 'target-dir-cfg-new'; + makeCompany(cid); + const parent = fs.mkdtempSync(path.join(os.tmpdir(), 'target-dir-cfg-new-parent-')); + createdDirs.push(parent); + const target = path.join(parent, 'brand-new-project'); + + expect(fs.existsSync(target)).toBe(false); + const [status, body] = await put(cid, { target_dir: target, target_mode: 'linked-new' }); + expect(status).toBe(200); + expect(body.company.target_mode).toBe('linked-new'); + expect(fs.existsSync(target)).toBe(true); // validateLinkedTarget 的既有副作用:自动建目录 + }); + + it('不传 target_mode 时默认按 linked-existing 处理(与 graph-engine.js 的默认口径一致)', async () => { + const cid = 'target-dir-cfg-default-mode'; + makeCompany(cid); + const target = fs.mkdtempSync(path.join(os.tmpdir(), 'target-dir-cfg-default-')); + createdDirs.push(target); + + const [status, body] = await put(cid, { target_dir: target }); + expect(status).toBe(200); + expect(body.company.target_mode).toBe('linked-existing'); + }); + + it('敏感路径命中黑名单 → 400,不写入', async () => { + const cid = 'target-dir-cfg-sensitive'; + makeCompany(cid); + const sensitive = path.join(os.homedir(), '.ssh', 'target-dir-cfg-should-not-write'); + + const [status, body] = await put(cid, { target_dir: sensitive, target_mode: 'linked-existing' }); + expect(status).toBe(400); + expect(body.error).toContain('sensitive_path'); + + const onDisk = JSON.parse(fs.readFileSync(path.join(COMPANIES_DIR, cid, 'company.json'), 'utf8')); + expect(onDisk.target_dir).toBeUndefined(); // 校验失败,company.json 没有被改动 + }); + + it('linked-new 但目录已存在且非空 → 400,不写入', async () => { + const cid = 'target-dir-cfg-new-nonempty'; + makeCompany(cid); + const target = fs.mkdtempSync(path.join(os.tmpdir(), 'target-dir-cfg-nonempty-')); + createdDirs.push(target); + fs.writeFileSync(path.join(target, 'already-here.txt'), 'x'); + + const [status, body] = await put(cid, { target_dir: target, target_mode: 'linked-new' }); + expect(status).toBe(400); + expect(body.error).toContain('not empty'); + }); + + it('linked-existing 但目录不存在 → 400,不写入', async () => { + const cid = 'target-dir-cfg-existing-missing'; + makeCompany(cid); + const parent = fs.mkdtempSync(path.join(os.tmpdir(), 'target-dir-cfg-missing-parent-')); + createdDirs.push(parent); + const target = path.join(parent, 'does-not-exist'); + + const [status, body] = await put(cid, { target_dir: target, target_mode: 'linked-existing' }); + expect(status).toBe(400); + expect(body.error).toContain('does not exist'); + }); + + it('target_dir 传空字符串清掉已有配置', async () => { + const cid = 'target-dir-cfg-clear'; + makeCompany(cid); + const target = fs.mkdtempSync(path.join(os.tmpdir(), 'target-dir-cfg-clear-')); + createdDirs.push(target); + + const [s1, b1] = await put(cid, { target_dir: target, target_mode: 'linked-existing' }); + expect(s1).toBe(200); + expect(b1.company.target_dir).toBeTruthy(); + + const [s2, b2] = await put(cid, { target_dir: '' }); + expect(s2).toBe(200); + expect(b2.company.target_dir).toBeNull(); + expect(b2.company.target_mode).toBeNull(); + }); + + it('公司不存在 → 404', async () => { + const [status] = await put('target-dir-cfg-nope', { target_dir: '/tmp/whatever' }); + expect(status).toBe(404); + }); +}); diff --git a/web/src/views/CompanyDashboard.vue b/web/src/views/CompanyDashboard.vue index 18c3c61..64393c9 100644 --- a/web/src/views/CompanyDashboard.vue +++ b/web/src/views/CompanyDashboard.vue @@ -123,6 +123,44 @@ + +
+ 配了之后,这家公司所有任务的交付物代码都固定落在这个目录,不再逐任务分配临时沙盒 + (类似 Claude Code 选工作目录)。留空则维持默认的沙盒模式。 +
+