Commit ae7650e8b82fae1ad75fb89a54a8549b40e265d6

Authored by yibo
1 parent 9e26bea2

feat(company): target_dir 的 API/UI 配置入口(42 号文档 Phase 3)

39 号文档 P5 落地的是"配了 company.json.target_dir 就生效"这一半,
"人怎么把这两个字段填进去"此前只能手工编辑 company.json。

复用已存在的 PUT /api/company/:companyId(companyService.update()),
没有新建端点。新增 target_dir/target_mode 分支,校验直接调
path-resolver.validateLinkedTarget(39 号文档 P5 抽出来的共享函数,
和 graph-engine.js 运行时校验同一份判据):合法路径写入成功、敏感
路径黑名单拒绝、linked-new 非空目录拒绝、linked-existing 不存在拒绝,
校验失败路由层转 400;传空字符串清空配置;不传 target_mode 默认
linked-existing,与 graph-engine.js 的 readCompanyTargetConfig 同口径。

前端:CompanyDashboard.vue 概览 tab 加一张配置卡片,本地表单状态
不直接双向绑定 selectedCompany,保存失败不会冲掉用户刚打的字。

新增测试 8 例,npm run web:build 编译通过。**未做真实浏览器点击
验证**——服务器上有其它并行会话真实在跑的 agent 进程,这次没有重启
服务器(42 号文档 §二记过一次"重启杀掉别人真实进程"的教训),文档
里标记了这个已知缺口。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwG4b7Nq1NLR9eeE3M9Us9
docs/todo/system-design/42-org-work-graph-reliability-followups.md
1 1 # 42. Org/Work Graph 扳撠 39 ﹝
2 2  
3   -> ****嚗hase 1-2 撌脣嚗2026-08-30嚗hase 3-4 敺撌乓
  3 +> ****嚗hase 1-3 撌脣嚗2026-08-30嚗hase 3 垢蝻箔甈∠撉砲秩hase 4 敺撌乓
