dsh-any-agent 0.3.4 → 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/workspace.mjs CHANGED
@@ -15,9 +15,9 @@
15
15
  * 或手动执行回填时做一次读-改-写,窗口很小;但仍与 GUI 存在理论上的覆盖可能。
16
16
  */
17
17
 
18
- import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, readdirSync, rmSync, realpathSync } from 'node:fs';
18
+ import { copyFileSync, readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, readdirSync, rmSync, realpathSync } from 'node:fs';
19
19
  import { randomUUID } from 'node:crypto';
20
- import { basename, dirname, join } from 'node:path';
20
+ import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path';
21
21
  import { tmpdir } from 'node:os';
22
22
  import { zstdDecompressSync } from 'node:zlib';
23
23
  import { DSH_HOME, REGISTER_WORKSPACE } from './config.mjs';
@@ -42,11 +42,46 @@ export function canonicalPath(p) {
42
42
  }
43
43
  }
44
44
 
45
- /** 路径是否位于系统临时目录下(这类"工作区"不该出现在 GUI 里)。 */
45
+ /** `child` 是否真的在 `parent` 里面(按路径分段,不能用字符串前缀)。 */
46
+ function isInside(parent, child) {
47
+ try {
48
+ const rel = relative(parent, child);
49
+ if (!rel) return true;
50
+ return rel !== '..' && !rel.startsWith('..' + sep) && !isAbsolute(rel);
51
+ } catch {
52
+ return false;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * 路径是否位于系统临时目录下(这类"工作区"不该出现在 GUI 里)。
58
+ *
59
+ * ★ 必须按路径分段判断,不能用 `startsWith(tmpdir())`:那样
60
+ * `/tmpfoo`、`%LOCALAPPDATA%\Temporary\proj` 都会被当成临时目录,
61
+ * 而清理逻辑会据此**递归删除真实会话目录**(真实数据丢失级 bug)。
62
+ */
46
63
  export function isTempPath(p) {
47
64
  try {
48
- const t = normPath(tmpdir());
49
- return normPath(p).startsWith(t);
65
+ return Boolean(p) && isInside(tmpdir(), p);
66
+ } catch {
67
+ return false;
68
+ }
69
+ }
70
+
71
+ /**
72
+ * 是否是本仓库测试自己造的临时工作区。
73
+ *
74
+ * 只认系统临时目录下、第一段目录名以 `dsh-any-agent-` 开头的路径
75
+ * (测试统一用 `mkdtempSync(join(tmpdir(), 'dsh-any-agent-...'))`)。
76
+ * 这不是"所有临时目录"——用户完全可能把真实项目放在 %TEMP% 的同级目录里。
77
+ */
78
+ export function isTestTempPath(p) {
79
+ try {
80
+ if (!p || !isTempPath(p)) return false;
81
+ const rel = relative(tmpdir(), p);
82
+ if (!rel || rel === '..' || rel.startsWith('..' + sep) || isAbsolute(rel)) return false;
83
+ const first = rel.split(/[\\/]/).filter(Boolean)[0] ?? '';
84
+ return /^dsh-any-agent-/.test(first);
50
85
  } catch {
51
86
  return false;
52
87
  }
@@ -87,16 +122,25 @@ function emptyDoc() {
87
122
  };
88
123
  }
89
124
 
125
+ /**
126
+ * 读取工作区注册表。
127
+ *
128
+ * ★ 解析失败必须 **fail-closed**:以前会静默回落到空结构,下一次 register/prune
129
+ * 就会把 GUI 的整个工作区列表覆盖掉(不可恢复)。现在抛错,并且绝不写盘;
130
+ * 调用方(register/backfill/prune)各自把错误返回给上层。
131
+ */
90
132
  function readDoc() {
91
- let doc = emptyDoc();
92
- if (existsSync(WORKSPACE_FILE)) {
93
- try {
94
- const parsed = JSON.parse(readFileSync(WORKSPACE_FILE, 'utf8'));
95
- if (parsed && typeof parsed === 'object') doc = parsed;
96
- } catch {
97
- /* 坏文件就用空结构重建,避免把 GUI 的注册表彻底写坏 */
98
- }
133
+ if (!existsSync(WORKSPACE_FILE)) return emptyDoc();
134
+ let parsed;
135
+ try {
136
+ parsed = JSON.parse(readFileSync(WORKSPACE_FILE, 'utf8'));
137
+ } catch (e) {
138
+ throw new Error(`workspace.json 无法解析,已保持原样不写入: ${WORKSPACE_FILE}(${e.message})`);
99
139
  }
140
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
141
+ throw new Error(`workspace.json 结构异常(不是对象),已保持原样不写入: ${WORKSPACE_FILE}`);
142
+ }
143
+ const doc = parsed;
100
144
  doc.global ??= emptyDoc().global;
101
145
  doc.global.workspaceIds ??= [];
102
146
  doc.global.archivedSessionIds ??= [];
@@ -108,9 +152,23 @@ function readDoc() {
108
152
 
109
153
  function writeDoc(doc) {
110
154
  mkdirSync(dirname(WORKSPACE_FILE), { recursive: true });
111
- const tmp = `${WORKSPACE_FILE}.dshmcp.tmp`;
112
- writeFileSync(tmp, JSON.stringify(doc, null, 2), 'utf8');
113
- renameSync(tmp, WORKSPACE_FILE);
155
+ // 保留上一份**可解析**的注册表:逻辑写坏时用户还能手工回滚。
156
+ try {
157
+ if (existsSync(WORKSPACE_FILE)) copyFileSync(WORKSPACE_FILE, `${WORKSPACE_FILE}.bak-dsh`);
158
+ } catch {
159
+ /* 备份失败不阻断写入 */
160
+ }
161
+ // 临时文件名必须唯一:固定 `.dshmcp.tmp` 会让两个实例/CLI 并发写互相覆盖。
162
+ const tmp = `${WORKSPACE_FILE}.${process.pid}.${randomUUID()}.tmp`;
163
+ try {
164
+ writeFileSync(tmp, JSON.stringify(doc, null, 2), 'utf8');
165
+ renameSync(tmp, WORKSPACE_FILE);
166
+ } catch (e) {
167
+ try {
168
+ rmSync(tmp, { force: true });
169
+ } catch {}
170
+ throw e;
171
+ }
114
172
  }
115
173
 
116
174
  /** 在已读入的 doc 里找到(或新建)某路径对应的工作区条目。 */
@@ -172,10 +230,17 @@ function readHeader(file) {
172
230
  }
173
231
  }
174
232
 
175
- /** 扫描磁盘上所有会话,按 header.cwd 归组。数据源是会话头,不是目录名(目录名有损)。 */
233
+ /**
234
+ * 扫描磁盘上所有会话,按 header.cwd 归组。数据源是会话头,不是目录名(目录名有损)。
235
+ *
236
+ * 会话根目录**不可读时抛错**,而不是返回空 Map —— 否则 backfill 的
237
+ * `pruneMissing` 会以为"所有会话都消失了",把工作区注册表里的 sessionIds 清空。
238
+ */
176
239
  export function scanSessions() {
177
240
  const byCwd = new Map();
178
- if (!existsSync(SESSIONS_ROOT)) return byCwd;
241
+ if (!existsSync(SESSIONS_ROOT)) {
242
+ throw new Error(`会话目录不存在或不可读: ${SESSIONS_ROOT}`);
243
+ }
179
244
  for (const bucket of readdirSync(SESSIONS_ROOT)) {
180
245
  const bucketPath = join(SESSIONS_ROOT, bucket);
181
246
  let dirs;
@@ -210,7 +275,13 @@ export function scanSessions() {
210
275
  export function findSessionHeader(sessionId) {
211
276
  if (typeof sessionId !== 'string' || !/^[A-Za-z0-9._-]{4,80}$/.test(sessionId)) return null;
212
277
  if (!existsSync(SESSIONS_ROOT)) return null;
213
- for (const bucket of readdirSync(SESSIONS_ROOT)) {
278
+ let buckets;
279
+ try {
280
+ buckets = readdirSync(SESSIONS_ROOT);
281
+ } catch {
282
+ return null;
283
+ }
284
+ for (const bucket of buckets) {
214
285
  const f = join(SESSIONS_ROOT, bucket, sessionId, 'session.v4.jsonl.zstd');
215
286
  if (!existsSync(f)) continue;
216
287
  const header = readHeader(f);
@@ -224,9 +295,6 @@ export function findSessionHeader(sessionId) {
224
295
  * 用于修复"本功能上线前创建的会话在 GUI 里看不到"。
225
296
  */
226
297
  export function backfillWorkspaces({ includeTemp = false, dryRun = false, pruneMissing = true } = {}) {
227
- const byCwd = scanSessions();
228
- const doc = readDoc();
229
- const nowIso = new Date().toISOString();
230
298
  const stats = {
231
299
  workspaces_created: 0,
232
300
  workspaces_existing: 0,
@@ -236,7 +304,19 @@ export function backfillWorkspaces({ includeTemp = false, dryRun = false, pruneM
236
304
  temp_skipped_workspaces: 0,
237
305
  /** 磁盘上已不存在、被从注册表摘掉的 sessionId 数量。 */
238
306
  sessions_pruned: 0,
307
+ /** 读取失败时的说明;非空表示**没有写盘**。 */
308
+ error: null,
239
309
  };
310
+ let byCwd;
311
+ let doc;
312
+ try {
313
+ byCwd = scanSessions();
314
+ doc = readDoc();
315
+ } catch (e) {
316
+ // 看不到会话存储 / 注册表坏了时绝不做对账:那会把所有 sessionIds 清空。
317
+ return { ...stats, error: e.message };
318
+ }
319
+ const nowIso = new Date().toISOString();
240
320
 
241
321
  /** 磁盘上真实存在的会话 id 全集。 */
242
322
  const live = new Set();
@@ -281,17 +361,20 @@ export function backfillWorkspaces({ includeTemp = false, dryRun = false, pruneM
281
361
  * 删除测试会话(**这是唯一会动磁盘会话文件的函数**,所以规则写得很死)。
282
362
  *
283
363
  * 只会删这几类,其余一律不碰:
284
- * 1) 会话头里的 cwd 位于系统临时目录下
285
- * 2) 会话头里的 cwd 匹配 Desktop\dsh-any-agent-test-* —— 传 tempOnly 可关掉这一条
286
- * 3) ids 里显式指定的
364
+ * 1) testOnly:cwd 在 `tmpdir()/dsh-any-agent-*` 下(本仓库测试自己造的)
365
+ * 2) cwd 匹配 Desktop\dsh-any-agent-test-*
366
+ * 3) tempOnly:cwd 在系统临时目录下(显式人工命令才用,范围最大)
367
+ * 4) ids 里显式指定的
287
368
  *
288
369
  * @param {object} opts
289
- * @param {boolean} [opts.tempOnly] 只删临时目录下的。自动清理(测试套件收尾)用这条,
290
- * 绝不动桌面或真实项目里的任何会话。
291
- * @param {boolean} [opts.idsOnly] **只删 ids 里点名的**,忽略上面两条规则。
370
+ * @param {boolean} [opts.testOnly] 只删**本仓库测试造的**临时目录(`tmpdir()/dsh-any-agent-*`)
371
+ * 与桌面 `dsh-any-agent-test-*`。自动清理(测试套件收尾)必须用这条。
372
+ * @param {boolean} [opts.tempOnly] 删除**所有** cwd 在系统临时目录下的会话。
373
+ * 只给显式人工命令用;不要放进自动清理 —— 用户完全可能把真实项目放在临时目录。
374
+ * @param {boolean} [opts.idsOnly] **只删 ids 里点名的**,忽略上面三条规则。
292
375
  * 要精确删某几个会话时用这条(否则一条临时目录规则会把别的也一起带走 —— 真实踩过)。
293
376
  */
294
- export function purgeTestSessions({ dryRun = false, ids = [], tempOnly = false, idsOnly = false } = {}) {
377
+ export function purgeTestSessions({ dryRun = false, ids = [], testOnly = false, tempOnly = false, idsOnly = false } = {}) {
295
378
  const explicit = new Set(ids);
296
379
  const victims = [];
297
380
  if (existsSync(SESSIONS_ROOT)) {
@@ -304,12 +387,15 @@ export function purgeTestSessions({ dryRun = false, ids = [], tempOnly = false,
304
387
  continue;
305
388
  }
306
389
  for (const d of dirs) {
307
- if (!d.isDirectory()) continue;
390
+ // 符号链接一律不碰:rmSync 对链接的行为依赖平台,且链接目标可能不在会话目录内。
391
+ if (!d.isDirectory() || d.isSymbolicLink()) continue;
308
392
  const dir = join(bucketPath, d.name);
393
+ // 兜底:只允许删 SESSIONS_ROOT 下面的目录(结构上已经如此,防未来改动)。
394
+ if (!isInside(SESSIONS_ROOT, dir)) continue;
309
395
  const header = readHeader(join(dir, 'session.v4.jsonl.zstd'));
310
396
  if (!header?.id) continue;
311
397
  const cwd = header.cwd ?? '';
312
- const isTemp = isTempPath(cwd);
398
+ const isTemp = testOnly ? isTestTempPath(cwd) : isTempPath(cwd);
313
399
  const isTestDesktop = !tempOnly && /[\\/]Desktop[\\/]dsh-any-agent-test-[AB]([\\/]|$)/i.test(cwd);
314
400
  const isExplicit = explicit.has(header.id);
315
401
  // idsOnly:只认点名,别的规则全部作废
@@ -318,7 +404,7 @@ export function purgeTestSessions({ dryRun = false, ids = [], tempOnly = false,
318
404
  id: header.id,
319
405
  dir,
320
406
  cwd,
321
- reason: isExplicit ? '显式指定' : isTemp ? '临时目录' : '验收测试目录',
407
+ reason: isExplicit ? '显式指定' : isTestDesktop ? '验收测试目录' : testOnly ? '测试临时目录' : '临时目录',
322
408
  origin: header.origin ?? null,
323
409
  });
324
410
  }
@@ -339,12 +425,14 @@ export function purgeTestSessions({ dryRun = false, ids = [], tempOnly = false,
339
425
  }
340
426
  }
341
427
  // 清掉因删空而残留的空分桶目录(每个工作区一个桶,空桶会一直堆着)
342
- for (const bucket of readdirSync(SESSIONS_ROOT)) {
343
- const p = join(SESSIONS_ROOT, bucket);
344
- try {
345
- if (readdirSync(p).length === 0) rmSync(p, { recursive: true, force: true });
346
- } catch {
347
- /* 桶被占用或权限问题就跳过 */
428
+ if (existsSync(SESSIONS_ROOT)) {
429
+ for (const bucket of readdirSync(SESSIONS_ROOT)) {
430
+ const p = join(SESSIONS_ROOT, bucket);
431
+ try {
432
+ if (readdirSync(p).length === 0) rmSync(p, { recursive: true, force: true });
433
+ } catch {
434
+ /* 桶被占用或权限问题就跳过 */
435
+ }
348
436
  }
349
437
  }
350
438
  // 删完之后把注册表里对应的 id 也摘掉
@@ -355,7 +443,12 @@ export function purgeTestSessions({ dryRun = false, ids = [], tempOnly = false,
355
443
 
356
444
  /** 清掉"没有会话且路径已不存在"的空工作区条目。 */
357
445
  export function pruneEmptyWorkspaces({ dryRun = false } = {}) {
358
- const doc = readDoc();
446
+ let doc;
447
+ try {
448
+ doc = readDoc();
449
+ } catch (e) {
450
+ return { removed: [], error: e.message };
451
+ }
359
452
  const removed = [];
360
453
  for (const id of Object.keys(doc.tables.workspaces)) {
361
454
  const ws = doc.tables.workspaces[id];
@@ -379,13 +472,20 @@ export function pruneEmptyWorkspaces({ dryRun = false } = {}) {
379
472
  * 启发式,实测不一定兜住)。这里按**路径模式**确定性清理,绝不碰真实项目目录。
380
473
  */
381
474
  export function pruneTestWorkspaces({ dryRun = false } = {}) {
382
- const doc = readDoc();
475
+ let doc;
476
+ try {
477
+ doc = readDoc();
478
+ } catch (e) {
479
+ return { removed: [], error: e.message };
480
+ }
383
481
  const removed = [];
384
482
  for (const id of Object.keys(doc.tables.workspaces)) {
385
483
  const ws = doc.tables.workspaces[id];
386
484
  const p = ws?.path ?? '';
387
485
  if (!p) continue;
388
- if (!isTempPath(p) && !isTestDesktopPath(p)) continue;
486
+ // ★ 只删测试自己造的临时目录(dsh-any-agent-*)与桌面测试目录。
487
+ // 以前用 isTempPath(p) 会把用户在 %TEMP% 里的真实项目登记也删掉。
488
+ if (!isTestTempPath(p) && !isTestDesktopPath(p)) continue;
389
489
  removed.push({ id, path: p, sessions: ws?.sessionIds?.length ?? 0 });
390
490
  delete doc.tables.workspaces[id];
391
491
  doc.global.workspaceIds = (doc.global.workspaceIds ?? []).filter((x) => x !== id);
@@ -397,7 +497,12 @@ export function pruneTestWorkspaces({ dryRun = false } = {}) {
397
497
 
398
498
  /** 清理:把系统临时目录下的工作区条目从注册表移除(**不动磁盘上的会话**)。 */
399
499
  export function pruneTempWorkspaces({ dryRun = false } = {}) {
400
- const doc = readDoc();
500
+ let doc;
501
+ try {
502
+ doc = readDoc();
503
+ } catch (e) {
504
+ return { removed: [], error: e.message };
505
+ }
401
506
  const removed = [];
402
507
  for (const id of Object.keys(doc.tables.workspaces)) {
403
508
  const ws = doc.tables.workspaces[id];
@@ -411,7 +516,7 @@ export function pruneTempWorkspaces({ dryRun = false } = {}) {
411
516
  return { removed };
412
517
  }
413
518
 
414
- /** 列出当前注册表里的工作区。 */
519
+ /** 列出当前注册表里的工作区。解析失败时抛错(由 CLI 显示可操作的错误)。 */
415
520
  export function listWorkspaces() {
416
521
  const doc = readDoc();
417
522
  return Object.entries(doc.tables.workspaces).map(([id, w]) => ({