/** * DevLoop · pathResolver * * 所有 DevLoop 产物路径的唯一真源。其他服务(docStore / unitRegistry / * bugTracker / sseBroker / gitVault)都通过这里查路径,绝不硬编码 * `data/companies/...`。 * * 支持三种 mode(见 arch-project-root.html §②): * - sandbox : 自动生成 root_path = `{dataDir}/companies/{cid}/projects/{pid}/` * - 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 */ const fs = require('fs'); const path = require('path'); const os = require('os'); const DATA_DIR = path.resolve(__dirname, '../../../data'); const LINKED_REGISTRY = path.join(os.homedir(), '.devloop', 'linked-projects.json'); // 系统敏感路径黑名单(不允许作为 linked-new 的 target_path) // 注意:os.tmpdir() (e.g. /var/folders/... on macOS, /tmp on Linux) 是允许的, // 即使前缀匹配 /var;isSensitivePath 内部会先检查 tmpdir 例外。 const SENSITIVE_PREFIXES = [ '/etc', '/usr', '/bin', '/sbin', '/boot', '/var/log', '/var/lib', '/var/db', '/var/spool', '/var/cache', '/System', '/Library/System', '/private/etc', '/private/var/log', path.join(os.homedir(), '.ssh'), path.join(os.homedir(), '.aws'), path.join(os.homedir(), '.gnupg'), path.join(os.homedir(), '.config'), path.join(os.homedir(), 'Downloads'), ]; const ALLOWED_EXEMPTIONS = [ os.tmpdir(), // /var/folders/... (mac) 或 /tmp (linux) '/tmp', ]; // 内存缓存:pid → { root_path, mode, company_id } let cache = null; function loadLinkedRegistry() { if (!fs.existsSync(LINKED_REGISTRY)) return {}; try { return JSON.parse(fs.readFileSync(LINKED_REGISTRY, 'utf8')); } catch { return {}; } } function saveLinkedRegistry(reg) { fs.mkdirSync(path.dirname(LINKED_REGISTRY), { recursive: true }); fs.writeFileSync(LINKED_REGISTRY, JSON.stringify(reg, null, 2)); } /** 全量扫描 + linked 注册表,构造内存索引。LoopController 启动时调一次。 */ function init() { cache = new Map(); // 1. 扫 sandbox 项目(data/companies/*/projects/*/project.json) if (fs.existsSync(DATA_DIR)) { const companiesDir = path.join(DATA_DIR, 'companies'); if (fs.existsSync(companiesDir)) { for (const cid of fs.readdirSync(companiesDir)) { const projectsDir = path.join(companiesDir, cid, 'projects'); if (!fs.existsSync(projectsDir)) continue; for (const pid of fs.readdirSync(projectsDir)) { const root = path.join(projectsDir, pid); const meta = path.join(root, 'project.json'); if (fs.existsSync(meta)) { cache.set(pid, { root_path: root, mode: 'sandbox', company_id: cid }); } } } } } // 2. 叠加 linked 项目 const linked = loadLinkedRegistry(); for (const [pid, entry] of Object.entries(linked)) { cache.set(pid, { root_path: entry.root_path, mode: entry.mode, company_id: entry.company_id, }); } return { count: cache.size }; } function ensureInit() { if (cache === null) init(); } function resolveProjectRoot(pid) { ensureInit(); const entry = cache.get(pid); if (!entry) throw new Error(`Unknown project: ${pid}`); return entry.root_path; } function resolveProjectMeta(pid) { ensureInit(); const entry = cache.get(pid); if (!entry) throw new Error(`Unknown project: ${pid}`); return { ...entry }; } function findProjectByPath(abs) { ensureInit(); for (const [pid, entry] of cache.entries()) { if (entry.root_path === abs) return { pid, ...entry }; } return null; } // ── 派生路径 ── function resolveUnitDir(pid, unitId) { return path.join(resolveProjectRoot(pid), 'units', unitId); } function resolvePrdSite(pid) { return path.join(resolveProjectRoot(pid), 'prd'); } function resolveSddSite(pid) { return path.join(resolveProjectRoot(pid), 'sdd'); } function resolveAgentState(pid, agent) { return path.join(resolveProjectRoot(pid), 'agent-state', `${agent}.json`); } function resolveAgentTodolist(pid, agent) { return path.join(resolveProjectRoot(pid), 'agent-state', `${agent}-todo.json`); } function resolveAgentSnapshotDir(pid) { return path.join(resolveProjectRoot(pid), 'agent-state', 'snapshots'); } function resolveTimelineLog(pid) { return path.join(resolveProjectRoot(pid), 'timeline.ndjson'); } function resolveBugsLog(pid) { return path.join(resolveProjectRoot(pid), 'bugs.ndjson'); } function resolveChangelog(pid) { return path.join(resolveProjectRoot(pid), 'changelog.ndjson'); } function resolveStreamLog(pid) { return path.join(resolveProjectRoot(pid), 'stream.log'); } function resolveHistoryDir(pid) { return path.join(resolveProjectRoot(pid), 'history'); } function resolveIntegrationDir(pid) { return path.join(resolveProjectRoot(pid), 'integration'); } function resolveUnitsJson(pid) { return path.join(resolveProjectRoot(pid), 'units.json'); } function resolveProjectJson(pid) { return path.join(resolveProjectRoot(pid), 'project.json'); } /** Memory 永远在 company 级(不跟项目根走,详见 arch-project-root §⑧) */ function resolveMemoryDir(companyId, agent) { return path.join(DATA_DIR, 'companies', companyId, 'memory', 'devloop', agent); } function resolveCompanyDir(companyId) { return path.join(DATA_DIR, 'companies', companyId); } // ── 创建项目 ── function generatePid() { // p- + 6 位 base36 const rand = Math.random().toString(36).slice(2, 8).padEnd(6, '0'); return `p-${rand}`; } function isSensitivePath(abs) { const normalized = path.resolve(abs); // 1. 明确允许的目录(tmpdir 等)→ 即使前缀匹配也放行 for (const ex of ALLOWED_EXEMPTIONS) { const e = path.resolve(ex); if (normalized === e || normalized.startsWith(e + path.sep)) return false; } // 2. 不允许直接放在根 / if (normalized === '/') return true; // 3. 检查黑名单前缀 return SENSITIVE_PREFIXES.some(prefix => { const p = path.resolve(prefix); return normalized === p || normalized.startsWith(p + path.sep); }); } /** * `linked-new` / `linked-existing` 共用的校验逻辑,**不碰 registry / cache / pid**。 * 抽出来是因为 39 号文档的公司级 `target_dir` 机制也要用同一套校验(敏感路径黑名单、 * git 干净树……),但它是「一个公司固定一个目录,不进 DevLoop 的 pid 注册表」, * 语义和 `allocateProjectRoot` 的 linked-* 分支(一对多、按 pid 登记)不同,不能直接复用 * 那个函数——于是只抽这一半(纯校验 + 解析 root_path),registry 写入留在调用方各自决定。 * * @returns {string} 校验通过后的绝对 root_path * @throws 校验失败:错误 message/code 与原 `allocateProjectRoot` 内联版本逐字一致, * 不要在这里改措辞——外部可能已经在依赖具体的 error.code(如 DIRTY_WORKTREE)。 */ 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; } if (mode === 'linked-new') { // 父目录必须存在 const parent = path.dirname(target_path); if (!fs.existsSync(parent)) { throw new Error(`parent dir does not exist: ${parent}`); } // 必须不存在或为空 if (fs.existsSync(target_path)) { const entries = fs.readdirSync(target_path); if (entries.length > 0) { throw new Error(`target_path is not empty: ${target_path}`); } } else { fs.mkdirSync(target_path, { recursive: false }); } 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)`); } const stat = fs.statSync(target_path); if (!stat.isDirectory()) { throw new Error(`target_path is not a directory: ${target_path}`); } // 已有 DevLoop 项目(目录里就有 project.json)→ 拒绝重复挂载 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 把用户脏改动扫进自动 commit) if (fs.existsSync(path.join(target_path, '.git'))) { const { execFileSync } = require('child_process'); let porcelain; try { porcelain = execFileSync('git', ['status', '--porcelain'], { cwd: target_path, encoding: 'utf8' }); } catch (e) { throw new Error(`failed to check git status at ${target_path}: ${e.message}`); } if (porcelain.trim().length > 0) { const lines = porcelain.trim().split('\n').slice(0, 8).join('\n'); const err = new Error( `target_path has uncommitted changes; please commit or stash first.\n` + `Reason: DevLoop agents auto-commit with git add -A and would sweep your dirty files into agent commits.\n` + `--- git status --porcelain (truncated) ---\n${lines}` ); err.code = 'DIRTY_WORKTREE'; throw err; } } 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, company_id, registered_at: new Date().toISOString(), }; saveLinkedRegistry(reg); } else { throw new Error(`unknown mode: ${mode}`); } // 立即注入缓存 ensureInit(); cache.set(pid, { root_path, mode, company_id }); 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]) { delete reg[pid]; saveLinkedRegistry(reg); } if (cache) cache.delete(pid); } /** * 列出全部项目。读 project.json 注入 name/created_at/status/archived/idea_summary。 * 默认过滤已 archived 的项目;includeArchived=true 时全返。 */ function listProjects({ includeArchived = false } = {}) { ensureInit(); const out = []; for (const [pid, entry] of cache.entries()) { const meta = _readMetaSafe(entry.root_path); if (!includeArchived && meta && meta.archived) continue; out.push({ pid, ...entry, name: meta?.name || meta?.idea_summary || pid, idea_summary: meta?.idea_summary || '', status: meta?.status || 'draft', created_at: meta?.created_at || null, archived: !!meta?.archived, archived_at: meta?.archived_at || null, }); } // 倒序:最近创建的在前 out.sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || ''))); return out; } function _readMetaSafe(rootPath) { try { const p = path.join(rootPath, 'project.json'); if (!fs.existsSync(p)) return null; return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; } } /** * 修改 project.json 的字段(name / idea_summary / archived 等)。 * 调用方负责传合法字段集;不做 FSM 校验(status 用 loop-controller._setProjectStatus)。 */ function patchProjectMeta(pid, patch = {}) { ensureInit(); const root = resolveProjectRoot(pid); const metaPath = path.join(root, 'project.json'); if (!fs.existsSync(metaPath)) throw new Error(`project.json missing for ${pid}`); const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')); Object.assign(meta, patch); fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2)); return meta; } /** * 硬删项目:sandbox 模式下递归删 root_path;linked 模式只解除注册(不删用户自有目录)。 * 返回 { mode, removed_disk: boolean } */ function hardDeleteProject(pid) { ensureInit(); const entry = cache.get(pid); if (!entry) throw new Error(`Unknown project: ${pid}`); let removed_disk = false; if (entry.mode === 'sandbox') { if (fs.existsSync(entry.root_path)) { fs.rmSync(entry.root_path, { recursive: true, force: true }); removed_disk = true; } } else if (entry.mode === 'linked-new' || entry.mode === 'linked-existing') { // 只解注册,不动用户自己挑的目录 const reg = loadLinkedRegistry(); if (reg[pid]) { delete reg[pid]; saveLinkedRegistry(reg); } } cache.delete(pid); return { mode: entry.mode, removed_disk }; } /** 测试用:重置缓存,强制下次重新扫描 */ function _resetForTesting() { cache = null; } module.exports = { init, resolveProjectRoot, resolveProjectMeta, findProjectByPath, resolveUnitDir, resolvePrdSite, resolveSddSite, resolveAgentState, resolveAgentTodolist, resolveAgentSnapshotDir, resolveTimelineLog, resolveBugsLog, resolveChangelog, resolveStreamLog, resolveHistoryDir, resolveIntegrationDir, resolveUnitsJson, resolveProjectJson, resolveMemoryDir, resolveCompanyDir, allocateProjectRoot, resolveFixedTarget, validateLinkedTarget, unregisterLinkedProject, listProjects, patchProjectMeta, hardDeleteProject, isSensitivePath, _resetForTesting, // 常量 DATA_DIR, LINKED_REGISTRY, SENSITIVE_PREFIXES, };