dsh-manage-session 2.0.3 → 2.0.4

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.
Files changed (43) hide show
  1. package/CHANGELOG.ja.md +33 -33
  2. package/CHANGELOG.ko.md +33 -33
  3. package/CHANGELOG.md +33 -33
  4. package/INSTALL.ja.md +164 -164
  5. package/INSTALL.ko.md +164 -164
  6. package/INSTALL.md +164 -164
  7. package/INSTALL.zh.md +164 -164
  8. package/LICENSE +21 -21
  9. package/README.en.md +76 -62
  10. package/README.ja.md +53 -39
  11. package/README.ko.md +52 -38
  12. package/README.md +18 -4
  13. package/cordis.patch.yml +19 -19
  14. package/dsh.plugin.json +2 -2
  15. package/locale/de.json +6 -6
  16. package/locale/en.json +6 -6
  17. package/locale/es.json +6 -6
  18. package/locale/fr.json +6 -6
  19. package/locale/it.json +6 -6
  20. package/locale/ja.json +6 -6
  21. package/locale/ko.json +6 -6
  22. package/locale/ru.json +6 -6
  23. package/locale/zh.json +6 -6
  24. package/package.json +9 -9
  25. package/screenshots.json +3 -3
  26. package/src/client/bridge-scope.ts +202 -202
  27. package/src/client/card.tsx +154 -154
  28. package/src/client/host-api.ts +61 -61
  29. package/src/client/index.ts +594 -594
  30. package/src/client/locales.ts +893 -893
  31. package/src/client/manage-locales.ts +178 -178
  32. package/src/client/manage.tsx +430 -430
  33. package/src/client/panel.tsx +131 -131
  34. package/src/config.ts +43 -43
  35. package/src/host/health/attribution.ts +118 -118
  36. package/src/host/health/lossless.ts +128 -128
  37. package/src/host/health/source-kind.ts +350 -350
  38. package/src/host/history/archive-source.ts +250 -250
  39. package/src/host/history/archive.ts +249 -249
  40. package/src/host/history/favorites.ts +71 -71
  41. package/src/host/history/purge.ts +370 -370
  42. package/src/host/registry/archive-registry.ts +317 -317
  43. package/src/host/settings-bridge.ts +222 -222