4 4 > ****嚗2026-08-30
5 5 > ****嚗39-boss-agent-task-supervision.md](./39-boss-agent-task-supervision.md) P1-P5 摰甈∠垢蝡舀遣 銝 goal `ceo` 銝銝芸揪虜laywright 5/5 撉**1469 銝芸蝏選活瘚撠 3 銝芸瘝∟憸**遢﹝甈∪銝撌仿◇摨葵 phase
6 6 > **霂餉◆**嚗葵 phase 膩甈∠嚗瘚雿辣/銵靘摰
... ... @@ -73,7 +73,15 @@ Phase 4嚗imeline.ndjson planning/reflection 頧刻蕨 蝏扳
73 73  
74 74 ---
75 75  
76   -## hase 3嚗target_dir` API/UI
  76 +## hase 3嚗target_dir` API/UI 撌脣嚗2026-08-30嚗
  77 +
  78 +**蝏**嚗
  79 +- 垢憭鈭歇摮 `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` 銝征蔭銝嚗
  80 +- 垢嚗CompanyDashboard.vue` "璁"tab 撘敶獢 + 璅∪ + 靽嚗銵典 `selectedCompany`嚗仃韐交銝砲霈拍◤
  81 +
  82 +**撉撘**嚗npm run web:build` 蝻 + 垢瘚楝撅 mock service嚗**瘝⊥撉**銝摰僎銵頝 agent 餈ps` 3 銝 `claude` 摮霈拙垢42 ﹝ 禮鈭 撌脩扇餈甈"鈭箇"悌嚗活銝**餈銝銝芸歇撩嚗""**嚗活蝒嚗瓷僎銵頝摨砲銵乩甈∠撉
  83 +
  84 +---
77 85  
78 86 ****嚗5 " `company.json.target_dir` 撠梁"餈"鈭箸舅銝芸挾憛怨"餈瓷極蝻敶 JSON 辣
79 87  
... ...
server/routes/company.js
... ... @@ -191,8 +191,16 @@ router.get(&#39;/:companyId&#39;, (req, res) =&gt; {
191 191 });
192 192  
193 193 // PUT /api/company/:companyId
  194 +// 42 号文档 Phase 3:body 里带 target_dir 时,companyService.update() 会做真实的路径校验
  195 +// (复用 path-resolver.validateLinkedTarget,敏感路径黑名单 / linked-new 非空 / linked-existing
  196 +// 不存在等都在这一步拦,不用等到真正跑任务分配项目根才发现),校验失败抛出,这里接住转 400。
194 197 router.put('/:companyId', (req, res) => {
195   - const company = companyService.update(req.params.companyId, req.body);
  198 + let company;
  199 + try {
  200 + company = companyService.update(req.params.companyId, req.body);
  201 + } catch (err) {
  202 + return res.status(400).json({ error: err.message });
  203 + }
196 204 if (!company) return res.status(404).json({ error: '公司不存在' });
197 205 res.json({ success: true, company });
198 206 });
... ...
server/services/company-service.js
... ... @@ -357,6 +357,26 @@ class CompanyService {
357 357 }
358 358 if (updates.sop !== undefined) company.sop = updates.sop;
359 359 if (updates.heartbeat !== undefined) company.heartbeat = { ...company.heartbeat, ...updates.heartbeat };
  360 +
  361 + // 42 号文档 Phase 3:target_dir 的配置入口。校验复用 path-resolver 的 validateLinkedTarget
  362 + // ——39 号文档 P5 落地时就是从这里抽出来的共享函数,graph-engine.js 的 ensureProjectRoot
  363 + // 用它做运行时校验,这里在配置阶段就先做一遍,不用等第一次真的分配项目根才发现路径不对。
  364 + // 校验失败直接抛出(调用方 try/catch 转 400),不静默吞掉、不写进一半的脏状态——
  365 + // 抛出发生在 _saveCompany() 之前,本次 update 里其它字段也不会被落盘(同一次保存,要么全写要么不写)。
  366 + if (updates.target_dir !== undefined) {
  367 + if (updates.target_dir === null || updates.target_dir === '') {
  368 + company.target_dir = null;
  369 + company.target_mode = null;
  370 + } else {
  371 + // 缺省 'linked-existing':与 graph-engine.js 的 readCompanyTargetConfig 同一条默认值口径,
  372 + // 两处判据分叉是这个仓库反复栽过的坑("两套体系不统一"),不能各写一份。
  373 + const mode = updates.target_mode === 'linked-new' ? 'linked-new' : 'linked-existing';
  374 + const { validateLinkedTarget } = require('./devloop/path-resolver');
  375 + company.target_dir = validateLinkedTarget(updates.target_dir, mode);
  376 + company.target_mode = mode;
  377 + }
  378 + }
  379 +
360 380 company.updatedAt = new Date().toISOString();
361 381  
362 382 this._saveCompany(company);
... ...
server/tests/unit/company-target-dir-config.test.js 0 → 100644
  1 +/**
  2 + * Unit Tests — PUT /api/company/:companyId 的 target_dir 配置入口(42 号文档 Phase 3)
  3 + *
  4 + * 39 号文档 P5 落地的是"配了 company.json.target_dir 就生效"这一半,"人怎么把这两个字段
  5 + * 填进去"这一半此前只能手工编辑 company.json。这条测试钉住新入口的校验分支——校验复用
  6 + * path-resolver.validateLinkedTarget(P5 已经抽出来的共享函数),跟 graph-engine.js 运行时
  7 + * 用的是同一份判据,不重新写一遍。
  8 + *
  9 + * 走真入口:把 router 挂到真 express 上 listen(0) 再 fetch,不 mock company-service。
  10 + */
  11 +import { describe, it, expect, beforeAll, afterAll } from 'vitest';
  12 +import fs from 'fs';
  13 +import os from 'os';
  14 +import path from 'path';
  15 +import express from 'express';
  16 +
  17 +const COMPANIES_DIR = path.resolve(__dirname, '../../../data/companies');
  18 +
  19 +describe('PUT /api/company/:companyId — target_dir 配置(42 号文档 Phase 3)', () => {
  20 + let server;
  21 + let base;
  22 + const createdCompanies = [];
  23 + const createdDirs = [];
  24 +
  25 + beforeAll(async () => {
  26 + const app = express();
  27 + app.use(express.json());
  28 + app.use('/api/company', require('../../routes/company'));
  29 + await new Promise((resolve) => { server = app.listen(0, resolve); });
  30 + base = `http://127.0.0.1:${server.address().port}/api/company`;
  31 + });
  32 +
  33 + afterAll(() => {
  34 + if (server) server.close();
  35 + for (const cid of createdCompanies) {
  36 + fs.rmSync(path.join(COMPANIES_DIR, cid), { recursive: true, force: true });
  37 + }
  38 + for (const d of createdDirs) {
  39 + try { fs.rmSync(d, { recursive: true, force: true }); } catch { /* ignore */ }
  40 + }
  41 + });
  42 +
  43 + function makeCompany(companyId) {
  44 + const dir = path.join(COMPANIES_DIR, companyId);
  45 + fs.mkdirSync(dir, { recursive: true });
  46 + fs.writeFileSync(path.join(dir, 'company.json'), JSON.stringify({ id: companyId, name: companyId }));
  47 + createdCompanies.push(companyId);
  48 + return dir;
  49 + }
  50 +
  51 + async function put(companyId, body) {
  52 + const r = await fetch(`${base}/${companyId}`, {
  53 + method: 'PUT',
  54 + headers: { 'Content-Type': 'application/json' },
  55 + body: JSON.stringify(body),
  56 + });
  57 + return [r.status, await r.json()];
  58 + }
  59 +
  60 + it('linked-existing:合法的已存在空目录,写入成功', async () => {
  61 + const cid = 'target-dir-cfg-existing';
  62 + makeCompany(cid);
  63 + const target = fs.mkdtempSync(path.join(os.tmpdir(), 'target-dir-cfg-existing-'));
  64 + createdDirs.push(target);
  65 +
  66 + const [status, body] = await put(cid, { target_dir: target, target_mode: 'linked-existing' });
  67 + expect(status).toBe(200);
  68 + expect(body.company.target_dir).toBe(path.resolve(target));
  69 + expect(body.company.target_mode).toBe('linked-existing');
  70 +
  71 + // 真的落盘了,不是只回了个内存对象
  72 + const onDisk = JSON.parse(fs.readFileSync(path.join(COMPANIES_DIR, cid, 'company.json'), 'utf8'));
  73 + expect(onDisk.target_dir).toBe(path.resolve(target));
  74 + });
  75 +
  76 + it('linked-new:目录不存在时自动创建,写入成功', async () => {
  77 + const cid = 'target-dir-cfg-new';
  78 + makeCompany(cid);
  79 + const parent = fs.mkdtempSync(path.join(os.tmpdir(), 'target-dir-cfg-new-parent-'));
  80 + createdDirs.push(parent);
  81 + const target = path.join(parent, 'brand-new-project');
  82 +
  83 + expect(fs.existsSync(target)).toBe(false);
  84 + const [status, body] = await put(cid, { target_dir: target, target_mode: 'linked-new' });
  85 + expect(status).toBe(200);
  86 + expect(body.company.target_mode).toBe('linked-new');
  87 + expect(fs.existsSync(target)).toBe(true); // validateLinkedTarget 的既有副作用:自动建目录
  88 + });
  89 +
  90 + it('不传 target_mode 时默认按 linked-existing 处理(与 graph-engine.js 的默认口径一致)', async () => {
  91 + const cid = 'target-dir-cfg-default-mode';
  92 + makeCompany(cid);
  93 + const target = fs.mkdtempSync(path.join(os.tmpdir(), 'target-dir-cfg-default-'));
  94 + createdDirs.push(target);
  95 +
  96 + const [status, body] = await put(cid, { target_dir: target });
  97 + expect(status).toBe(200);
  98 + expect(body.company.target_mode).toBe('linked-existing');
  99 + });
  100 +
  101 + it('敏感路径命中黑名单 → 400,不写入', async () => {
  102 + const cid = 'target-dir-cfg-sensitive';
  103 + makeCompany(cid);
  104 + const sensitive = path.join(os.homedir(), '.ssh', 'target-dir-cfg-should-not-write');
  105 +
  106 + const [status, body] = await put(cid, { target_dir: sensitive, target_mode: 'linked-existing' });
  107 + expect(status).toBe(400);
  108 + expect(body.error).toContain('sensitive_path');
  109 +
  110 + const onDisk = JSON.parse(fs.readFileSync(path.join(COMPANIES_DIR, cid, 'company.json'), 'utf8'));
  111 + expect(onDisk.target_dir).toBeUndefined(); // 校验失败,company.json 没有被改动
  112 + });
  113 +
  114 + it('linked-new 但目录已存在且非空 → 400,不写入', async () => {
  115 + const cid = 'target-dir-cfg-new-nonempty';
  116 + makeCompany(cid);
  117 + const target = fs.mkdtempSync(path.join(os.tmpdir(), 'target-dir-cfg-nonempty-'));
  118 + createdDirs.push(target);
  119 + fs.writeFileSync(path.join(target, 'already-here.txt'), 'x');
  120 +
  121 + const [status, body] = await put(cid, { target_dir: target, target_mode: 'linked-new' });
  122 + expect(status).toBe(400);
  123 + expect(body.error).toContain('not empty');
  124 + });
  125 +
  126 + it('linked-existing 但目录不存在 → 400,不写入', async () => {
  127 + const cid = 'target-dir-cfg-existing-missing';
  128 + makeCompany(cid);
  129 + const parent = fs.mkdtempSync(path.join(os.tmpdir(), 'target-dir-cfg-missing-parent-'));
  130 + createdDirs.push(parent);
  131 + const target = path.join(parent, 'does-not-exist');
  132 +
  133 + const [status, body] = await put(cid, { target_dir: target, target_mode: 'linked-existing' });
  134 + expect(status).toBe(400);
  135 + expect(body.error).toContain('does not exist');
  136 + });
  137 +
  138 + it('target_dir 传空字符串清掉已有配置', async () => {
  139 + const cid = 'target-dir-cfg-clear';
  140 + makeCompany(cid);
  141 + const target = fs.mkdtempSync(path.join(os.tmpdir(), 'target-dir-cfg-clear-'));
  142 + createdDirs.push(target);
  143 +
  144 + const [s1, b1] = await put(cid, { target_dir: target, target_mode: 'linked-existing' });
  145 + expect(s1).toBe(200);
  146 + expect(b1.company.target_dir).toBeTruthy();
  147 +
  148 + const [s2, b2] = await put(cid, { target_dir: '' });
  149 + expect(s2).toBe(200);
  150 + expect(b2.company.target_dir).toBeNull();
  151 + expect(b2.company.target_mode).toBeNull();
  152 + });
  153 +
  154 + it('公司不存在 → 404', async () => {
  155 + const [status] = await put('target-dir-cfg-nope', { target_dir: '/tmp/whatever' });
  156 + expect(status).toBe(404);
  157 + });
  158 +});
