Commit d967ac0d634adafd8dc8fcd2bf4c02c37efc6243
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
Showing
6 changed files
with
382 additions
and
48 deletions
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 = 'sandbox', 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 = 'sandbox', 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 = 'sandbox', 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 = 'sandbox', 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('pathResolver', () => { |
| 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('批 4 · 公司级执行位(max_parallel_tasks)', () => { |
| 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 的存量库', () => { | ... | ... |