dsh-session-curator 0.1.1 → 0.2.0

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.
@@ -0,0 +1,477 @@
1
+ /**
2
+ * 会话管理器:**只读投影**(快照 / 标题 / 体积)+ 归档 / 置顶这类轻量变更。
3
+ *
4
+ * 危险操作(软删除 / 恢复 / 永久删除)在 `trash.ts` 里,那边带完整的事故注释;
5
+ * 这里只负责把磁盘与 registry 的事实汇总成快照,以及逐项执行的框架 `each()`。
6
+ */
7
+ import { promises as fsp } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { dirStats, mapLimit, messageOf, readProjectionFile } from "./fs-utils.js";
10
+ import { META_FILE, dshHomePath, projCacheFile, sessionsRoot, trashRoot } from "./paths.js";
11
+ import { LOCAL_VERSION } from "./manifest.js";
12
+ import { checkForUpdate } from "./update.js";
13
+ import { purgeOlderThan, purgeSessions, restoreSessions, trashSessions } from "./trash.js";
14
+ import { isExpired, retentionCutoff } from "./retention.js";
15
+ import { openSession } from "./open.js";
16
+ /**
17
+ * 文件系统侧的并发上限。快照一次要给上百个目录做 `readdir` + `stat`,串行 await 会让
18
+ * 每次变更都慢一拍;无上限并发又会跟 DSH 自己的会话扫描抢 IO。8 是"够快且不过分"的折中。
19
+ */
20
+ const FS_CONCURRENCY = 8;
21
+ /**
22
+ * 活动判定的并发上限。每个会话问一次官方的 `workspace/session-activity`(瀑布钩子,
23
+ * 可能被别的插件挂成异步 IO),所以给得比文件系统小一些。
24
+ */
25
+ const ACTIVITY_CONCURRENCY = 4;
26
+ /** `usage` 的两个榜单各回几条(客户端的文案按同一个数字算)。 */
27
+ export const USAGE_TOP_LIMIT = 5;
28
+ export class SessionManager {
29
+ ctx;
30
+ constructor(ctx) {
31
+ this.ctx = ctx;
32
+ }
33
+ get registry() {
34
+ return this.ctx.workspaceRegistry;
35
+ }
36
+ // ── 只读投影 ─────────────────────────────────────────────────────────
37
+ /** `<DSH_HOME>/sessions/<slug>/<sessionId>` → `{ dir, slug }` 索引。 */
38
+ async scanSessionDirs() {
39
+ const root = sessionsRoot();
40
+ const map = new Map();
41
+ let slugs;
42
+ try {
43
+ slugs = await fsp.readdir(root, { withFileTypes: true });
44
+ }
45
+ catch {
46
+ return map;
47
+ }
48
+ for (const slug of slugs) {
49
+ if (!slug.isDirectory())
50
+ continue;
51
+ let ids;
52
+ try {
53
+ ids = await fsp.readdir(join(root, slug.name), { withFileTypes: true });
54
+ }
55
+ catch {
56
+ continue;
57
+ }
58
+ for (const id of ids) {
59
+ if (!id.isDirectory())
60
+ continue;
61
+ if (!map.has(id.name))
62
+ map.set(id.name, { dir: join(root, slug.name, id.name), slug: slug.name });
63
+ }
64
+ }
65
+ return map;
66
+ }
67
+ /** 投影缓存里的会话元数据(标题 / cwd / 创建时间)。缺文件时返回空字段。 */
68
+ readProjection(sessionId) {
69
+ return readProjectionFile(projCacheFile(sessionId));
70
+ }
71
+ dirStats(dir) {
72
+ return dirStats(dir);
73
+ }
74
+ findWorkspaceOf(sessionId) {
75
+ for (const workspace of this.registry.list()) {
76
+ const ids = workspace.sessionIds;
77
+ if (Array.isArray(ids) && ids.includes(sessionId))
78
+ return workspace;
79
+ }
80
+ return undefined;
81
+ }
82
+ /**
83
+ * 一个会话的完整只读事实。
84
+ *
85
+ * 两件事互不依赖(投影缓存、磁盘体积),所以并发拿:单条的耗时从"两次串行 IO"
86
+ * 降到"最慢的那一次"。`findWorkspaceOf()` 是纯内存遍历,不必并发。
87
+ *
88
+ * `active` 由调用方注入(快照会先把整批活动判定并发算完)——`sessionActivity()` 是
89
+ * 瀑布钩子,逐行现问会让"有没有别的插件挂了这个钩子"直接变成列表的延迟。
90
+ */
91
+ async describeSession(sessionId, dirs, active) {
92
+ const info = dirs.get(sessionId);
93
+ const [projection, stats] = await Promise.all([
94
+ this.readProjection(sessionId),
95
+ info ? this.dirStats(info.dir) : Promise.resolve({ bytes: 0, files: 0 })
96
+ ]);
97
+ const workspace = this.findWorkspaceOf(sessionId);
98
+ return {
99
+ sessionId,
100
+ title: projection.title ?? (workspace ? workspace.title : null) ?? sessionId,
101
+ cwd: projection.cwd ?? (workspace ? workspace.path : null),
102
+ createdAt: projection.createdAt ?? null,
103
+ workspaceId: workspace ? String(workspace.id) : null,
104
+ workspaceTitle: workspace ? workspace.title : null,
105
+ slug: info ? info.slug : null,
106
+ onDisk: Boolean(info),
107
+ bytes: stats.bytes,
108
+ files: stats.files,
109
+ active
110
+ };
111
+ }
112
+ async listTrash() {
113
+ const root = trashRoot();
114
+ let entries;
115
+ try {
116
+ entries = await fsp.readdir(root, { withFileTypes: true });
117
+ }
118
+ catch {
119
+ return [];
120
+ }
121
+ // 每条都要读 `__trash.json` + 遍历目录:串行时条目一多就明显卡(回收站常驻几十条)。
122
+ // 这里**故意**用最宽松的读法(坏 JSON / 缺字段 / 连版本都比插件新,都只是"字段缺失"):
123
+ // 一次列表不该因为某一条元数据坏了就整个炸掉。要按 schema 校验、并且会拒绝未知版本的是
124
+ // `trash.ts` 的 `readTrashMeta()`(恢复那条路,见那边的注释)。
125
+ const rows = await mapLimit(entries.filter((entry) => entry.isDirectory()), FS_CONCURRENCY, async (entry) => {
126
+ const dir = join(root, entry.name);
127
+ let meta = null;
128
+ try {
129
+ meta = JSON.parse(await fsp.readFile(join(dir, META_FILE), "utf8"));
130
+ }
131
+ catch {
132
+ meta = null;
133
+ }
134
+ const stats = await this.dirStats(dir);
135
+ return {
136
+ sessionId: entry.name,
137
+ title: typeof meta?.title === "string" && meta.title !== "" ? meta.title : entry.name,
138
+ cwd: typeof meta?.cwd === "string" ? meta.cwd : null,
139
+ slug: typeof meta?.slug === "string" ? meta.slug : null,
140
+ // 与 describeSession 对齐:客户端按"原来的工作区文件夹"分组要用到这两个字段。
141
+ workspaceId: typeof meta?.workspaceId === "string" ? meta.workspaceId : null,
142
+ workspaceTitle: typeof meta?.workspaceTitle === "string" ? meta.workspaceTitle : null,
143
+ deletedAt: typeof meta?.deletedAt === "number" ? meta.deletedAt : 0,
144
+ bytes: stats.bytes,
145
+ files: stats.files,
146
+ restorable: typeof meta?.slug === "string" && meta.slug !== ""
147
+ };
148
+ });
149
+ rows.sort((left, right) => right.deletedAt - left.deletedAt);
150
+ return rows;
151
+ }
152
+ /** 当前归档 / 置顶 / 回收站的完整快照,附带目录位置。 */
153
+ async buildSnapshot() {
154
+ const registry = this.registry;
155
+ // 三个只读来源互不依赖:目录索引(哪些 id 在磁盘上)、回收站清单、registry 记账。
156
+ const [dirs, trashed] = await Promise.all([this.scanSessionDirs(), this.listTrash()]);
157
+ // 回收站条目**只**归回收站页签。删除时我们故意让 id 留在归档集合里(那是 DSH
158
+ // 唯一"从所有分组面消失"的机制,见 trash.ts),所以这里必须把交集滤掉:
159
+ // 否则同一个会话会同时出现在"已归档"和"回收站",而"已归档"那条还是没有目录的幽灵行。
160
+ const inTrash = new Set(trashed.map((row) => row.sessionId));
161
+ const archivedIds = [];
162
+ for (const sessionId of registry.archivedSessionIds) {
163
+ // 顺带把"目录已经不在了"的 id 也滤掉:`archivedSessionIds` 是**只增不减**的记账
164
+ //(DSH 自己的注释:目录没了的条目照样留在集合里),而 DSH 的侧边栏只从会话清单里
165
+ // 取行 —— 这种 id 在它那边本来就不显示。列出来只会得到一条 0 B、点不动的幽灵行。
166
+ if (inTrash.has(sessionId) || !dirs.has(sessionId))
167
+ continue;
168
+ archivedIds.push(sessionId);
169
+ }
170
+ const pinnedIds = [];
171
+ for (const sessionId of registry.pinnedSessionIds) {
172
+ if (inTrash.has(sessionId) || !dirs.has(sessionId))
173
+ continue;
174
+ pinnedIds.push(sessionId);
175
+ }
176
+ // 活动判定:两个集合一起去重问一遍(同一个 id 可能既归档又置顶)。结果既写进每行的
177
+ // `active`,也平铺成 `activeSessionIds` 给客户端的跨页签批量用。
178
+ const activeIds = await this.activeAmong([...new Set(archivedIds.concat(pinnedIds))]);
179
+ // 逐条取事实:并发上限里跑,顺序仍按 registry 给的顺序(行不会整片跳位)。
180
+ const [archived, pinned] = await Promise.all([
181
+ mapLimit(archivedIds, FS_CONCURRENCY, (sessionId) => this.describeSession(sessionId, dirs, activeIds.has(sessionId))),
182
+ mapLimit(pinnedIds, FS_CONCURRENCY, (sessionId) => this.describeSession(sessionId, dirs, activeIds.has(sessionId)))
183
+ ]);
184
+ let archivedBytes = 0;
185
+ for (const row of archived)
186
+ archivedBytes += row.bytes;
187
+ let trashBytes = 0;
188
+ for (const row of trashed)
189
+ trashBytes += row.bytes;
190
+ return {
191
+ dshHome: dshHomePath(),
192
+ version: LOCAL_VERSION,
193
+ trashDir: trashRoot(),
194
+ sessionsDir: sessionsRoot(),
195
+ archived,
196
+ pinned,
197
+ trashed,
198
+ totals: {
199
+ archived: archived.length,
200
+ archivedBytes,
201
+ pinned: pinned.length,
202
+ trashed: trashed.length,
203
+ trashBytes
204
+ },
205
+ activeSessionIds: [...activeIds]
206
+ };
207
+ }
208
+ /** 一批 id 里哪些正在跑(有上限地并发问 `workspace/session-activity`)。 */
209
+ async activeAmong(sessionIds) {
210
+ const flags = await mapLimit(sessionIds, ACTIVITY_CONCURRENCY, async (sessionId) => (await this.sessionActivity(sessionId)).length > 0);
211
+ const active = new Set();
212
+ sessionIds.forEach((sessionId, index) => {
213
+ if (flags[index] === true)
214
+ active.add(sessionId);
215
+ });
216
+ return active;
217
+ }
218
+ // ── 变更操作 ─────────────────────────────────────────────────────────
219
+ snapshot() {
220
+ return this.buildSnapshot();
221
+ }
222
+ /**
223
+ * **单个**会话的完整事实(第十个端点,侧边栏 hover 卡片用)。
224
+ *
225
+ * 入参复用 `ids`(线协议的 `idsOf()` 只接受字符串数组):取第一条,其余忽略 ——
226
+ * hover 卡片一次只问一个会话,把它做成批量端点没有意义。
227
+ *
228
+ * 客户端的 hover 会在 800ms 之后触发、且**每次划过都会问**,所以这里刻意只做
229
+ * `listTrash()` + `scanSessionDirs()` + 单条 `dirStats()`,不做全量快照;
230
+ * 端到端的节流由客户端那份带 TTL 的缓存负责(`src/client/detail.ts`)。
231
+ */
232
+ async describe(ids) {
233
+ const sessionId = ids[0];
234
+ if (typeof sessionId !== "string" || sessionId === "")
235
+ throw new Error("缺少会话 id");
236
+ const registry = this.registry;
237
+ const archived = registry.archivedSessionIds.includes(sessionId);
238
+ const pinned = registry.pinnedSessionIds.includes(sessionId);
239
+ // 活动判定与回收站清单互不依赖:一起等。回收站里的条目不可能正在跑(目录都搬走了),
240
+ // 所以只有磁盘上那条路要用它的结果。
241
+ const [trashed, activeList] = await Promise.all([
242
+ this.listTrash().then((rows) => rows.find((row) => row.sessionId === sessionId)),
243
+ this.sessionActivity(sessionId)
244
+ ]);
245
+ const active = activeList.length > 0;
246
+ if (trashed) {
247
+ return {
248
+ sessionId,
249
+ title: trashed.title,
250
+ cwd: trashed.cwd,
251
+ workspaceId: trashed.workspaceId,
252
+ workspaceTitle: trashed.workspaceTitle,
253
+ slug: trashed.slug,
254
+ // `__trash.json` 只记删除时间,恢复时才用得上创建时间,所以这里不硬凑。
255
+ createdAt: null,
256
+ bytes: trashed.bytes,
257
+ files: trashed.files,
258
+ onDisk: false,
259
+ archived,
260
+ pinned,
261
+ // 回收站条目:目录已经搬走,谈不上"正在跑"。
262
+ active: false,
263
+ trashed: true,
264
+ deletedAt: trashed.deletedAt,
265
+ restorable: trashed.restorable
266
+ };
267
+ }
268
+ const dirs = await this.scanSessionDirs();
269
+ if (!dirs.has(sessionId))
270
+ throw new Error("找不到这个会话(日志目录不在磁盘上)");
271
+ const row = await this.describeSession(sessionId, dirs, active);
272
+ return {
273
+ ...row,
274
+ archived,
275
+ pinned,
276
+ trashed: false,
277
+ deletedAt: null,
278
+ restorable: false
279
+ };
280
+ }
281
+ /** 逐个执行,单项失败只记录在 results 里,不影响其余项。 */
282
+ async each(ids, action) {
283
+ const results = [];
284
+ for (const sessionId of ids) {
285
+ try {
286
+ const extra = await action(sessionId);
287
+ const row = { sessionId, ok: true };
288
+ if (extra)
289
+ row.steps = extra;
290
+ results.push(row);
291
+ }
292
+ catch (error) {
293
+ results.push({ sessionId, ok: false, reason: messageOf(error) });
294
+ }
295
+ }
296
+ return { results, ...(await this.buildSnapshot()) };
297
+ }
298
+ /** `stopActivity` 为 true 时先停掉会话正在跑的工作再归档。 */
299
+ archive(ids, stopActivity) {
300
+ return this.each(ids, (sessionId) => this.registry.archiveSession(sessionId, { stopActivity: Boolean(stopActivity) }));
301
+ }
302
+ unarchive(ids) {
303
+ return this.each(ids, (sessionId) => this.registry.unarchiveSession(sessionId));
304
+ }
305
+ pin(ids) {
306
+ return this.each(ids, (sessionId) => this.registry.pinSession(sessionId));
307
+ }
308
+ unpin(ids) {
309
+ return this.each(ids, (sessionId) => this.registry.unpinSession(sessionId));
310
+ }
311
+ /** 正在跑的会话不允许删除,与官方归档同一条活动判据。 */
312
+ async sessionActivity(sessionId) {
313
+ try {
314
+ const activity = await this.ctx.waterfall("workspace/session-activity", { sessionId }, () => Promise.resolve([]));
315
+ return Array.isArray(activity) ? activity : [];
316
+ }
317
+ catch {
318
+ return [];
319
+ }
320
+ }
321
+ // ── 危险操作:实现在 trash.ts(那边有完整的事故注释) ─────────────────
322
+ trash(ids) {
323
+ return trashSessions(this, ids);
324
+ }
325
+ restore(ids) {
326
+ return restoreSessions(this, ids);
327
+ }
328
+ purge(ids) {
329
+ return purgeSessions(this, ids);
330
+ }
331
+ /**
332
+ * 回收站保留策略的**预览**(只读):多少条、多少字节会被"清理 N 天前"带走。
333
+ *
334
+ * 只统计**真正能清掉**的条目 —— 老版本留下的、没有 `__trash.json`(`deletedAt` 为 0 或
335
+ * 缺 slug)的条目不算在内:它们连删除时间都不知道,`purge-older-than` 也一定会跳过,
336
+ * 预览要是把它们算进去,用户点确认后就会发现"说好的 5 条只删了 3 条"。
337
+ */
338
+ async trashStats(days) {
339
+ const rows = await this.listTrash();
340
+ const cutoff = retentionCutoff(days);
341
+ let count = 0;
342
+ let bytes = 0;
343
+ let totalBytes = 0;
344
+ for (const row of rows) {
345
+ totalBytes += row.bytes;
346
+ if (isExpired(row, cutoff)) {
347
+ count += 1;
348
+ bytes += row.bytes;
349
+ }
350
+ }
351
+ return { days, cutoff, count, bytes, totalCount: rows.length, totalBytes };
352
+ }
353
+ /** 「清理 N 天前」:与预览同一套判据,逐项失败不影响其余项。 */
354
+ purgeOlderThan(days) {
355
+ return purgeOlderThan(this, days);
356
+ }
357
+ /** 复制路径 / 在资源管理器中显示(实现在 `open.ts`,那边有完整的路径校验注释)。 */
358
+ open(ids, target) {
359
+ return openSession(this, ids, target);
360
+ }
361
+ /**
362
+ * 磁盘占用视图(只读):整个 `<DSH_HOME>/sessions` 的占用合计 + 两个榜单。
363
+ *
364
+ * 与 `buildSnapshot()` 的关系:两者都读同一批目录,`dirStats()` 的 mtime 缓存让重复的
365
+ * 目录遍历只有第一次真的走磁盘;快照刚刚跑过的那批(归档 / 置顶)在这里直接命中缓存。
366
+ *
367
+ * 为什么要有这条端点而不是让客户端从快照里算:快照只有三个集合,**普通会话**(正在用的
368
+ * 那些)根本不在里面 —— 而"哪些会话最占地方"恰恰要问全部。
369
+ *
370
+ * 聚合按会话**当前所在的文件夹**(回收站条目用 `__trash.json` 里的原 cwd):
371
+ * 与列表分组同一套键,用户看到的"工作区"是同一个东西。
372
+ */
373
+ async usage() {
374
+ const registry = this.registry;
375
+ const [dirs, trashed] = await Promise.all([this.scanSessionDirs(), this.listTrash()]);
376
+ // 磁盘上的会话 + 回收站里的会话 = 全部会话(两边 id 不会重合,删除时目录已经搬走了)。
377
+ const onDiskIds = [...dirs.keys()];
378
+ const activeIds = await this.activeAmong(onDiskIds);
379
+ const rows = [];
380
+ const push = (row) => {
381
+ rows.push(row);
382
+ };
383
+ await mapLimit(onDiskIds, FS_CONCURRENCY, async (sessionId) => {
384
+ const described = await this.describeSession(sessionId, dirs, activeIds.has(sessionId));
385
+ push({
386
+ sessionId,
387
+ title: described.title,
388
+ cwd: described.cwd,
389
+ workspaceTitle: described.workspaceTitle,
390
+ bytes: described.bytes,
391
+ files: described.files,
392
+ createdAt: described.createdAt,
393
+ deletedAt: null,
394
+ archived: registry.archivedSessionIds.includes(sessionId),
395
+ pinned: registry.pinnedSessionIds.includes(sessionId),
396
+ trashed: false,
397
+ active: described.active
398
+ });
399
+ });
400
+ for (const row of trashed) {
401
+ push({
402
+ sessionId: row.sessionId,
403
+ title: row.title,
404
+ cwd: row.cwd,
405
+ workspaceTitle: row.workspaceTitle,
406
+ bytes: row.bytes,
407
+ files: row.files,
408
+ createdAt: null,
409
+ deletedAt: row.deletedAt > 0 ? row.deletedAt : null,
410
+ archived: registry.archivedSessionIds.includes(row.sessionId),
411
+ pinned: false,
412
+ trashed: true,
413
+ // 目录都搬进回收站了,谈不上"正在跑"。
414
+ active: false
415
+ });
416
+ }
417
+ let totalBytes = 0;
418
+ const groups = new Map();
419
+ for (const row of rows) {
420
+ totalBytes += row.bytes;
421
+ const key = groupKeyOfRow(row);
422
+ const group = groups.get(key);
423
+ if (group === undefined) {
424
+ groups.set(key, { key, title: groupTitleOf(row), count: 1, bytes: row.bytes });
425
+ }
426
+ else {
427
+ group.count += 1;
428
+ group.bytes += row.bytes;
429
+ }
430
+ }
431
+ // 榜单按"体积 / 时间"取前 N 条:客户端只画这几条,"最占地方"也不该把 191 行都发过去。
432
+ const largest = [...rows].sort((left, right) => right.bytes - left.bytes).slice(0, USAGE_TOP_LIMIT);
433
+ const oldest = [...rows]
434
+ // 没有创建时间的沉底(`createdAt: null`):`0` 会让它们排到最前面,看起来像"最老的"。
435
+ .sort((left, right) => (left.createdAt ?? Number.POSITIVE_INFINITY) - (right.createdAt ?? Number.POSITIVE_INFINITY))
436
+ .slice(0, USAGE_TOP_LIMIT);
437
+ return {
438
+ totalSessions: rows.length,
439
+ totalBytes,
440
+ onDiskSessions: onDiskIds.length,
441
+ groups: [...groups.values()].sort((left, right) => right.bytes - left.bytes),
442
+ largest,
443
+ oldest
444
+ };
445
+ }
446
+ /** 更新检查:只读,结果在模块级缓存(见 update.ts)。 */
447
+ checkUpdate(force) {
448
+ return checkForUpdate(force);
449
+ }
450
+ }
451
+ /**
452
+ * 聚合用的分组键:与客户端 `groups.ts` 的 `groupKeyOf()` **同一套语义**(cwd 归一化后优先,
453
+ * 其次 workspaceId,最后"未知目录")。两边各写一份是因为客户端那份在浏览器里、这份在宿主,
454
+ * 但规则必须一致 —— 否则"磁盘占用"里的工作区名会和列表分组对不上。
455
+ */
456
+ function groupKeyOfRow(row) {
457
+ if (typeof row.cwd === "string" && row.cwd.trim() !== "") {
458
+ return `cwd:${row.cwd
459
+ .trim()
460
+ .replace(/[\\/]+$/, "")
461
+ .replace(/\\/g, "/")
462
+ .toLowerCase()}`;
463
+ }
464
+ return "unknown";
465
+ }
466
+ /** 分组标题:workspaceTitle 优先,其次路径尾段,最后交给客户端填"未知目录"。 */
467
+ function groupTitleOf(row) {
468
+ if (typeof row.workspaceTitle === "string" && row.workspaceTitle !== "")
469
+ return row.workspaceTitle;
470
+ if (typeof row.cwd === "string" && row.cwd !== "") {
471
+ const parts = row.cwd.split(/[\\/]/).filter((part) => part !== "");
472
+ if (parts.length > 0)
473
+ return parts[parts.length - 1];
474
+ return row.cwd;
475
+ }
476
+ return "";
477
+ }