dsh-manage-session 2.0.3 → 2.0.5
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/CHANGELOG.ja.md +33 -33
- package/CHANGELOG.ko.md +33 -33
- package/CHANGELOG.md +33 -33
- package/INSTALL.ja.md +164 -164
- package/INSTALL.ko.md +164 -164
- package/INSTALL.md +164 -164
- package/INSTALL.zh.md +164 -164
- package/LICENSE +21 -21
- package/README.en.md +76 -62
- package/README.ja.md +53 -39
- package/README.ko.md +52 -38
- package/README.md +18 -4
- package/cordis.patch.yml +19 -19
- package/dsh.plugin.json +2 -2
- package/locale/de.json +6 -6
- package/locale/en.json +6 -6
- package/locale/es.json +6 -6
- package/locale/fr.json +6 -6
- package/locale/it.json +6 -6
- package/locale/ja.json +6 -6
- package/locale/ko.json +6 -6
- package/locale/ru.json +6 -6
- package/locale/zh.json +6 -6
- package/package.json +9 -9
- package/screenshots.json +3 -3
- package/src/client/bridge-scope.ts +202 -202
- package/src/client/card.tsx +154 -154
- package/src/client/host-api.ts +61 -61
- package/src/client/index.ts +594 -594
- package/src/client/locales.ts +893 -893
- package/src/client/manage-locales.ts +178 -178
- package/src/client/manage.tsx +430 -430
- package/src/client/panel.tsx +131 -131
- package/src/config.ts +43 -43
- package/src/host/health/attribution.ts +118 -118
- package/src/host/health/lossless.ts +128 -128
- package/src/host/health/source-kind.ts +350 -350
- package/src/host/history/archive-source.ts +250 -250
- package/src/host/history/archive.ts +249 -249
- package/src/host/history/favorites.ts +71 -71
- package/src/host/history/purge.ts +370 -370
- package/src/host/registry/archive-registry.ts +317 -317
- 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
|
+
}
|