path-resolver.js
11.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
/**
* 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,
};