diff --git a/server/services/devloop/path-resolver.js b/server/services/devloop/path-resolver.js index 6bbec10..73636d1 100644 --- a/server/services/devloop/path-resolver.js +++ b/server/services/devloop/path-resolver.js @@ -7,8 +7,10 @@ * * 支持三种 mode(见 arch-project-root.html §②): * - sandbox : 自动生成 root_path = `{dataDir}/companies/{cid}/projects/{pid}/` - * - linked-new : 用户指定 root_path(绝对路径),注册到 ~/.devloop/linked-projects.json - * - linked-existing : v2,本文件预留接口但 register 阶段拒收 + * - linked-new : 用户指定一个空/新建的 root_path(绝对路径),注册到 ~/.devloop/linked-projects.json + * - linked-existing : 用户指定一个已存在的目录,接管为项目根,同样注册到 ~/.devloop/linked-projects.json + * (已实现,校验见 validateLinkedTarget:路径存在且是目录、未挂载过 DevLoop 项目、 + * 有 .git 则 working tree 必须干净) * * 详尽规格:docs/prd/newfunction/_spec/arch-project-root.md */ @@ -209,31 +211,28 @@ function isSensitivePath(abs) { } /** - * 在文件系统上分配一个 root_path,但不写任何业务文件。 - * 仅 mkdir + 校验路径合法。 - * 业务文件(project.json / .gitignore / git init)由 LoopController.createProject 完成。 + * `linked-new` / `linked-existing` 共用的校验逻辑,**不碰 registry / cache / pid**。 + * 抽出来是因为 39 号文档的公司级 `target_dir` 机制也要用同一套校验(敏感路径黑名单、 + * git 干净树……),但它是「一个公司固定一个目录,不进 DevLoop 的 pid 注册表」, + * 语义和 `allocateProjectRoot` 的 linked-* 分支(一对多、按 pid 登记)不同,不能直接复用 + * 那个函数——于是只抽这一半(纯校验 + 解析 root_path),registry 写入留在调用方各自决定。 * - * @returns { pid, root_path, mode } + * @returns {string} 校验通过后的绝对 root_path + * @throws 校验失败:错误 message/code 与原 `allocateProjectRoot` 内联版本逐字一致, + * 不要在这里改措辞——外部可能已经在依赖具体的 error.code(如 DIRTY_WORKTREE)。 */ -function allocateProjectRoot({ company_id, mode = 'sandbox', target_path }) { - if (!company_id) throw new Error('company_id required'); - - const pid = generatePid(); +function validateLinkedTarget(target_path, mode) { + if (!target_path) throw new Error(`target_path required for ${mode}`); + if (!path.isAbsolute(target_path)) { + throw new Error('target_path must be absolute'); + } + if (isSensitivePath(target_path)) { + const e = new Error(`sensitive_path: ${target_path} is in blacklist`); + e.code = 'SENSITIVE_PATH'; + throw e; + } - let root_path; - if (mode === 'sandbox') { - root_path = path.join(DATA_DIR, 'companies', company_id, 'projects', pid); - fs.mkdirSync(root_path, { recursive: true }); - } else if (mode === 'linked-new') { - if (!target_path) throw new Error('target_path required for linked-new'); - if (!path.isAbsolute(target_path)) { - throw new Error('target_path must be absolute'); - } - if (isSensitivePath(target_path)) { - const e = new Error(`sensitive_path: ${target_path} is in blacklist`); - e.code = 'SENSITIVE_PATH'; - throw e; - } + if (mode === 'linked-new') { // 父目录必须存在 const parent = path.dirname(target_path); if (!fs.existsSync(parent)) { @@ -248,26 +247,10 @@ function allocateProjectRoot({ company_id, mode = 'sandbox', target_path }) { } else { fs.mkdirSync(target_path, { recursive: false }); } - root_path = path.resolve(target_path); - // 注册到 linked-projects - const reg = loadLinkedRegistry(); - reg[pid] = { - root_path, - mode: 'linked-new', - company_id, - registered_at: new Date().toISOString(), - }; - saveLinkedRegistry(reg); - } else if (mode === 'linked-existing') { - if (!target_path) throw new Error('target_path required for linked-existing'); - if (!path.isAbsolute(target_path)) { - throw new Error('target_path must be absolute'); - } - if (isSensitivePath(target_path)) { - const e = new Error(`sensitive_path: ${target_path} is in blacklist`); - e.code = 'SENSITIVE_PATH'; - throw e; - } + return path.resolve(target_path); + } + + if (mode === 'linked-existing') { if (!fs.existsSync(target_path)) { throw new Error(`target_path does not exist: ${target_path} (use linked-new for empty/new dir)`); } @@ -279,7 +262,7 @@ function allocateProjectRoot({ company_id, mode = 'sandbox', target_path }) { if (fs.existsSync(path.join(target_path, 'project.json'))) { throw new Error(`target_path already contains a DevLoop project.json: ${target_path}`); } - // 已有 .git → 必须 working tree 干净(避免后续 Agent 的 git add -A 把用户脏改动扫进 DevLoop commit) + // 已有 .git → 必须 working tree 干净(避免后续 Agent 的 git add -A 把用户脏改动扫进自动 commit) if (fs.existsSync(path.join(target_path, '.git'))) { const { execFileSync } = require('child_process'); let porcelain; @@ -299,11 +282,35 @@ function allocateProjectRoot({ company_id, mode = 'sandbox', target_path }) { throw err; } } - root_path = path.resolve(target_path); + return path.resolve(target_path); + } + + throw new Error(`unknown mode: ${mode}`); +} + +/** + * 在文件系统上分配一个 root_path,但不写任何业务文件。 + * 仅 mkdir + 校验路径合法。 + * 业务文件(project.json / .gitignore / git init)由 LoopController.createProject 完成。 + * + * @returns { pid, root_path, mode } + */ +function allocateProjectRoot({ company_id, mode = 'sandbox', target_path }) { + if (!company_id) throw new Error('company_id required'); + + const pid = generatePid(); + + let root_path; + if (mode === 'sandbox') { + root_path = path.join(DATA_DIR, 'companies', company_id, 'projects', pid); + fs.mkdirSync(root_path, { recursive: true }); + } else if (mode === 'linked-new' || mode === 'linked-existing') { + root_path = validateLinkedTarget(target_path, mode); + // 注册到 linked-projects const reg = loadLinkedRegistry(); reg[pid] = { root_path, - mode: 'linked-existing', + mode, company_id, registered_at: new Date().toISOString(), }; @@ -319,6 +326,25 @@ function allocateProjectRoot({ company_id, mode = 'sandbox', target_path }) { return { pid, root_path, mode }; } +/** + * 39 号文档「公司级目标代码目录」专用:校验并解析一个**固定**目录, + * 不生成 pid、不写 `~/.devloop/linked-projects.json`、不进 DevLoop 的项目列表。 + * 语义是「这家公司所有 run 永远落这一个目录」,跟 DevLoop 「一对多、每个项目一个 pid」 + * 的项目模型是两回事,故意不复用那套注册表——见 `validateLinkedTarget` 头部注释。 + * + * 每次调用都会重新校验(包括 git 干净树检查),不缓存结果:目标目录的状态可能在 + * 两次调用之间被人手动改过(比如两次任务之间用户自己提交了一次),下次分配前应该 + * 重新确认一次,而不是信任上一次的结论。 + * + * @returns {string} 校验通过后的绝对 root_path + */ +function resolveFixedTarget({ target_path, mode }) { + if (mode !== 'linked-new' && mode !== 'linked-existing') { + throw new Error(`unknown mode: ${mode} (must be linked-new | linked-existing)`); + } + return validateLinkedTarget(target_path, mode); +} + function unregisterLinkedProject(pid) { const reg = loadLinkedRegistry(); if (reg[pid]) { @@ -427,6 +453,8 @@ module.exports = { resolveMemoryDir, resolveCompanyDir, allocateProjectRoot, + resolveFixedTarget, + validateLinkedTarget, unregisterLinkedProject, listProjects, patchProjectMeta, diff --git a/server/services/graph-engine.js b/server/services/graph-engine.js index 88615c5..eb31e36 100644 --- a/server/services/graph-engine.js +++ b/server/services/graph-engine.js @@ -3501,11 +3501,42 @@ function readProjectRootRecord(run) { } /** + * 39 号文档「公司级目标代码目录」:读 `company.json.target_dir`/`target_mode`。 + * + * 走 `companyRootDir` 而不是 `company-service.get`——理由与 `work-graph-db.maxActiveRootRuns` + * 头部注释一致(那边的判据是这个仓库唯一持有者,这里只是同一份配置的第二个读者, + * 字段名必须逐字对齐,不能另起 camelCase):`companyRootDir` 认 GRAPH_RUNS_DIR 测试覆盖, + * `company-service.get` 不认,用后者会导致设了 GRAPH_RUNS_DIR 的单测读到真仓库的 + * `data/companies/`,撞上同名公司就读到真配置。 + * + * 读不到 / 格式不对一律当没配(回落 sandbox),不抛错——目标目录是可选加成, + * 不能因为它读取失败就让整个项目根分配跟着炸。 + */ +function readCompanyTargetConfig(companyId) { + if (!companyId) return null; + try { + const fp = path.join(companyRootDir(companyId), 'company.json'); + if (!fs.existsSync(fp)) return null; + const cfg = JSON.parse(fs.readFileSync(fp, 'utf-8')); + if (!cfg.target_dir || typeof cfg.target_dir !== 'string') return null; + const target_mode = cfg.target_mode === 'linked-new' ? 'linked-new' : 'linked-existing'; + return { target_dir: cfg.target_dir, target_mode }; + } catch { + return null; + } +} + +/** * 真正在盘上开一个目录。 * * GRAPH_RUNS_DIR 覆盖时项目根跟着走(`{RUNS_ROOT}/{rootRunId}/project`),理由与本文件 * 顶部 RUNS_ROOT 那段注释完全一样:run 的产物被重定向到临时目录,项目根却还落在仓库 * `data/companies/` 的话,测试每跑一次就往仓库里堆一个空项目目录。两处必须同源。 + * + * 配了 `company.json.target_dir` 时,该公司**所有** run 解析到同一个固定 root_path, + * 不再走 sandbox 那套「每 run 一个新 pid」——39 号文档 P5:公司级目标代码目录。 + * 校验失败(比如 git 树不干净)直接抛出,不静默降级回 sandbox:配错了必须看得见, + * 不能让 agent 写到一个跟用户预期不一样的空目录里却毫无察觉。 */ function allocateRunProjectRoot(run) { const runsRoot = runsRootOverride(); @@ -3514,6 +3545,13 @@ function allocateRunProjectRoot(run) { fs.mkdirSync(root_path, { recursive: true }); return { pid: `p-run-${run.root_run_id}`, root_path, mode: 'runs-dir-override' }; } + const targetCfg = readCompanyTargetConfig(run.company_id); + if (targetCfg) { + const root_path = require('./devloop/path-resolver').resolveFixedTarget({ + target_path: targetCfg.target_dir, mode: targetCfg.target_mode, + }); + return { pid: 'company-target', root_path, mode: targetCfg.target_mode }; + } return require('./devloop/path-resolver').allocateProjectRoot({ company_id: run.company_id, mode: 'sandbox', }); diff --git a/server/services/work-graph-db.js b/server/services/work-graph-db.js index 5934a0d..21de6f5 100644 --- a/server/services/work-graph-db.js +++ b/server/services/work-graph-db.js @@ -102,17 +102,34 @@ function warnMaxParallelOnce(companyId, raw, why) { function maxActiveRootRuns(companyId) { if (!companyId) return DEFAULT_MAX_ACTIVE_ROOT_RUNS; let raw; + let targetDir; try { const { companyRootDir } = require('./company-paths'); const fp = require('path').join(companyRootDir(companyId), 'company.json'); const fs = require('fs'); if (!fs.existsSync(fp)) return DEFAULT_MAX_ACTIVE_ROOT_RUNS; - raw = JSON.parse(fs.readFileSync(fp, 'utf-8')).max_parallel_tasks; + const cfg = JSON.parse(fs.readFileSync(fp, 'utf-8')); + raw = cfg.max_parallel_tasks; + targetDir = cfg.target_dir; } catch (err) { // company.json 坏了不该让一次出队变成 500:回落缺省,但必须留声 warnMaxParallelOnce(companyId, null, `读取失败(${err.message}),按 ${DEFAULT_MAX_ACTIVE_ROOT_RUNS} 处理`); return DEFAULT_MAX_ACTIVE_ROOT_RUNS; } + // 39 号文档 P5:配了公司级目标代码目录(多个 run 共享同一份真实工作区)时, + // 强制把并行度钉死为 1,不管 max_parallel_tasks 配的是什么——`linked-existing`/ + // `linked-new` 那道「进入时 working tree 干净」的校验只在分配那一刻查一次, + // 不是运行期互斥锁,两个 coder agent 同时写同一个 git 仓库会真的冲突。 + // ponytail: 天花板是「配了 target_dir 的公司同一时间只能有一个任务真正动代码」; + // 真要多任务并发写同一仓库,需要 git worktree + 完成时合并那一层,是独立工作量。 + if (typeof targetDir === 'string' && targetDir) { + const key = `${companyId}:target_dir`; + if (!warnedMaxParallel.has(key)) { + warnedMaxParallel.add(key); + console.warn(`[work-graph-db] company ${companyId} 配了 target_dir=${JSON.stringify(targetDir)},并行度强制钉为 1(忽略 max_parallel_tasks=${JSON.stringify(raw)})`); + } + return 1; + } if (raw === undefined || raw === null) return DEFAULT_MAX_ACTIVE_ROOT_RUNS; if (!Number.isInteger(raw) || raw < 1) { warnMaxParallelOnce(companyId, raw, `不是 ≥1 的整数,按 ${DEFAULT_MAX_ACTIVE_ROOT_RUNS} 处理`); diff --git a/server/tests/devloop/path-resolver.test.js b/server/tests/devloop/path-resolver.test.js index 453e0b7..13d944f 100644 --- a/server/tests/devloop/path-resolver.test.js +++ b/server/tests/devloop/path-resolver.test.js @@ -162,6 +162,70 @@ describe('pathResolver', () => { }); }); + describe('resolveFixedTarget(39 号文档:公司级目标代码目录,不进 registry)', () => { + it('linked-existing:校验通过时返回 root_path,不写 linked-projects registry', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-fixed-le-')); + fs.writeFileSync(path.join(dir, 'README.md'), '# user'); + const before = fs.existsSync(pathResolver.LINKED_REGISTRY) + ? fs.readFileSync(pathResolver.LINKED_REGISTRY, 'utf8') : null; + const root = pathResolver.resolveFixedTarget({ target_path: dir, mode: 'linked-existing' }); + expect(root).toBe(dir); + const after = fs.existsSync(pathResolver.LINKED_REGISTRY) + ? fs.readFileSync(pathResolver.LINKED_REGISTRY, 'utf8') : null; + expect(after).toBe(before); // registry 文件内容没变——没有 pid 被登记进去 + expect(pathResolver.findProjectByPath(dir)).toBe(null); // 也没有进内存 cache + }); + + it('linked-existing:git 树不干净照样拒绝(复用同一套校验,报同一个 error.code)', () => { + const { execFileSync } = require('child_process'); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-fixed-dirty-')); + execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: dir }); + execFileSync('git', ['config', 'user.email', 't@l'], { cwd: dir }); + execFileSync('git', ['config', 'user.name', 't'], { cwd: dir }); + fs.writeFileSync(path.join(dir, 'a.txt'), 'one'); + execFileSync('git', ['add', '-A'], { cwd: dir }); + execFileSync('git', ['commit', '-q', '-m', 'init'], { cwd: dir }); + fs.writeFileSync(path.join(dir, 'b.txt'), 'untracked'); + let caught = null; + try { + pathResolver.resolveFixedTarget({ target_path: dir, mode: 'linked-existing' }); + } catch (e) { caught = e; } + expect(caught).not.toBe(null); + expect(caught.message).toMatch(/uncommitted changes/); + expect(caught.code).toBe('DIRTY_WORKTREE'); + }); + + it('linked-existing:路径不存在时报错,不静默创建/降级', () => { + const fake = path.join(os.tmpdir(), 'devloop-fixed-le-nope'); + fs.rmSync(fake, { recursive: true, force: true }); + expect(() => + pathResolver.resolveFixedTarget({ target_path: fake, mode: 'linked-existing' }) + ).toThrow(/does not exist/); + expect(fs.existsSync(fake)).toBe(false); // 没有被顺手 mkdir 出来 + }); + + it('linked-new:空目录直接接受,同样不进 registry', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-fixed-new-')); + fs.rmSync(dir, { recursive: true, force: true }); // 留空壳测试「不存在则新建」 + const root = pathResolver.resolveFixedTarget({ target_path: dir, mode: 'linked-new' }); + expect(root).toBe(dir); + expect(fs.existsSync(dir)).toBe(true); + expect(pathResolver.findProjectByPath(dir)).toBe(null); + }); + + it('拒绝未知 mode', () => { + expect(() => + pathResolver.resolveFixedTarget({ target_path: '/tmp/x', mode: 'sandbox' }) + ).toThrow(/unknown mode/); + }); + + it('敏感路径黑名单同样生效', () => { + expect(() => + pathResolver.resolveFixedTarget({ target_path: '/etc/whatever', mode: 'linked-existing' }) + ).toThrow(/sensitive_path/); + }); + }); + describe('resolveProjectRoot + 派生路径', () => { it('throws on unknown pid', () => { expect(() => pathResolver.resolveProjectRoot('p-nonexistent')).toThrow(/Unknown project/); diff --git a/server/tests/unit/graph-engine-target-dir.test.js b/server/tests/unit/graph-engine-target-dir.test.js new file mode 100644 index 0000000..0baaa00 --- /dev/null +++ b/server/tests/unit/graph-engine-target-dir.test.js @@ -0,0 +1,140 @@ +/** + * Unit Tests — graph-engine 的公司级目标代码目录(39 号文档 P5) + * + * 验的是 `ensureProjectRoot` 在配了 `company.json.target_dir` 时的行为: + * T1 没配 target_dir:走老的 sandbox 路径,行为不变(零回归) + * T2 配了 target_dir:两个不同 root_run_id 的 run(同一公司)解析到同一个 root_path + * T3 target_dir 校验失败(git 树不干净):ensureProjectRoot 优雅降级成 null + timeline 记录, + * 不静默 fallback 回 sandbox + * T4 GRAPH_RUNS_DIR 测试覆盖优先级高于 target_dir(不能让业务配置绕过测试隔离) + * + * `ensureProjectRoot` 本身不碰 SQLite(只读写文件系统),所以不需要像 + * graph-boundary.test.js 那样做 GRAPH_DB_PATH 隔离;但会真的往 `data/companies/` 写 + * 东西(company.json 走 companyRootDir,不认测试注入,除非设 GRAPH_RUNS_DIR——T1-T3 + * 就是要验「不设 GRAPH_RUNS_DIR 时的生产行为」,所以只能用真路径 + 用后即删, + * 手法同 path-resolver.test.js 的 sandbox 用例)。 + */ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const engine = require('../../services/graph-engine'); +const { companyRootDir } = require('../../services/company-paths'); + +describe('graph-engine 公司级目标代码目录(target_dir)', () => { + const cleanups = []; + + afterEach(() => { + delete process.env.GRAPH_RUNS_DIR; + while (cleanups.length) { + const p = cleanups.pop(); + try { fs.rmSync(p, { recursive: true, force: true }); } catch { /* best-effort */ } + } + }); + + function writeCompany(companyId, patch) { + const dir = companyRootDir(companyId); + fs.mkdirSync(dir, { recursive: true }); + cleanups.push(dir); + fs.writeFileSync(path.join(dir, 'company.json'), JSON.stringify({ id: companyId, name: companyId, ...patch })); + return dir; + } + + function fakeRun(companyId, rootRunId) { + return { root_run_id: rootRunId, company_id: companyId }; + } + + it('T1 没配 target_dir:走 sandbox,行为跟改动前一样', () => { + const cid = 'tdcfg-t1'; + writeCompany(cid, {}); + const run = fakeRun(cid, 'r-t1'); + const rec = engine.ensureProjectRoot({ run }); + expect(rec).not.toBe(null); + expect(rec.mode).toBe('sandbox'); + expect(rec.root_path).toContain(path.join('data', 'companies', cid, 'projects')); + expect(fs.existsSync(rec.root_path)).toBe(true); + cleanups.push(path.join(companyRootDir(cid), '..', 'runs')); // 无害;下面按 run 目录精确清 + cleanups.push(path.dirname(path.dirname(rec.root_path))); // projects/ 目录 + cleanups.push(path.join(companyRootDir(cid), 'runs', run.root_run_id)); + }); + + it('T2 配了 target_dir:同一公司两个不同 run 解析到同一个 root_path,且不产生 pid 目录', () => { + const cid = 'tdcfg-t2'; + const target = fs.mkdtempSync(path.join(os.tmpdir(), 'graph-engine-target-')); + cleanups.push(target); + fs.writeFileSync(path.join(target, 'README.md'), '# existing repo, no .git'); + writeCompany(cid, { target_dir: target, target_mode: 'linked-existing' }); + + const runA = fakeRun(cid, 'r-t2-a'); + const runB = fakeRun(cid, 'r-t2-b'); + cleanups.push(path.join(companyRootDir(cid), 'runs', runA.root_run_id)); + cleanups.push(path.join(companyRootDir(cid), 'runs', runB.root_run_id)); + + const recA = engine.ensureProjectRoot({ run: runA }); + const recB = engine.ensureProjectRoot({ run: runB }); + + expect(recA).not.toBe(null); + expect(recB).not.toBe(null); + expect(recA.root_path).toBe(target); + expect(recB.root_path).toBe(target); + expect(recA.mode).toBe('linked-existing'); + // 没有新的 sandbox pid 目录被造出来 + const projectsDir = path.join(companyRootDir(cid), 'projects'); + expect(fs.existsSync(projectsDir)).toBe(false); + }); + + it('T2b target_mode 缺省时按 linked-existing 处理(目标目录本来就该已经存在)', () => { + const cid = 'tdcfg-t2b'; + const target = fs.mkdtempSync(path.join(os.tmpdir(), 'graph-engine-target-')); + cleanups.push(target); + writeCompany(cid, { target_dir: target }); // 不传 target_mode + + const run = fakeRun(cid, 'r-t2b'); + cleanups.push(path.join(companyRootDir(cid), 'runs', run.root_run_id)); + + const rec = engine.ensureProjectRoot({ run }); + expect(rec.root_path).toBe(target); + expect(rec.mode).toBe('linked-existing'); + }); + + it('T3 target_dir 校验失败(git 树不干净):不静默降级回 sandbox,返回 null', () => { + const { execFileSync } = require('child_process'); + const cid = 'tdcfg-t3'; + const target = fs.mkdtempSync(path.join(os.tmpdir(), 'graph-engine-target-dirty-')); + cleanups.push(target); + execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: target }); + execFileSync('git', ['config', 'user.email', 't@l'], { cwd: target }); + execFileSync('git', ['config', 'user.name', 't'], { cwd: target }); + fs.writeFileSync(path.join(target, 'a.txt'), 'one'); + execFileSync('git', ['add', '-A'], { cwd: target }); + execFileSync('git', ['commit', '-q', '-m', 'init'], { cwd: target }); + fs.writeFileSync(path.join(target, 'b.txt'), 'untracked, dirty tree'); + + writeCompany(cid, { target_dir: target, target_mode: 'linked-existing' }); + const run = fakeRun(cid, 'r-t3'); + cleanups.push(path.join(companyRootDir(cid), 'runs', run.root_run_id)); + + const rec = engine.ensureProjectRoot({ run }); + expect(rec).toBe(null); // 优雅失败,不是编一个假 sandbox 路径 + + const projectsDir = path.join(companyRootDir(cid), 'projects'); + expect(fs.existsSync(projectsDir)).toBe(false); // 确认真的没有偷偷退化成 sandbox + }); + + it('T4 GRAPH_RUNS_DIR 测试覆盖优先级高于 target_dir(测试隔离不能被业务配置绕过)', () => { + const cid = 'tdcfg-t4'; + const target = fs.mkdtempSync(path.join(os.tmpdir(), 'graph-engine-target-override-')); + cleanups.push(target); + writeCompany(cid, { target_dir: target, target_mode: 'linked-existing' }); + + const runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'graph-engine-runs-override-')); + cleanups.push(runsDir); + process.env.GRAPH_RUNS_DIR = runsDir; + + const run = fakeRun(cid, 'r-t4'); + const rec = engine.ensureProjectRoot({ run }); + expect(rec.mode).toBe('runs-dir-override'); + expect(rec.root_path).toBe(path.join(runsDir, run.root_run_id, 'project')); + expect(rec.root_path).not.toBe(target); + }); +}); diff --git a/server/tests/unit/task-slots-parallel.test.js b/server/tests/unit/task-slots-parallel.test.js index 91853eb..a4fc3d6 100644 --- a/server/tests/unit/task-slots-parallel.test.js +++ b/server/tests/unit/task-slots-parallel.test.js @@ -587,6 +587,53 @@ describe('批 4 · 公司级执行位(max_parallel_tasks)', () => { }); // ──────────────────────────────────────────────────────────────── + // ⑤bis 39 号文档 P5:配了 target_dir 强制并行度=1,不管 max_parallel_tasks 说什么 + // ──────────────────────────────────────────────────────────────── + describe('⑤bis 公司级目标代码目录:target_dir 存在时并行度钉死为 1', () => { + /** 同一家公司同时配 target_dir 和一个 >1 的 max_parallel_tasks */ + function setTargetDir(companyId, targetDir, maxParallel) { + const { companyRootDir } = require('../../services/company-paths'); + const dir = companyRootDir(companyId); + fs.mkdirSync(dir, { recursive: true }); + const cfg = { id: companyId, name: companyId, target_dir: targetDir }; + if (maxParallel !== undefined) cfg.max_parallel_tasks = maxParallel; + fs.writeFileSync(path.join(dir, 'company.json'), JSON.stringify(cfg)); + return companyId; + } + + it('target_dir 非空 + max_parallel_tasks=5 → 仍然返回 1', () => { + const cid = setTargetDir(nextCompany(), '/tmp/some-shared-repo', 5); + expect(wdb.maxActiveRootRuns(cid)).toBe(1); + }); + + it('没配 target_dir 时 max_parallel_tasks 照常生效(回归:这条改动不该动到没配的公司)', () => { + const cid = setMaxParallel(nextCompany(), 3); + expect(wdb.maxActiveRootRuns(cid)).toBe(3); + }); + + it('target_dir 留声一次(不静默),且是可读的一次性日志,不刷屏', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const cid = setTargetDir(nextCompany(), '/tmp/shared-repo-log-test', 9); + expect(wdb.maxActiveRootRuns(cid)).toBe(1); + expect(wdb.maxActiveRootRuns(cid)).toBe(1); + expect(wdb.maxActiveRootRuns(cid)).toBe(1); + expect(warn).toHaveBeenCalledTimes(1); + expect(String(warn.mock.calls[0][0])).toContain('target_dir'); + } finally { + warn.mockRestore(); + } + }); + + it('运行期把 target_dir 清空后,恢复读 max_parallel_tasks(调用时读盘,不是缓存快照)', () => { + const cid = setTargetDir(nextCompany(), '/tmp/shared-repo-toggle', 4); + expect(wdb.maxActiveRootRuns(cid)).toBe(1); + setMaxParallel(cid, 4); // 覆写掉 target_dir + expect(wdb.maxActiveRootRuns(cid)).toBe(4); + }); + }); + + // ──────────────────────────────────────────────────────────────── // ⑥ DROP 迁移:存量库(带旧索引)启动一次就没了 // ──────────────────────────────────────────────────────────────── describe('⑥ 迁移:带 idx_graph_runs_one_active_root 的存量库', () => {