... ...
web/src/views/CompanyDashboard.vue
... ... @@ -123,6 +123,44 @@
123 123 </div>
124 124 </div>
125 125  
  126 + <!-- Target Dir Card(42 号文档 Phase 3:公司级目标代码目录配置) -->
  127 + <div class="overview-card">
  128 + <div class="overview-card-header">
  129 + <h3>📁 目标代码目录</h3>
  130 + </div>
  131 + <p style="font-size:13px; color:var(--text-secondary); margin:0 0 12px;">
  132 + 配了之后,这家公司所有任务的交付物代码都固定落在这个目录,不再逐任务分配临时沙盒
  133 + (类似 Claude Code 选工作目录)。留空则维持默认的沙盒模式。
  134 + </p>
  135 + <div class="overview-info-grid" style="grid-template-columns: 1fr;">
  136 + <div class="info-item">
  137 + <span class="info-label">目标目录(绝对路径)</span>
  138 + <input
  139 + v-model="targetDirForm.target_dir"
  140 + type="text"
  141 + placeholder="/Users/you/projects/my-app"
  142 + style="padding:6px 10px; border:1px solid var(--border-color); border-radius:6px; font-size:13px; width:100%; box-sizing:border-box;"
  143 + />
  144 + </div>
  145 + <div class="info-item">
  146 + <span class="info-label">模式</span>
  147 + <select v-model="targetDirForm.target_mode" style="padding:6px 10px; border:1px solid var(--border-color); border-radius:6px; font-size:13px;">
  148 + <option value="linked-existing">linked-existing(接管一个已存在的目录)</option>
  149 + <option value="linked-new">linked-new(新建/空目录)</option>
  150 + </select>
  151 + </div>
  152 + </div>
  153 + <div class="overview-actions">
  154 + <button class="btn-sm" @click="saveTargetDir" :disabled="isSavingTargetDir">
  155 + {{ isSavingTargetDir ? '⏳ 保存中...' : '💾 保存' }}
  156 + </button>
  157 + <span v-if="targetDirError" style="color:var(--status-failed); font-size:13px;">{{ targetDirError }}</span>
  158 + <span v-else-if="selectedCompany?.target_dir" style="color:var(--text-secondary); font-size:13px;">
  159 + 当前生效:{{ selectedCompany.target_dir }}({{ selectedCompany.target_mode }})
  160 + </span>
  161 + </div>
  162 + </div>
  163 +
