Commit d967ac0d634adafd8dc8fcd2bf4c02c37efc6243

Authored by yibo
1 parent 5108d67f

feat(devloop): 公司级目标代码目录 target_dir(P5)

company.json 新增 target_dir/target_mode,graph-engine 的项目根分配
读到配置后不再逐 run 分配新 sandbox,同公司所有 run 解析到同一个固定
路径(复用 path-resolver 的 linked-new/linked-existing 校验逻辑,
抽出 validateLinkedTarget/resolveFixedTarget 共享);未配置时零回归。
配了 target_dir 的公司 max_parallel_tasks 强制钉 1,避免多任务并发
写同一个仓库。按 39 号设计文档 §二 P5 落地。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwG4b7Nq1NLR9eeE3M9Us9
server/services/devloop/path-resolver.js
... ... @@ -7,8 +7,10 @@
7 7 *
8 8 * 支持三种 mode(见 arch-project-root.html §②):
9 9 * - sandbox : 自动生成 root_path = `{dataDir}/companies/{cid}/projects/{pid}/`
10   - * - linked-new : 用户指定 root_path(绝对路径),注册到 ~/.devloop/linked-projects.json
11   - * - linked-existing : v2,本文件预留接口但 register 阶段拒收
  10 + * - linked-new : 用户指定一个空/新建的 root_path(绝对路径),注册到 ~/.devloop/linked-projects.json
  11 + * - linked-existing : 用户指定一个已存在的目录,接管为项目根,同样注册到 ~/.devloop/linked-projects.json
  12 + * (已实现,校验见 validateLinkedTarget:路径存在且是目录、未挂载过 DevLoop 项目、
  13 + * 有 .git 则 working tree 必须干净)
