/** * 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 : v2,本文件预留接口但 register 阶段拒收 * * 详尽规格: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); }); } /** * 在文件系统上分配一个 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') { 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; } // 父目录必须存在 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 }); } 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') { const e = new Error('linked-existing mode is v2; not supported in v1'); e.code = 'NOT_IMPLEMENTED'; throw e; } else { throw new Error(`unknown mode: ${mode}`); } // 立即注入缓存 ensureInit(); cache.set(pid, { root_path, mode, company_id }); return { pid, root_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, unregisterLinkedProject, listProjects, patchProjectMeta, hardDeleteProject, isSensitivePath, _resetForTesting, // 常量 DATA_DIR, LINKED_REGISTRY, SENSITIVE_PREFIXES, };