126 164 <!-- Agent Status Table -->
127 165 <div class="overview-card agents-card">
128 166 <div class="overview-card-header">
... ... @@ -739,7 +777,7 @@
739 777 </template>
740 778  
741 779 <script setup>
742   -import { ref, computed, onMounted, watch, onUnmounted } from 'vue'
  780 +import { ref, reactive, computed, onMounted, watch, onUnmounted } from 'vue'
743 781 import { useRouter, useRoute } from 'vue-router'
744 782 import axios from 'axios'
745 783 import AppNav from '@/components/AppNav.vue'
... ... @@ -756,6 +794,11 @@ const companyStatus = ref(null)
756 794 const isStarting = ref(false)
757 795 const isStopping = ref(false)
758 796 const selectedCompany = ref(null)
  797 +// 42 号文档 Phase 3:target_dir 配置表单本地状态,不直接双向绑到 selectedCompany——
  798 +// 保存失败时输入框不该跟着回滚成服务端的旧值,用户还得看着自己刚打的字重打一遍。
  799 +const targetDirForm = reactive({ target_dir: '', target_mode: 'linked-existing' })
  800 +const isSavingTargetDir = ref(false)
  801 +const targetDirError = ref('')
759 802 const companyAgents = ref([])
760 803 const pipelines = ref([])
761 804 const pipelineRuns = ref([])
... ... @@ -972,6 +1015,14 @@ function goToTaskBoard() {
972 1015 router.push(`/${selectedCompany.value.id}/task-board`)
973 1016 }
974 1017  
  1018 +// 42 号文档 Phase 3:公司切换 / 首次加载时,把表单同步成服务端当前值——只在 id 变化时同步,
  1019 +// 不watch 整个 selectedCompany,否则用户正在改的输入框会被别的字段刷新(比如心跳状态轮询)打断。
  1020 +watch(() => selectedCompany.value?.id, () => {
  1021 + targetDirForm.target_dir = selectedCompany.value?.target_dir || ''
  1022 + targetDirForm.target_mode = selectedCompany.value?.target_mode || 'linked-existing'
  1023 + targetDirError.value = ''
  1024 +}, { immediate: true })
  1025 +
975 1026 watch(() => route?.params, async (params) => {
976 1027 if (!params) return
977 1028 if (route.name === 'CompanyDetail' && params.id) {
... ... @@ -1154,6 +1205,22 @@ async function stopCompany() {
1154 1205 isStopping.value = false
1155 1206 }
1156 1207  
  1208 +async function saveTargetDir() {
  1209 + if (!selectedCompany.value) return
  1210 + isSavingTargetDir.value = true
  1211 + targetDirError.value = ''
  1212 + try {
  1213 + const { data } = await axios.put(`/api/company/${selectedCompany.value.id}`, {
  1214 + target_dir: targetDirForm.target_dir,
  1215 + target_mode: targetDirForm.target_mode,
  1216 + })
  1217 + selectedCompany.value = data.company
  1218 + } catch (e) {
  1219 + targetDirError.value = e.response?.data?.error || e.message
  1220 + }
  1221 + isSavingTargetDir.value = false
  1222 +}
  1223 +
1157 1224 async function deleteCompany(companyId) {
1158 1225 if (!confirm('确认要删除该公司吗?该操作仅会从列表中移除公司声明。')) return
1159 1226 try {
... ...