12 14 *
13 15 * 详尽规格:docs/prd/newfunction/_spec/arch-project-root.md
14 16 */
... ... @@ -209,31 +211,28 @@ function isSensitivePath(abs) {
209 211 }
210 212  
211 213 /**
212   - * 在文件系统上分配一个 root_path,但不写任何业务文件。
213   - * 仅 mkdir + 校验路径合法。
214   - * 业务文件(project.json / .gitignore / git init)由 LoopController.createProject 完成。
  214 + * `linked-new` / `linked-existing` 共用的校验逻辑,**不碰 registry / cache / pid**。
  215 + * 抽出来是因为 39 号文档的公司级 `target_dir` 机制也要用同一套校验(敏感路径黑名单、
  216 + * git 干净树……),但它是「一个公司固定一个目录,不进 DevLoop 的 pid 注册表」,
  217 + * 语义和 `allocateProjectRoot` 的 linked-* 分支(一对多、按 pid 登记)不同,不能直接复用
  218 + * 那个函数——于是只抽这一半(纯校验 + 解析 root_path),registry 写入留在调用方各自决定。
215 219 *
216   - * @returns { pid, root_path, mode }
  220 + * @returns {string} 校验通过后的绝对 root_path
  221 + * @throws 校验失败:错误 message/code 与原 `allocateProjectRoot` 内联版本逐字一致,
  222 + * 不要在这里改措辞——外部可能已经在依赖具体的 error.code(如 DIRTY_WORKTREE)。
217 223 */
218   -function allocateProjectRoot({ company_id, mode = 'sandbox', target_path }) {
219   - if (!company_id) throw new Error('company_id required');
220   -
221   - const pid = generatePid();
  224 +function validateLinkedTarget(target_path, mode) {
  225 + if (!target_path) throw new Error(`target_path required for ${mode}`);
  226 + if (!path.isAbsolute(target_path)) {
  227 + throw new Error('target_path must be absolute');
  228 + }
  229 + if (isSensitivePath(target_path)) {
  230 + const e = new Error(`sensitive_path: ${target_path} is in blacklist`);
  231 + e.code = 'SENSITIVE_PATH';
  232 + throw e;
  233 + }
222 234  
223   - let root_path;
224   - if (mode === 'sandbox') {
225   - root_path = path.join(DATA_DIR, 'companies', company_id, 'projects', pid);
226   - fs.mkdirSync(root_path, { recursive: true });
227   - } else if (mode === 'linked-new') {
228   - if (!target_path) throw new Error('target_path required for linked-new');
229   - if (!path.isAbsolute(target_path)) {
230   - throw new Error('target_path must be absolute');
231   - }
232   - if (isSensitivePath(target_path)) {
233   - const e = new Error(`sensitive_path: ${target_path} is in blacklist`);
234   - e.code = 'SENSITIVE_PATH';
235   - throw e;
236   - }
  235 + if (mode === 'linked-new') {
237 236 // 父目录必须存在
238 237 const parent = path.dirname(target_path);
239 238 if (!fs.existsSync(parent)) {
... ... @@ -248,26 +247,10 @@ function allocateProjectRoot({ company_id, mode = &#39;sandbox&#39;, target_path }) {
248 247 } else {
249 248 fs.mkdirSync(target_path, { recursive: false });
250 249 }
251   - root_path = path.resolve(target_path);
252   - // 注册到 linked-projects
253   - const reg = loadLinkedRegistry();
254   - reg[pid] = {
255   - root_path,
256   - mode: 'linked-new',
257   - company_id,
258   - registered_at: new Date().toISOString(),
259   - };
260   - saveLinkedRegistry(reg);
261   - } else if (mode === 'linked-existing') {
262   - if (!target_path) throw new Error('target_path required for linked-existing');
263   - if (!path.isAbsolute(target_path)) {
264   - throw new Error('target_path must be absolute');
265   - }
266   - if (isSensitivePath(target_path)) {
267   - const e = new Error(`sensitive_path: ${target_path} is in blacklist`);
268   - e.code = 'SENSITIVE_PATH';
269   - throw e;
270   - }
  250 + return path.resolve(target_path);
  251 + }
  252 +
  253 + if (mode === 'linked-existing') {
271 254 if (!fs.existsSync(target_path)) {
272 255 throw new Error(`target_path does not exist: ${target_path} (use linked-new for empty/new dir)`);
273 256 }
... ... @@ -279,7 +262,7 @@ function allocateProjectRoot({ company_id, mode = &#39;sandbox&#39;, target_path }) {
279 262 if (fs.existsSync(path.join(target_path, 'project.json'))) {
280 263 throw new Error(`target_path already contains a DevLoop project.json: ${target_path}`);
281 264 }
282   - // 已有 .git → 必须 working tree 干净(避免后续 Agent 的 git add -A 把用户脏改动扫进 DevLoop commit)
  265 + // 已有 .git → 必须 working tree 干净(避免后续 Agent 的 git add -A 把用户脏改动扫进自动 commit)
283 266 if (fs.existsSync(path.join(target_path, '.git'))) {
284 267 const { execFileSync } = require('child_process');
285 268 let porcelain;
... ... @@ -299,11 +282,35 @@ function allocateProjectRoot({ company_id, mode = &#39;sandbox&#39;, target_path }) {
299 282 throw err;
300 283 }
301 284 }
302   - root_path = path.resolve(target_path);
  285 + return path.resolve(target_path);
  286 + }
  287 +
  288 + throw new Error(`unknown mode: ${mode}`);
  289 +}
  290 +
  291 +/**
  292 + * 在文件系统上分配一个 root_path,但不写任何业务文件。
  293 + * 仅 mkdir + 校验路径合法。
  294 + * 业务文件(project.json / .gitignore / git init)由 LoopController.createProject 完成。
  295 + *
  296 + * @returns { pid, root_path, mode }
  297 + */
  298 +function allocateProjectRoot({ company_id, mode = 'sandbox', target_path }) {
  299 + if (!company_id) throw new Error('company_id required');
  300 +
  301 + const pid = generatePid();
  302 +
  303 + let root_path;
  304 + if (mode === 'sandbox') {
  305 + root_path = path.join(DATA_DIR, 'companies', company_id, 'projects', pid);
  306 + fs.mkdirSync(root_path, { recursive: true });
  307 + } else if (mode === 'linked-new' || mode === 'linked-existing') {
  308 + root_path = validateLinkedTarget(target_path, mode);
  309 + // 注册到 linked-projects
303 310 const reg = loadLinkedRegistry();
304 311 reg[pid] = {
305 312 root_path,
306   - mode: 'linked-existing',
  313 + mode,
307 314 company_id,
308 315 registered_at: new Date().toISOString(),
309 316 };
... ... @@ -319,6 +326,25 @@ function allocateProjectRoot({ company_id, mode = &#39;sandbox&#39;, target_path }) {
319 326 return { pid, root_path, mode };
320 327 }
321 328  
  329 +/**
  330 + * 39 号文档「公司级目标代码目录」专用:校验并解析一个**固定**目录,
  331 + * 不生成 pid、不写 `~/.devloop/linked-projects.json`、不进 DevLoop 的项目列表。
  332 + * 语义是「这家公司所有 run 永远落这一个目录」,跟 DevLoop 「一对多、每个项目一个 pid」
  333 + * 的项目模型是两回事,故意不复用那套注册表——见 `validateLinkedTarget` 头部注释。
  334 + *
  335 + * 每次调用都会重新校验(包括 git 干净树检查),不缓存结果:目标目录的状态可能在
  336 + * 两次调用之间被人手动改过(比如两次任务之间用户自己提交了一次),下次分配前应该
  337 + * 重新确认一次,而不是信任上一次的结论。
  338 + *
  339 + * @returns {string} 校验通过后的绝对 root_path
  340 + */
  341 +function resolveFixedTarget({ target_path, mode }) {
  342 + if (mode !== 'linked-new' && mode !== 'linked-existing') {
  343 + throw new Error(`unknown mode: ${mode} (must be linked-new | linked-existing)`);
  344 + }
  345 + return validateLinkedTarget(target_path, mode);
  346 +}
  347 +
322 348 function unregisterLinkedProject(pid) {
323 349 const reg = loadLinkedRegistry();
324 350 if (reg[pid]) {
... ... @@ -427,6 +453,8 @@ module.exports = {
427 453 resolveMemoryDir,
428 454 resolveCompanyDir,
429 455 allocateProjectRoot,
  456 + resolveFixedTarget,
  457 + validateLinkedTarget,
430 458 unregisterLinkedProject,
431 459 listProjects,
432 460 patchProjectMeta,
... ...
server/services/graph-engine.js
... ... @@ -3501,11 +3501,42 @@ function readProjectRootRecord(run) {
3501 3501 }
3502 3502  
3503 3503 /**
  3504 + * 39 号文档「公司级目标代码目录」:读 `company.json.target_dir`/`target_mode`。
  3505 + *
  3506 + * 走 `companyRootDir` 而不是 `company-service.get`——理由与 `work-graph-db.maxActiveRootRuns`
  3507 + * 头部注释一致(那边的判据是这个仓库唯一持有者,这里只是同一份配置的第二个读者,
  3508 + * 字段名必须逐字对齐,不能另起 camelCase):`companyRootDir` 认 GRAPH_RUNS_DIR 测试覆盖,
  3509 + * `company-service.get` 不认,用后者会导致设了 GRAPH_RUNS_DIR 的单测读到真仓库的
  3510 + * `data/companies/`,撞上同名公司就读到真配置。
  3511 + *
  3512 + * 读不到 / 格式不对一律当没配(回落 sandbox),不抛错——目标目录是可选加成,
  3513 + * 不能因为它读取失败就让整个项目根分配跟着炸。
  3514 + */
  3515 +function readCompanyTargetConfig(companyId) {
  3516 + if (!companyId) return null;
  3517 + try {
  3518 + const fp = path.join(companyRootDir(companyId), 'company.json');
  3519 + if (!fs.existsSync(fp)) return null;
  3520 + const cfg = JSON.parse(fs.readFileSync(fp, 'utf-8'));
  3521 + if (!cfg.target_dir || typeof cfg.target_dir !== 'string') return null;
  3522 + const target_mode = cfg.target_mode === 'linked-new' ? 'linked-new' : 'linked-existing';
  3523 + return { target_dir: cfg.target_dir, target_mode };
  3524 + } catch {
  3525 + return null;
  3526 + }
  3527 +}
  3528 +
  3529 +/**
3504 3530 * 真正在盘上开一个目录。
3505 3531 *
3506 3532 * GRAPH_RUNS_DIR 覆盖时项目根跟着走(`{RUNS_ROOT}/{rootRunId}/project`),理由与本文件
3507 3533 * 顶部 RUNS_ROOT 那段注释完全一样:run 的产物被重定向到临时目录,项目根却还落在仓库
3508 3534 * `data/companies/` 的话,测试每跑一次就往仓库里堆一个空项目目录。两处必须同源。
  3535 + *
  3536 + * 配了 `company.json.target_dir` 时,该公司**所有** run 解析到同一个固定 root_path,
  3537 + * 不再走 sandbox 那套「每 run 一个新 pid」——39 号文档 P5:公司级目标代码目录。
  3538 + * 校验失败(比如 git 树不干净)直接抛出,不静默降级回 sandbox:配错了必须看得见,
  3539 + * 不能让 agent 写到一个跟用户预期不一样的空目录里却毫无察觉。
3509 3540 */
3510 3541 function allocateRunProjectRoot(run) {
3511 3542 const runsRoot = runsRootOverride();
... ... @@ -3514,6 +3545,13 @@ function allocateRunProjectRoot(run) {
3514 3545 fs.mkdirSync(root_path, { recursive: true });
3515 3546 return { pid: `p-run-${run.root_run_id}`, root_path, mode: 'runs-dir-override' };
3516 3547 }
  3548 + const targetCfg = readCompanyTargetConfig(run.company_id);
  3549 + if (targetCfg) {
  3550 + const root_path = require('./devloop/path-resolver').resolveFixedTarget({
  3551 + target_path: targetCfg.target_dir, mode: targetCfg.target_mode,
  3552 + });
  3553 + return { pid: 'company-target', root_path, mode: targetCfg.target_mode };
  3554 + }
3517 3555 return require('./devloop/path-resolver').allocateProjectRoot({
3518 3556 company_id: run.company_id, mode: 'sandbox',
3519 3557 });
... ...
server/services/work-graph-db.js
... ... @@ -102,17 +102,34 @@ function warnMaxParallelOnce(companyId, raw, why) {
102 102 function maxActiveRootRuns(companyId) {
103 103 if (!companyId) return DEFAULT_MAX_ACTIVE_ROOT_RUNS;
104 104 let raw;
  105 + let targetDir;
105 106 try {
106 107 const { companyRootDir } = require('./company-paths');
107 108 const fp = require('path').join(companyRootDir(companyId), 'company.json');
108 109 const fs = require('fs');
109 110 if (!fs.existsSync(fp)) return DEFAULT_MAX_ACTIVE_ROOT_RUNS;
110   - raw = JSON.parse(fs.readFileSync(fp, 'utf-8')).max_parallel_tasks;
  111 + const cfg = JSON.parse(fs.readFileSync(fp, 'utf-8'));
  112 + raw = cfg.max_parallel_tasks;
  113 + targetDir = cfg.target_dir;
111 114 } catch (err) {
112 115 // company.json 坏了不该让一次出队变成 500:回落缺省,但必须留声
113 116 warnMaxParallelOnce(companyId, null, `读取失败(${err.message}),按 ${DEFAULT_MAX_ACTIVE_ROOT_RUNS} 处理`);
114 117 return DEFAULT_MAX_ACTIVE_ROOT_RUNS;
115 118 }
  119 + // 39 号文档 P5:配了公司级目标代码目录(多个 run 共享同一份真实工作区)时,
  120 + // 强制把并行度钉死为 1,不管 max_parallel_tasks 配的是什么——`linked-existing`/
  121 + // `linked-new` 那道「进入时 working tree 干净」的校验只在分配那一刻查一次,
  122 + // 不是运行期互斥锁,两个 coder agent 同时写同一个 git 仓库会真的冲突。
  123 + // ponytail: 天花板是「配了 target_dir 的公司同一时间只能有一个任务真正动代码」;
  124 + // 真要多任务并发写同一仓库,需要 git worktree + 完成时合并那一层,是独立工作量。
  125 + if (typeof targetDir === 'string' && targetDir) {
  126 + const key = `${companyId}:target_dir`;
  127 + if (!warnedMaxParallel.has(key)) {
  128 + warnedMaxParallel.add(key);
  129 + console.warn(`[work-graph-db] company ${companyId} 配了 target_dir=${JSON.stringify(targetDir)},并行度强制钉为 1(忽略 max_parallel_tasks=${JSON.stringify(raw)})`);
  130 + }
  131 + return 1;
  132 + }
116 133 if (raw === undefined || raw === null) return DEFAULT_MAX_ACTIVE_ROOT_RUNS;
117 134 if (!Number.isInteger(raw) || raw < 1) {
118 135 warnMaxParallelOnce(companyId, raw, `不是 ≥1 的整数,按 ${DEFAULT_MAX_ACTIVE_ROOT_RUNS} 处理`);
... ...
server/tests/devloop/path-resolver.test.js
... ... @@ -162,6 +162,70 @@ describe(&#39;pathResolver&#39;, () =&gt; {
162 162 });
163 163 });
164 164  
  165 + describe('resolveFixedTarget(39 号文档:公司级目标代码目录,不进 registry)', () => {
  166 + it('linked-existing:校验通过时返回 root_path,不写 linked-projects registry', () => {
  167 + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-fixed-le-'));
  168 + fs.writeFileSync(path.join(dir, 'README.md'), '# user');
  169 + const before = fs.existsSync(pathResolver.LINKED_REGISTRY)
  170 + ? fs.readFileSync(pathResolver.LINKED_REGISTRY, 'utf8') : null;
  171 + const root = pathResolver.resolveFixedTarget({ target_path: dir, mode: 'linked-existing' });
  172 + expect(root).toBe(dir);
  173 + const after = fs.existsSync(pathResolver.LINKED_REGISTRY)
  174 + ? fs.readFileSync(pathResolver.LINKED_REGISTRY, 'utf8') : null;
  175 + expect(after).toBe(before); // registry 文件内容没变——没有 pid 被登记进去
  176 + expect(pathResolver.findProjectByPath(dir)).toBe(null); // 也没有进内存 cache
  177 + });
  178 +
  179 + it('linked-existing:git 树不干净照样拒绝(复用同一套校验,报同一个 error.code)', () => {
  180 + const { execFileSync } = require('child_process');
  181 + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-fixed-dirty-'));
  182 + execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: dir });
  183 + execFileSync('git', ['config', 'user.email', 't@l'], { cwd: dir });
  184 + execFileSync('git', ['config', 'user.name', 't'], { cwd: dir });
  185 + fs.writeFileSync(path.join(dir, 'a.txt'), 'one');
  186 + execFileSync('git', ['add', '-A'], { cwd: dir });
  187 + execFileSync('git', ['commit', '-q', '-m', 'init'], { cwd: dir });
  188 + fs.writeFileSync(path.join(dir, 'b.txt'), 'untracked');
  189 + let caught = null;
  190 + try {
  191 + pathResolver.resolveFixedTarget({ target_path: dir, mode: 'linked-existing' });
  192 + } catch (e) { caught = e; }
  193 + expect(caught).not.toBe(null);
  194 + expect(caught.message).toMatch(/uncommitted changes/);
  195 + expect(caught.code).toBe('DIRTY_WORKTREE');
  196 + });
  197 +
  198 + it('linked-existing:路径不存在时报错,不静默创建/降级', () => {
  199 + const fake = path.join(os.tmpdir(), 'devloop-fixed-le-nope');
  200 + fs.rmSync(fake, { recursive: true, force: true });
  201 + expect(() =>
  202 + pathResolver.resolveFixedTarget({ target_path: fake, mode: 'linked-existing' })
  203 + ).toThrow(/does not exist/);
  204 + expect(fs.existsSync(fake)).toBe(false); // 没有被顺手 mkdir 出来
  205 + });
  206 +
  207 + it('linked-new:空目录直接接受,同样不进 registry', () => {
  208 + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-fixed-new-'));
  209 + fs.rmSync(dir, { recursive: true, force: true }); // 留空壳测试「不存在则新建」
  210 + const root = pathResolver.resolveFixedTarget({ target_path: dir, mode: 'linked-new' });
  211 + expect(root).toBe(dir);
  212 + expect(fs.existsSync(dir)).toBe(true);
  213 + expect(pathResolver.findProjectByPath(dir)).toBe(null);
  214 + });
  215 +
  216 + it('拒绝未知 mode', () => {
  217 + expect(() =>
  218 + pathResolver.resolveFixedTarget({ target_path: '/tmp/x', mode: 'sandbox' })
  219 + ).toThrow(/unknown mode/);
  220 + });
  221 +
  222 + it('敏感路径黑名单同样生效', () => {
  223 + expect(() =>
  224 + pathResolver.resolveFixedTarget({ target_path: '/etc/whatever', mode: 'linked-existing' })
  225 + ).toThrow(/sensitive_path/);
  226 + });
  227 + });
  228 +
165 229 describe('resolveProjectRoot + 派生路径', () => {
166 230 it('throws on unknown pid', () => {
167 231 expect(() => pathResolver.resolveProjectRoot('p-nonexistent')).toThrow(/Unknown project/);
... ...
server/tests/unit/graph-engine-target-dir.test.js 0 → 100644
  1 +/**
  2 + * Unit Tests — graph-engine 的公司级目标代码目录(39 号文档 P5)
  3 + *
  4 + * 验的是 `ensureProjectRoot` 在配了 `company.json.target_dir` 时的行为:
  5 + * T1 没配 target_dir:走老的 sandbox 路径,行为不变(零回归)
  6 + * T2 配了 target_dir:两个不同 root_run_id 的 run(同一公司)解析到同一个 root_path
  7 + * T3 target_dir 校验失败(git 树不干净):ensureProjectRoot 优雅降级成 null + timeline 记录,
  8 + * 不静默 fallback 回 sandbox
  9 + * T4 GRAPH_RUNS_DIR 测试覆盖优先级高于 target_dir(不能让业务配置绕过测试隔离)
  10 + *
  11 + * `ensureProjectRoot` 本身不碰 SQLite(只读写文件系统),所以不需要像
  12 + * graph-boundary.test.js 那样做 GRAPH_DB_PATH 隔离;但会真的往 `data/companies/` 写
  13 + * 东西(company.json 走 companyRootDir,不认测试注入,除非设 GRAPH_RUNS_DIR——T1-T3
  14 + * 就是要验「不设 GRAPH_RUNS_DIR 时的生产行为」,所以只能用真路径 + 用后即删,
  15 + * 手法同 path-resolver.test.js 的 sandbox 用例)。
  16 + */
  17 +const fs = require('fs');
  18 +const os = require('os');
  19 +const path = require('path');
  20 +
  21 +const engine = require('../../services/graph-engine');
  22 +const { companyRootDir } = require('../../services/company-paths');
  23 +
  24 +describe('graph-engine 公司级目标代码目录(target_dir)', () => {
  25 + const cleanups = [];
  26 +
  27 + afterEach(() => {
  28 + delete process.env.GRAPH_RUNS_DIR;
  29 + while (cleanups.length) {
  30 + const p = cleanups.pop();
  31 + try { fs.rmSync(p, { recursive: true, force: true }); } catch { /* best-effort */ }
  32 + }
  33 + });
  34 +
  35 + function writeCompany(companyId, patch) {
  36 + const dir = companyRootDir(companyId);
  37 + fs.mkdirSync(dir, { recursive: true });
  38 + cleanups.push(dir);
  39 + fs.writeFileSync(path.join(dir, 'company.json'), JSON.stringify({ id: companyId, name: companyId, ...patch }));
  40 + return dir;
  41 + }
  42 +
  43 + function fakeRun(companyId, rootRunId) {
  44 + return { root_run_id: rootRunId, company_id: companyId };
  45 + }
  46 +
  47 + it('T1 没配 target_dir:走 sandbox,行为跟改动前一样', () => {
  48 + const cid = 'tdcfg-t1';
  49 + writeCompany(cid, {});
  50 + const run = fakeRun(cid, 'r-t1');
  51 + const rec = engine.ensureProjectRoot({ run });
  52 + expect(rec).not.toBe(null);
  53 + expect(rec.mode).toBe('sandbox');
  54 + expect(rec.root_path).toContain(path.join('data', 'companies', cid, 'projects'));
  55 + expect(fs.existsSync(rec.root_path)).toBe(true);
  56 + cleanups.push(path.join(companyRootDir(cid), '..', 'runs')); // 无害;下面按 run 目录精确清
  57 + cleanups.push(path.dirname(path.dirname(rec.root_path))); // projects/ 目录
  58 + cleanups.push(path.join(companyRootDir(cid), 'runs', run.root_run_id));
  59 + });
  60 +
  61 + it('T2 配了 target_dir:同一公司两个不同 run 解析到同一个 root_path,且不产生 pid 目录', () => {
  62 + const cid = 'tdcfg-t2';
  63 + const target = fs.mkdtempSync(path.join(os.tmpdir(), 'graph-engine-target-'));
  64 + cleanups.push(target);
  65 + fs.writeFileSync(path.join(target, 'README.md'), '# existing repo, no .git');
  66 + writeCompany(cid, { target_dir: target, target_mode: 'linked-existing' });
  67 +
  68 + const runA = fakeRun(cid, 'r-t2-a');
  69 + const runB = fakeRun(cid, 'r-t2-b');
  70 + cleanups.push(path.join(companyRootDir(cid), 'runs', runA.root_run_id));
  71 + cleanups.push(path.join(companyRootDir(cid), 'runs', runB.root_run_id));
  72 +
  73 + const recA = engine.ensureProjectRoot({ run: runA });
  74 + const recB = engine.ensureProjectRoot({ run: runB });
  75 +
  76 + expect(recA).not.toBe(null);
  77 + expect(recB).not.toBe(null);
  78 + expect(recA.root_path).toBe(target);
  79 + expect(recB.root_path).toBe(target);
  80 + expect(recA.mode).toBe('linked-existing');
  81 + // 没有新的 sandbox pid 目录被造出来
  82 + const projectsDir = path.join(companyRootDir(cid), 'projects');
  83 + expect(fs.existsSync(projectsDir)).toBe(false);
  84 + });
  85 +
  86 + it('T2b target_mode 缺省时按 linked-existing 处理(目标目录本来就该已经存在)', () => {
  87 + const cid = 'tdcfg-t2b';
  88 + const target = fs.mkdtempSync(path.join(os.tmpdir(), 'graph-engine-target-'));
  89 + cleanups.push(target);
  90 + writeCompany(cid, { target_dir: target }); // 不传 target_mode
  91 +
  92 + const run = fakeRun(cid, 'r-t2b');
  93 + cleanups.push(path.join(companyRootDir(cid), 'runs', run.root_run_id));
  94 +
  95 + const rec = engine.ensureProjectRoot({ run });
  96 + expect(rec.root_path).toBe(target);
  97 + expect(rec.mode).toBe('linked-existing');
  98 + });
  99 +
  100 + it('T3 target_dir 校验失败(git 树不干净):不静默降级回 sandbox,返回 null', () => {
  101 + const { execFileSync } = require('child_process');
  102 + const cid = 'tdcfg-t3';
  103 + const target = fs.mkdtempSync(path.join(os.tmpdir(), 'graph-engine-target-dirty-'));
  104 + cleanups.push(target);
  105 + execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: target });
  106 + execFileSync('git', ['config', 'user.email', 't@l'], { cwd: target });
  107 + execFileSync('git', ['config', 'user.name', 't'], { cwd: target });
  108 + fs.writeFileSync(path.join(target, 'a.txt'), 'one');
  109 + execFileSync('git', ['add', '-A'], { cwd: target });
  110 + execFileSync('git', ['commit', '-q', '-m', 'init'], { cwd: target });
  111 + fs.writeFileSync(path.join(target, 'b.txt'), 'untracked, dirty tree');
  112 +
  113 + writeCompany(cid, { target_dir: target, target_mode: 'linked-existing' });
  114 + const run = fakeRun(cid, 'r-t3');
  115 + cleanups.push(path.join(companyRootDir(cid), 'runs', run.root_run_id));
  116 +
  117 + const rec = engine.ensureProjectRoot({ run });
  118 + expect(rec).toBe(null); // 优雅失败,不是编一个假 sandbox 路径
  119 +
  120 + const projectsDir = path.join(companyRootDir(cid), 'projects');
  121 + expect(fs.existsSync(projectsDir)).toBe(false); // 确认真的没有偷偷退化成 sandbox
  122 + });
  123 +
  124 + it('T4 GRAPH_RUNS_DIR 测试覆盖优先级高于 target_dir(测试隔离不能被业务配置绕过)', () => {
  125 + const cid = 'tdcfg-t4';
  126 + const target = fs.mkdtempSync(path.join(os.tmpdir(), 'graph-engine-target-override-'));
  127 + cleanups.push(target);
  128 + writeCompany(cid, { target_dir: target, target_mode: 'linked-existing' });
  129 +
  130 + const runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'graph-engine-runs-override-'));
  131 + cleanups.push(runsDir);
  132 + process.env.GRAPH_RUNS_DIR = runsDir;
  133 +
  134 + const run = fakeRun(cid, 'r-t4');
  135 + const rec = engine.ensureProjectRoot({ run });
  136 + expect(rec.mode).toBe('runs-dir-override');
  137 + expect(rec.root_path).toBe(path.join(runsDir, run.root_run_id, 'project'));
  138 + expect(rec.root_path).not.toBe(target);
  139 + });
  140 +});
... ...
server/tests/unit/task-slots-parallel.test.js
... ... @@ -587,6 +587,53 @@ describe(&#39;批 4 · 公司级执行位(max_parallel_tasks)&#39;, () =&gt; {
587 587 });
588 588  
589 589 // ────────────────────────────────────────────────────────────────
  590 + // ⑤bis 39 号文档 P5:配了 target_dir 强制并行度=1,不管 max_parallel_tasks 说什么
  591 + // ────────────────────────────────────────────────────────────────
  592 + describe('⑤bis 公司级目标代码目录:target_dir 存在时并行度钉死为 1', () => {
  593 + /** 同一家公司同时配 target_dir 和一个 >1 的 max_parallel_tasks */
  594 + function setTargetDir(companyId, targetDir, maxParallel) {
  595 + const { companyRootDir } = require('../../services/company-paths');
  596 + const dir = companyRootDir(companyId);
  597 + fs.mkdirSync(dir, { recursive: true });
  598 + const cfg = { id: companyId, name: companyId, target_dir: targetDir };
  599 + if (maxParallel !== undefined) cfg.max_parallel_tasks = maxParallel;
  600 + fs.writeFileSync(path.join(dir, 'company.json'), JSON.stringify(cfg));
  601 + return companyId;
  602 + }
  603 +
  604 + it('target_dir 非空 + max_parallel_tasks=5 → 仍然返回 1', () => {
  605 + const cid = setTargetDir(nextCompany(), '/tmp/some-shared-repo', 5);
  606 + expect(wdb.maxActiveRootRuns(cid)).toBe(1);
  607 + });
  608 +
  609 + it('没配 target_dir 时 max_parallel_tasks 照常生效(回归:这条改动不该动到没配的公司)', () => {
  610 + const cid = setMaxParallel(nextCompany(), 3);
  611 + expect(wdb.maxActiveRootRuns(cid)).toBe(3);
  612 + });
  613 +
  614 + it('target_dir 留声一次(不静默),且是可读的一次性日志,不刷屏', () => {
  615 + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
  616 + try {
  617 + const cid = setTargetDir(nextCompany(), '/tmp/shared-repo-log-test', 9);
  618 + expect(wdb.maxActiveRootRuns(cid)).toBe(1);
  619 + expect(wdb.maxActiveRootRuns(cid)).toBe(1);
  620 + expect(wdb.maxActiveRootRuns(cid)).toBe(1);
  621 + expect(warn).toHaveBeenCalledTimes(1);
  622 + expect(String(warn.mock.calls[0][0])).toContain('target_dir');
  623 + } finally {
  624 + warn.mockRestore();
  625 + }
  626 + });
  627 +
  628 + it('运行期把 target_dir 清空后,恢复读 max_parallel_tasks(调用时读盘,不是缓存快照)', () => {
  629 + const cid = setTargetDir(nextCompany(), '/tmp/shared-repo-toggle', 4);
  630 + expect(wdb.maxActiveRootRuns(cid)).toBe(1);
  631 + setMaxParallel(cid, 4); // 覆写掉 target_dir
  632 + expect(wdb.maxActiveRootRuns(cid)).toBe(4);
  633 + });
  634 + });
  635 +
  636 + // ────────────────────────────────────────────────────────────────
590 637 // ⑥ DROP 迁移:存量库(带旧索引)启动一次就没了
591 638 // ────────────────────────────────────────────────────────────────
592 639 describe('⑥ 迁移:带 idx_graph_runs_one_active_root 的存量库', () => {
... ...