@@ -1,370 +1,370 @@
1
- /**
2
- * 归档文件清理(**真删除**)—— 会话管理唯一的破坏性操作。
3
- *
4
- * 与「取消归档状态」(`pruneArchiveFile`)是**两件事**,刻意分开:
5
- *
6
- * | | 取消归档状态 | 清理归档文件 |
7
- * |---|---|---|
8
- * | 改什么 | 只改 `global.archivedSessionIds` | 删磁盘实体 **+** 改两处 id |
9
- * | 会话 | 回到侧边栏 | 从世界上消失 |
10
- * | 可逆 | 重启即生效 | 不可逆 |
11
- *
12
- * 「清理归档文件」必须**连带**执行「取消归档状态」:文件删了、id 还留在数组里,
13
- * 就是一条僵尸归档 —— 侧边栏看不见它,一旦被取消归档又会冒出一个打不开的空会话。
14
- *
15
- * 一次清理动 4 处:
16
- * 1. `~/.dsh/sessions/<工作区>/<会话id>/` —— 整个目录(可能含 `session.jsonl.zstd`
17
- * 与旧格式 `session.v3.jsonl.zstd` 两份,所以必须整目录删,不能只删当前格式)
18
- * 2. `~/.dsh/storages/session_projcache/sessions/<会话id>.json` —— 逐条投影缓存
19
- * 3. `workspace.json` → `global.archivedSessionIds`
20
- * 4. `workspace.json` → `tables.workspaces.*.sessionIds`
21
- *
22
- * 执行顺序是**先改文件、后删实体**:改文件是原子的且可整体中止(失败则一个字节都没删);
23
- * 反过来先删实体再改文件,中途失败会留下僵尸 id。
24
- */
25
- import { existsSync, readdirSync, rmSync, statSync } from 'node:fs'
26
- import { basename, join, relative, resolve, sep } from 'node:path'
27
- import { editWorkspaceDocument } from './archive-source.ts'
28
- import { SOURCE_MIGRATE_BACKUP_SUFFIX } from '../health/source-kind.ts'
29
-
30
- /** 一个归档会话在磁盘上的实体与占用。 */
31
- export interface StewardSessionUsage {
32
- sessionId: string
33
- /** 转录目录绝对路径(不存在时缺省)。 */
34
- dir?: string
35
- /** 转录目录占用字节数(**含**署名转换备份;备份单列在 `backupBytes`)。 */
36
- bytes: number
37
- /**
38
- * 署名转换备份(`*.pre-sourcemigrate-<ts>`,见 `SOURCE_MIGRATE_BACKUP_SUFFIX`)
39
- * 占用字节数 —— 是 `bytes` 的子集,单列出来免得被当作日志体积误读。
40
- */
41
- backupBytes: number
42
- /** 备份文件个数。 */
43
- backupCount: number
44
- /** 投影缓存文件绝对路径(不存在时缺省)。 */
45
- cacheFile?: string
46
- /** 投影缓存占用字节数。 */
47
- cacheBytes: number
48
- }
49
-
50
- /** 是否署名转换备份文件(`*.pre-sourcemigrate-<ts>`)。 */
51
- export function isSourceMigrateBackupName(name: string): boolean {
52
- return name.includes(SOURCE_MIGRATE_BACKUP_SUFFIX)
53
- }
54
-
55
- /**
56
- * 列出一个转录目录里的署名转换备份(只扫顶层:备份与日志同目录落盘)。
57
- * @returns 备份绝对路径与合计字节数;目录不存在时为空。
58
- */
59
- export function backupFilesIn(dir: string): { files: string[]; bytes: number } {
60
- const files: string[] = []
61
- let bytes = 0
62
- let entries
63
- try {
64
- entries = readdirSync(dir, { withFileTypes: true })
65
- } catch {
66
- return { files, bytes }
67
- }
68
- for (const entry of entries) {
69
- if (!entry.isFile() || !isSourceMigrateBackupName(entry.name)) continue
70
- const path = join(dir, entry.name)
71
- files.push(path)
72
- try {
73
- bytes += statSync(path).size
74
- } catch { /* 竞态:文件刚被删 */ }
75
- }
76
- return { files, bytes }
77
- }
78
-
79
- /** 转录根目录(`<dshHome>/sessions`)。 */
80
- export function sessionsRootFor(dshHome: string): string {
81
- return join(dshHome, 'sessions')
82
- }
83
-
84
- /**
85
- * 投影缓存(**逐条布局**)根目录。
86
- *
87
- * 注意另有一个 `<dshHome>/storages/session_projcache.json`(整份布局)——
88
- * 那是布局迁移留下的**化石**,宿主早已不再写它,本插件也不碰它。
89
- */
90
- export function projCacheRootFor(dshHome: string): string {
91
- return join(dshHome, 'storages', 'session_projcache', 'sessions')
92
- }
93
-
94
- /**
95
- * 会话 id 的目录名归一化。
96
- *
97
- * 归档集合里一律是 `session-<uuid>`,但子会话的目录名是不带前缀的 `<uuid>`,
98
- * 两者必须视为同一个会话,否则清理会「找不到文件」而静默什么都没删。
99
- */
100
- export function dirKey(name: string): string {
101
- return name.startsWith('session-') ? name.slice('session-'.length) : name
102
- }
103
-
104
- /** 是不是目录(读不到就当不是)。 */
105
- function isDir(path: string): boolean {
106
- try {
107
- return statSync(path).isDirectory()
108
- } catch {
109
- return false
110
- }
111
- }
112
-
113
- /**
114
- * 扫一遍转录根,建立「会话 id → 目录」索引。
115
- *
116
- * 同一个 id 同时存在带前缀与不带前缀的目录时,**优先带 `session-` 前缀的那个**
117
- * (归档集合里的 id 本身带前缀)。
118
- * @param sessionsRoot - `<dshHome>/sessions`。
119
- * @returns 归一化 id → 目录绝对路径。
120
- */
121
- export function indexSessionDirs(sessionsRoot: string): Map<string, string> {
122
- const map = new Map<string, string>()
123
- if (!existsSync(sessionsRoot)) return map
124
- for (const workspace of readdirSync(sessionsRoot)) {
125
- const wsDir = join(sessionsRoot, workspace)
126
- if (!isDir(wsDir)) continue
127
- for (const name of readdirSync(wsDir)) {
128
- const entry = join(wsDir, name)
129
- if (!isDir(entry)) continue
130
- const key = dirKey(name)
131
- const existing = map.get(key)
132
- const prefer = name.startsWith('session-') && existing !== undefined && !basename(existing).startsWith('session-')
133
- if (existing === undefined || prefer) map.set(key, entry)
134
- }
135
- }
136
- return map
137
- }
138
-
139
- /** 递归求目录占用字节数(读不到的条目跳过,不抛错)。 */
140
- export function dirSize(dir: string): number {
141
- let total = 0
142
- let entries
143
- try {
144
- entries = readdirSync(dir, { withFileTypes: true })
145
- } catch {
146
- return 0
147
- }
148
- for (const entry of entries) {
149
- const path = join(dir, entry.name)
150
- if (entry.isDirectory()) {
151
- total += dirSize(path)
152
- } else {
153
- try {
154
- total += statSync(path).size
155
- } catch { /* 竞态:文件刚被删 */ }
156
- }
157
- }
158
- return total
159
- }
160
-
161
- /**
162
- * 解析一批会话在磁盘上的实体与占用(**只读,不删任何东西**)。
163
- *
164
- * 列表按行显示体积就走这里 —— 体积差异极大(实测单条缓存可达 23 MB、单条转录可达
165
- * 6 MB,也有 0 字节的),报一个总量对用户没有意义。
166
- * @param ids - 会话 id(带不带 `session-` 前缀都接受)。
167
- * @param dshHome - DSH 主目录。
168
- * @returns 会话 id → 实体位置与占用。
169
- */
170
- export function locateSessionUsage(ids: readonly string[], dshHome: string): Map<string, StewardSessionUsage> {
171
- const dirs = indexSessionDirs(sessionsRootFor(dshHome))
172
- const cacheRoot = projCacheRootFor(dshHome)
173
- const out = new Map<string, StewardSessionUsage>()
174
- for (const sessionId of ids) {
175
- const key = dirKey(sessionId)
176
- const usage: StewardSessionUsage = { sessionId, bytes: 0, backupBytes: 0, backupCount: 0, cacheBytes: 0 }
177
- const dir = dirs.get(key)
178
- if (dir !== undefined) {
179
- usage.dir = dir
180
- usage.bytes = dirSize(dir)
181
- // 备份单列(是 bytes 的子集):统计路不把备份当日志体积误报,清理路据此如实上报。
182
- const backups = backupFilesIn(dir)
183
- usage.backupBytes = backups.bytes
184
- usage.backupCount = backups.files.length
185
- }
186
- // 缓存文件名可能带前缀也可能不带 —— 两个候选都试。
187
- for (const name of [`${sessionId}.json`, `${key}.json`]) {
188
- const candidate = join(cacheRoot, name)
189
- if (existsSync(candidate)) {
190
- usage.cacheFile = candidate
191
- try {
192
- usage.cacheBytes = statSync(candidate).size
193
- } catch { /* 竞态 */ }
194
- break
195
- }
196
- }
197
- out.set(sessionId, usage)
198
- }
199
- return out
200
- }
201
-
202
- /**
203
- * 断言目标是 `root` 的**直接子项**(层级也校验)。
204
- *
205
- * 破坏性操作不能只靠「路径拼对了」—— 拼错一层就是删掉整个 sessions 根。
206
- * @param root - 允许的父目录。
207
- * @param target - 待删目标。
208
- * @param depth - 相对 root 的期望层数。
209
- * @returns 是否安全。
210
- */
211
- export function isSafeChild(root: string, target: string, depth: number): boolean {
212
- const rel = relative(resolve(root), resolve(target))
213
- if (rel === '' || rel.startsWith('..') || resolve(root, rel) !== resolve(target)) return false
214
- return rel.split(sep).filter((part) => part !== '').length === depth
215
- }
216
-
217
- /** 清理结果。 */
218
- export interface StewardHistoryPurgeResult {
219
- ok: boolean
220
- /** 成功清理的会话数。 */
221
- purged?: number
222
- /** 释放的字节数(转录 + 投影缓存;含随目录删除的署名转换备份)。 */
223
- freedBytes?: number
224
- /** 一并删除的署名转换备份(`*.pre-sourcemigrate-<ts>`)个数。 */
225
- backupsRemoved?: number
226
- /** 逐条失败原因;不阻断其余条目。 */
227
- failures?: { sessionId: string; reason: string }[]
228
- requiresRestart?: boolean
229
- error?: string
230
- }
231
-
232
- /** 单次清理的最大 id 数(与 prune 一致)。 */
233
- export const MAX_PURGE_IDS = 5000
234
-
235
- /** 清理选项。 */
236
- export interface StewardPurgeOptions {
237
- /** DSH 主目录;**缺失时拒绝执行**(破坏性操作不接受猜测的路径)。 */
238
- dshHome?: string
239
- /** workspace.json 候选路径覆盖(测试注入用)。 */
240
- searchPaths?: readonly string[]
241
- }
242
-
243
- /**
244
- * `session-history-purge`:**真删除**归档会话的磁盘实体,并连带取消其归档状态。
245
- * @param payload - `{ sessionIds: string[] }`。
246
- * @param log - 可选日志出口。
247
- * @param options - `dshHome` 必填;`searchPaths` 可选覆盖。
248
- * @returns 清理条数、释放字节数与逐条失败。
249
- */
250
- export function purgeHistory(
251
- payload: unknown,
252
- log?: (msg: string) => void,
253
- options?: StewardPurgeOptions,
254
- ): StewardHistoryPurgeResult {
255
- const dshHome = options?.dshHome
256
- if (dshHome === undefined || dshHome === '') {
257
- return { ok: false, error: '缺少 dshHome:破坏性清理拒绝在未知路径上执行' }
258
- }
259
- const record = payload as { sessionIds?: unknown } | null
260
- if (!Array.isArray(record?.sessionIds) || record.sessionIds.length === 0) {
261
- return { ok: false, error: '缺少 sessionIds 数组' }
262
- }
263
- const ids = [...new Set(record.sessionIds.filter((id): id is string => typeof id === 'string' && id !== ''))]
264
- if (ids.length === 0) return { ok: false, error: 'sessionIds 无有效值' }
265
- if (ids.length > MAX_PURGE_IDS) return { ok: false, error: `单次最多清理 ${MAX_PURGE_IDS} 个` }
266
-
267
- const wanted = new Set(ids)
268
- const usage = locateSessionUsage(ids, dshHome)
269
-
270
- // ── 第一步:改 workspace.json(归档状态 + 工作区成员表)。原子 + 备份;失败即整体中止。
271
- const candidates = options?.searchPaths ?? [join(dshHome, 'storages', 'workspace.json')]
272
- let edited = false
273
- let lastReason = `workspace 存储文件不存在(已试:${candidates.join('、')})`
274
- for (const path of candidates) {
275
- const outcome = editWorkspaceDocument(path, (document) => {
276
- let changed = false
277
- // ③ 归档集合
278
- const global = document.global as { archivedSessionIds?: unknown } | undefined
279
- if (Array.isArray(global?.archivedSessionIds)) {
280
- const kept = global.archivedSessionIds.filter((id) => typeof id !== 'string' || !wanted.has(id))
281
- if (kept.length !== global.archivedSessionIds.length) {
282
- document.global = { ...global, archivedSessionIds: kept }
283
- changed = true
284
- }
285
- }
286
- // ④ 工作区成员表
287
- const tables = document.tables as { workspaces?: Record<string, { sessionIds?: unknown }> } | undefined
288
- if (tables?.workspaces !== undefined) {
289
- for (const [workspaceId, workspace] of Object.entries(tables.workspaces)) {
290
- if (!Array.isArray(workspace?.sessionIds)) continue
291
- const kept = workspace.sessionIds.filter((id) => typeof id !== 'string' || !wanted.has(id))
292
- if (kept.length !== workspace.sessionIds.length) {
293
- tables.workspaces[workspaceId] = { ...workspace, sessionIds: kept }
294
- changed = true
295
- }
296
- }
297
- }
298
- return changed
299
- }, log)
300
- if (!outcome.ok) { lastReason = outcome.reason; continue }
301
- edited = true
302
- break
303
- }
304
- if (!edited) return { ok: false, error: lastReason }
305
-
306
- // ── 第二步:删磁盘实体。逐条独立,失败只记不抛。
307
- const sessionsRoot = sessionsRootFor(dshHome)
308
- const cacheRoot = projCacheRootFor(dshHome)
309
- const failures: { sessionId: string; reason: string }[] = []
310
- let purged = 0
311
- let freedBytes = 0
312
- let backupsRemoved = 0
313
-
314
- for (const sessionId of ids) {
315
- const entry = usage.get(sessionId)
316
- let missing = 0
317
- let entryFreed = 0
318
-
319
- if (entry?.dir !== undefined) {
320
- if (!isSafeChild(sessionsRoot, entry.dir, 2)) {
321
- failures.push({ sessionId, reason: `拒绝删除越界路径:${entry.dir}` })
322
- continue
323
- }
324
- // 整目录删除已连带备份;这里只是**先点个数**,让结果如实上报「备份一并处置了」。
325
- backupsRemoved += entry.backupCount
326
- try {
327
- rmSync(entry.dir, { recursive: true, force: true })
328
- entryFreed += entry.bytes
329
- } catch (err) {
330
- failures.push({ sessionId, reason: `删除转录目录失败:${String(err instanceof Error ? err.message : err)}` })
331
- continue
332
- }
333
- } else {
334
- missing++
335
- }
336
-
337
- if (entry?.cacheFile !== undefined) {
338
- if (!isSafeChild(cacheRoot, entry.cacheFile, 1)) {
339
- failures.push({ sessionId, reason: `拒绝删除越界缓存:${entry.cacheFile}` })
340
- continue
341
- }
342
- try {
343
- entryFreed += entry.cacheBytes
344
- rmSync(entry.cacheFile, { force: true })
345
- } catch (err) {
346
- failures.push({ sessionId, reason: `删除投影缓存失败:${String(err instanceof Error ? err.message : err)}` })
347
- continue
348
- }
349
- } else {
350
- missing++
351
- }
352
-
353
- if (missing === 2) {
354
- // 磁盘上本来就没有实体(可能上一轮删过)—— 状态已取消,算成功,但不释放空间。
355
- log?.(`purge: ${sessionId} had no on-disk entity; only the archive state was cleared`)
356
- }
357
- purged++
358
- freedBytes += entryFreed
359
- }
360
-
361
- log?.(`archive purge: cleared ${purged} of ${ids.length}; freed ${(freedBytes / 1048576).toFixed(1)}MB; backups ${backupsRemoved}; failures ${failures.length}`)
362
- return {
363
- ok: true,
364
- purged,
365
- freedBytes,
366
- backupsRemoved,
367
- requiresRestart: true,
368
- ...(failures.length === 0 ? {} : { failures }),
369
- }
370
- }
1
+ /**
2
+ * 归档文件清理(**真删除**)—— 会话管理唯一的破坏性操作。
3
+ *
4
+ * 与「取消归档状态」(`pruneArchiveFile`)是**两件事**,刻意分开:
5
+ *
6
+ * | | 取消归档状态 | 清理归档文件 |
7
+ * |---|---|---|
8
+ * | 改什么 | 只改 `global.archivedSessionIds` | 删磁盘实体 **+** 改两处 id |
9
+ * | 会话 | 回到侧边栏 | 从世界上消失 |
10
+ * | 可逆 | 重启即生效 | 不可逆 |
11
+ *
12
+ * 「清理归档文件」必须**连带**执行「取消归档状态」:文件删了、id 还留在数组里,
13
+ * 就是一条僵尸归档 —— 侧边栏看不见它,一旦被取消归档又会冒出一个打不开的空会话。
14
+ *
15
+ * 一次清理动 4 处:
16
+ * 1. `~/.dsh/sessions/<工作区>/<会话id>/` —— 整个目录(可能含 `session.jsonl.zstd`
17
+ * 与旧格式 `session.v3.jsonl.zstd` 两份,所以必须整目录删,不能只删当前格式)
18
+ * 2. `~/.dsh/storages/session_projcache/sessions/<会话id>.json` —— 逐条投影缓存
19
+ * 3. `workspace.json` → `global.archivedSessionIds`
20
+ * 4. `workspace.json` → `tables.workspaces.*.sessionIds`
21
+ *
22
+ * 执行顺序是**先改文件、后删实体**:改文件是原子的且可整体中止(失败则一个字节都没删);
23
+ * 反过来先删实体再改文件,中途失败会留下僵尸 id。
24
+ */
25
+ import { existsSync, readdirSync, rmSync, statSync } from 'node:fs'
26
+ import { basename, join, relative, resolve, sep } from 'node:path'
27
+ import { editWorkspaceDocument } from './archive-source.ts'
28
+ import { SOURCE_MIGRATE_BACKUP_SUFFIX } from '../health/source-kind.ts'
29
+
30
+ /** 一个归档会话在磁盘上的实体与占用。 */
31
+ export interface StewardSessionUsage {
32
+ sessionId: string
33
+ /** 转录目录绝对路径(不存在时缺省)。 */
34
+ dir?: string
35
+ /** 转录目录占用字节数(**含**署名转换备份;备份单列在 `backupBytes`)。 */
36
+ bytes: number
37
+ /**
38
+ * 署名转换备份(`*.pre-sourcemigrate-<ts>`,见 `SOURCE_MIGRATE_BACKUP_SUFFIX`)
39
+ * 占用字节数 —— 是 `bytes` 的子集,单列出来免得被当作日志体积误读。
40
+ */
41
+ backupBytes: number
42
+ /** 备份文件个数。 */
43
+ backupCount: number
44
+ /** 投影缓存文件绝对路径(不存在时缺省)。 */
45
+ cacheFile?: string
46
+ /** 投影缓存占用字节数。 */
47
+ cacheBytes: number
48
+ }
49
+
50
+ /** 是否署名转换备份文件(`*.pre-sourcemigrate-<ts>`)。 */
51
+ export function isSourceMigrateBackupName(name: string): boolean {
52
+ return name.includes(SOURCE_MIGRATE_BACKUP_SUFFIX)
53
+ }
54
+
55
+ /**
56
+ * 列出一个转录目录里的署名转换备份(只扫顶层:备份与日志同目录落盘)。
57
+ * @returns 备份绝对路径与合计字节数;目录不存在时为空。
58
+ */
59
+ export function backupFilesIn(dir: string): { files: string[]; bytes: number } {
60
+ const files: string[] = []
61
+ let bytes = 0
62
+ let entries
63
+ try {
64
+ entries = readdirSync(dir, { withFileTypes: true })
65
+ } catch {
66
+ return { files, bytes }
67
+ }
68
+ for (const entry of entries) {
69
+ if (!entry.isFile() || !isSourceMigrateBackupName(entry.name)) continue
70
+ const path = join(dir, entry.name)
71
+ files.push(path)
72
+ try {
73
+ bytes += statSync(path).size
74
+ } catch { /* 竞态:文件刚被删 */ }
75
+ }
76
+ return { files, bytes }
77
+ }
78
+
79
+ /** 转录根目录(`<dshHome>/sessions`)。 */
80
+ export function sessionsRootFor(dshHome: string): string {
81
+ return join(dshHome, 'sessions')
82
+ }
83
+
84
+ /**
85
+ * 投影缓存(**逐条布局**)根目录。
86
+ *
87
+ * 注意另有一个 `<dshHome>/storages/session_projcache.json`(整份布局)——
88
+ * 那是布局迁移留下的**化石**,宿主早已不再写它,本插件也不碰它。
89
+ */
90
+ export function projCacheRootFor(dshHome: string): string {
91
+ return join(dshHome, 'storages', 'session_projcache', 'sessions')
92
+ }
93
+
94
+ /**
95
+ * 会话 id 的目录名归一化。
96
+ *
97
+ * 归档集合里一律是 `session-<uuid>`,但子会话的目录名是不带前缀的 `<uuid>`,
98
+ * 两者必须视为同一个会话,否则清理会「找不到文件」而静默什么都没删。
99
+ */
100
+ export function dirKey(name: string): string {
101
+ return name.startsWith('session-') ? name.slice('session-'.length) : name
102
+ }
103
+
104
+ /** 是不是目录(读不到就当不是)。 */
105
+ function isDir(path: string): boolean {
106
+ try {
107
+ return statSync(path).isDirectory()
108
+ } catch {
109
+ return false
110
+ }
111
+ }
112
+
113
+ /**
114
+ * 扫一遍转录根,建立「会话 id → 目录」索引。
115
+ *
116
+ * 同一个 id 同时存在带前缀与不带前缀的目录时,**优先带 `session-` 前缀的那个**
117
+ * (归档集合里的 id 本身带前缀)。
118
+ * @param sessionsRoot - `<dshHome>/sessions`。
119
+ * @returns 归一化 id → 目录绝对路径。
120
+ */
121
+ export function indexSessionDirs(sessionsRoot: string): Map<string, string> {
122
+ const map = new Map<string, string>()
123
+ if (!existsSync(sessionsRoot)) return map
124
+ for (const workspace of readdirSync(sessionsRoot)) {
125
+ const wsDir = join(sessionsRoot, workspace)
126
+ if (!isDir(wsDir)) continue
127
+ for (const name of readdirSync(wsDir)) {
128
+ const entry = join(wsDir, name)
129
+ if (!isDir(entry)) continue
130
+ const key = dirKey(name)
131
+ const existing = map.get(key)
132
+ const prefer = name.startsWith('session-') && existing !== undefined && !basename(existing).startsWith('session-')
133
+ if (existing === undefined || prefer) map.set(key, entry)
134
+ }
135
+ }
136
+ return map
137
+ }
138
+
139
+ /** 递归求目录占用字节数(读不到的条目跳过,不抛错)。 */
140
+ export function dirSize(dir: string): number {
141
+ let total = 0
142
+ let entries
143
+ try {
144
+ entries = readdirSync(dir, { withFileTypes: true })
145
+ } catch {
146
+ return 0
147
+ }
148
+ for (const entry of entries) {
149
+ const path = join(dir, entry.name)
150
+ if (entry.isDirectory()) {
151
+ total += dirSize(path)
152
+ } else {
153
+ try {
154
+ total += statSync(path).size
155
+ } catch { /* 竞态:文件刚被删 */ }
156
+ }
157
+ }
158
+ return total
159
+ }
160
+
161
+ /**
162
+ * 解析一批会话在磁盘上的实体与占用(**只读,不删任何东西**)。
163
+ *
164
+ * 列表按行显示体积就走这里 —— 体积差异极大(实测单条缓存可达 23 MB、单条转录可达
165
+ * 6 MB,也有 0 字节的),报一个总量对用户没有意义。
166
+ * @param ids - 会话 id(带不带 `session-` 前缀都接受)。
167
+ * @param dshHome - DSH 主目录。
168
+ * @returns 会话 id → 实体位置与占用。
169
+ */
170
+ export function locateSessionUsage(ids: readonly string[], dshHome: string): Map<string, StewardSessionUsage> {
171
+ const dirs = indexSessionDirs(sessionsRootFor(dshHome))
172
+ const cacheRoot = projCacheRootFor(dshHome)
173
+ const out = new Map<string, StewardSessionUsage>()
174
+ for (const sessionId of ids) {
175
+ const key = dirKey(sessionId)
176
+ const usage: StewardSessionUsage = { sessionId, bytes: 0, backupBytes: 0, backupCount: 0, cacheBytes: 0 }
177
+ const dir = dirs.get(key)
178
+ if (dir !== undefined) {
179
+ usage.dir = dir
180
+ usage.bytes = dirSize(dir)
181
+ // 备份单列(是 bytes 的子集):统计路不把备份当日志体积误报,清理路据此如实上报。
182
+ const backups = backupFilesIn(dir)
183
+ usage.backupBytes = backups.bytes
184
+ usage.backupCount = backups.files.length
185
+ }
186
+ // 缓存文件名可能带前缀也可能不带 —— 两个候选都试。
187
+ for (const name of [`${sessionId}.json`, `${key}.json`]) {
188
+ const candidate = join(cacheRoot, name)
189
+ if (existsSync(candidate)) {
190
+ usage.cacheFile = candidate
191
+ try {
192
+ usage.cacheBytes = statSync(candidate).size
193
+ } catch { /* 竞态 */ }
194
+ break
195
+ }
196
+ }
197
+ out.set(sessionId, usage)
198
+ }
199
+ return out
200
+ }
201
+
202
+ /**
203
+ * 断言目标是 `root` 的**直接子项**(层级也校验)。
204
+ *
205
+ * 破坏性操作不能只靠「路径拼对了」—— 拼错一层就是删掉整个 sessions 根。
206
+ * @param root - 允许的父目录。
207
+ * @param target - 待删目标。
208
+ * @param depth - 相对 root 的期望层数。
209
+ * @returns 是否安全。
210
+ */
211
+ export function isSafeChild(root: string, target: string, depth: number): boolean {
212
+ const rel = relative(resolve(root), resolve(target))
213
+ if (rel === '' || rel.startsWith('..') || resolve(root, rel) !== resolve(target)) return false
214
+ return rel.split(sep).filter((part) => part !== '').length === depth
215
+ }
216
+
217
+ /** 清理结果。 */
218
+ export interface StewardHistoryPurgeResult {
219
+ ok: boolean
220
+ /** 成功清理的会话数。 */
221
+ purged?: number
222
+ /** 释放的字节数(转录 + 投影缓存;含随目录删除的署名转换备份)。 */
223
+ freedBytes?: number
224
+ /** 一并删除的署名转换备份(`*.pre-sourcemigrate-<ts>`)个数。 */
225
+ backupsRemoved?: number
226
+ /** 逐条失败原因;不阻断其余条目。 */
227
+ failures?: { sessionId: string; reason: string }[]
228
+ requiresRestart?: boolean
229
+ error?: string
230
+ }
231
+
232
+ /** 单次清理的最大 id 数(与 prune 一致)。 */
233
+ export const MAX_PURGE_IDS = 5000
234
+
235
+ /** 清理选项。 */
236
+ export interface StewardPurgeOptions {
237
+ /** DSH 主目录;**缺失时拒绝执行**(破坏性操作不接受猜测的路径)。 */
238
+ dshHome?: string
239
+ /** workspace.json 候选路径覆盖(测试注入用)。 */
240
+ searchPaths?: readonly string[]
241
+ }
242
+
243
+ /**
244
+ * `session-history-purge`:**真删除**归档会话的磁盘实体,并连带取消其归档状态。
245
+ * @param payload - `{ sessionIds: string[] }`。
246
+ * @param log - 可选日志出口。
247
+ * @param options - `dshHome` 必填;`searchPaths` 可选覆盖。
248
+ * @returns 清理条数、释放字节数与逐条失败。
249
+ */
250
+ export function purgeHistory(
251
+ payload: unknown,
252
+ log?: (msg: string) => void,
253
+ options?: StewardPurgeOptions,
254
+ ): StewardHistoryPurgeResult {
255
+ const dshHome = options?.dshHome
256
+ if (dshHome === undefined || dshHome === '') {
257
+ return { ok: false, error: '缺少 dshHome:破坏性清理拒绝在未知路径上执行' }
258
+ }
259
+ const record = payload as { sessionIds?: unknown } | null
260
+ if (!Array.isArray(record?.sessionIds) || record.sessionIds.length === 0) {
261
+ return { ok: false, error: '缺少 sessionIds 数组' }
262
+ }
263
+ const ids = [...new Set(record.sessionIds.filter((id): id is string => typeof id === 'string' && id !== ''))]
264
+ if (ids.length === 0) return { ok: false, error: 'sessionIds 无有效值' }
265
+ if (ids.length > MAX_PURGE_IDS) return { ok: false, error: `单次最多清理 ${MAX_PURGE_IDS} 个` }
266
+
267
+ const wanted = new Set(ids)
268
+ const usage = locateSessionUsage(ids, dshHome)
269
+
270
+ // ── 第一步:改 workspace.json(归档状态 + 工作区成员表)。原子 + 备份;失败即整体中止。
271
+ const candidates = options?.searchPaths ?? [join(dshHome, 'storages', 'workspace.json')]
272
+ let edited = false
273
+ let lastReason = `workspace 存储文件不存在(已试:${candidates.join('、')})`
274
+ for (const path of candidates) {
275
+ const outcome = editWorkspaceDocument(path, (document) => {
276
+ let changed = false
277
+ // ③ 归档集合
278
+ const global = document.global as { archivedSessionIds?: unknown } | undefined
279
+ if (Array.isArray(global?.archivedSessionIds)) {
280
+ const kept = global.archivedSessionIds.filter((id) => typeof id !== 'string' || !wanted.has(id))
281
+ if (kept.length !== global.archivedSessionIds.length) {
282
+ document.global = { ...global, archivedSessionIds: kept }
283
+ changed = true
284
+ }
285
+ }
286
+ // ④ 工作区成员表
287
+ const tables = document.tables as { workspaces?: Record<string, { sessionIds?: unknown }> } | undefined
288
+ if (tables?.workspaces !== undefined) {
289
+ for (const [workspaceId, workspace] of Object.entries(tables.workspaces)) {
290
+ if (!Array.isArray(workspace?.sessionIds)) continue
291
+ const kept = workspace.sessionIds.filter((id) => typeof id !== 'string' || !wanted.has(id))
292
+ if (kept.length !== workspace.sessionIds.length) {
293
+ tables.workspaces[workspaceId] = { ...workspace, sessionIds: kept }
294
+ changed = true
295
+ }
296
+ }
297
+ }
298
+ return changed
299
+ }, log)
300
+ if (!outcome.ok) { lastReason = outcome.reason; continue }
301
+ edited = true
302
+ break
303
+ }
304
+ if (!edited) return { ok: false, error: lastReason }
305
+
306
+ // ── 第二步:删磁盘实体。逐条独立,失败只记不抛。
307
+ const sessionsRoot = sessionsRootFor(dshHome)
308
+ const cacheRoot = projCacheRootFor(dshHome)
309
+ const failures: { sessionId: string; reason: string }[] = []
310
+ let purged = 0
311
+ let freedBytes = 0
312
+ let backupsRemoved = 0
313
+
314
+ for (const sessionId of ids) {
315
+ const entry = usage.get(sessionId)
316
+ let missing = 0
317
+ let entryFreed = 0
318
+
319
+ if (entry?.dir !== undefined) {
320
+ if (!isSafeChild(sessionsRoot, entry.dir, 2)) {
321
+ failures.push({ sessionId, reason: `拒绝删除越界路径:${entry.dir}` })
322
+ continue
323
+ }
324
+ // 整目录删除已连带备份;这里只是**先点个数**,让结果如实上报「备份一并处置了」。
325
+ backupsRemoved += entry.backupCount
326
+ try {
327
+ rmSync(entry.dir, { recursive: true, force: true })
328
+ entryFreed += entry.bytes
329
+ } catch (err) {
330
+ failures.push({ sessionId, reason: `删除转录目录失败:${String(err instanceof Error ? err.message : err)}` })
331
+ continue
332
+ }
333
+ } else {
334
+ missing++
335
+ }
336
+
337
+ if (entry?.cacheFile !== undefined) {
338
+ if (!isSafeChild(cacheRoot, entry.cacheFile, 1)) {
339
+ failures.push({ sessionId, reason: `拒绝删除越界缓存:${entry.cacheFile}` })
340
+ continue
341
+ }
342
+ try {
343
+ entryFreed += entry.cacheBytes
344
+ rmSync(entry.cacheFile, { force: true })
345
+ } catch (err) {
346
+ failures.push({ sessionId, reason: `删除投影缓存失败:${String(err instanceof Error ? err.message : err)}` })
347
+ continue
348
+ }
349
+ } else {
350
+ missing++
351
+ }
352
+
353
+ if (missing === 2) {
354
+ // 磁盘上本来就没有实体(可能上一轮删过)—— 状态已取消,算成功,但不释放空间。
355
+ log?.(`purge: ${sessionId} had no on-disk entity; only the archive state was cleared`)
356
+ }
357
+ purged++
358
+ freedBytes += entryFreed
359
+ }
360
+
361
+ log?.(`archive purge: cleared ${purged} of ${ids.length}; freed ${(freedBytes / 1048576).toFixed(1)}MB; backups ${backupsRemoved}; failures ${failures.length}`)
362
+ return {
363
+ ok: true,
364
+ purged,
365
+ freedBytes,
366
+ backupsRemoved,
367
+ requiresRestart: true,
368
+ ...(failures.length === 0 ? {} : { failures }),
369
+ }
370
+ }