dsh-vscode-mode 0.1.29 → 0.1.32

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/paths.ts ADDED
@@ -0,0 +1,284 @@
1
+ /**
2
+ * dsh-vscode-mode host — 路径常量与动态路径统一出口(PathConst)。
3
+ * 所有 host 侧文件路径/缓存键/路由前缀从这里取,避免散落内联:
4
+ * - 静态:插件缓存根(~/.dsh/dsh-vscode-mode/cache,下分 workspace/ 工作区级与
5
+ * user/ 用户级)、日志根、工作区侧车名、路由前缀、静态资源目录(import.meta.url 派生)
6
+ * - 动态:每工作区树缓存文件(workspace/<id>/tree.v<schema>.json)、home 解析
7
+ * (DSH_HOME → ~/.dsh)
8
+ * - 清理:缓存 sweep(分级:工作区级按版本/TTL/空目录、用户级按版本/保留期、根级残留)
9
+ * 纯函数可单测;raw fs 写入与引擎自身插件(settings-file 用 node:fs + DSH_HOME)一致。
10
+ * 作者 ddj 2026-09-01
11
+ */
12
+ import { createHash } from 'node:crypto'
13
+ import { homedir } from 'node:os'
14
+ import { dirname, join } from 'node:path'
15
+ import { fileURLToPath } from 'node:url'
16
+ import { readdir, rm, stat } from 'node:fs/promises'
17
+ import { RPC_PATH } from './shared/rpc.js'
18
+
19
+ // --region 常量
20
+ /** 插件 id(也是 .dsh 下插件工作区目录名)。 */
21
+ export const PLUGIN_ID = 'dsh-vscode-mode'
22
+ /** DSH 工作区目录名(默认 home 下)。 */
23
+ export const DSH_DIR_NAME = '.dsh'
24
+ /** home 覆盖环境变量(与引擎 resolveDshHome 一致)。 */
25
+ export const DSH_HOME_ENV = 'DSH_HOME'
26
+ /** 树缓存 schema 版本(文件名携带;版本递增即触发旧版清理)。 */
27
+ export const TREE_CACHE_SCHEMA = 1
28
+ /** 树缓存保留期:超期文件视为废弃(工作区搬迁/废弃残留)。 */
29
+ export const TREE_RETENTION_MS = 30 * 24 * 60 * 60 * 1000
30
+ /** 插件缓存目录总预算:超限按 mtime 从旧到新删文件,防任意增长撑爆磁盘(最后兜底)。 */
31
+ export const CACHE_TOTAL_CAP = 200 * 1024 * 1024
32
+
33
+ /** 工作区根侧车:审查记录(用户数据,随项目走,不迁移)。 */
34
+ export const SIDECAR = '.dsh-edit-review.json'
35
+ /** 工作区根侧车:归档(用户数据)。 */
36
+ export const SIDECAR_ARCHIVE = '.dsh-edit-review-archive.json'
37
+ /** 旧 debug 日志文件名(工作区,迁移后由插件清理)。 */
38
+ export const DEBUG_LOG = '.dsh-edit-review-debug.log'
39
+ /** 旧树缓存 sidecar 名(工作区,迁移后由插件清理;缓存可重建,删除无损)。 */
40
+ export const LEGACY_TREE_SIDECARS = ['.dsh-edit-review-tree.json', '.dsh-vscode-mode-tree.json']
41
+
42
+ /** RPC 精确路由(shared 定义,这里统一出口)。 */
43
+ export { RPC_PATH }
44
+ /** 路由前缀(兼容性检查用)。 */
45
+ export const ROUTE_PREFIX = '/edrv'
46
+ /** Monaco 静态资源前缀。 */
47
+ export const VENDOR_PREFIX = '/edrv/vendor'
48
+ // --endregion
49
+
50
+ /** 稳定短哈希(cwd → 缓存文件名段;sha1 截断,跨运行稳定)。 */
51
+ export function hashOf(text: string): string {
52
+ return createHash('sha1').update(String(text)).digest('hex').slice(0, 16)
53
+ }
54
+
55
+ // --region DSH 会话定位编码
56
+ /**
57
+ * 可读安全路径段编码:非安全码位 → ~XXXX 大写十六进制(`~` 本身也转义)。
58
+ * 与 DSH 引擎 dsh-session-persistence-jsonl 的 encodeSegment 逐字节一致,
59
+ * 用于从 cwd/id 反推 `~/.dsh/sessions` 下的会话目录,纯函数可单测。
60
+ * @author ddj 2026年09月02号
61
+ * @param raw 原始路径段(非空)
62
+ * @returns 路径安全段(`.`/`..` 特判为 ~002E / ~002E~002E)
63
+ */
64
+ export function encodeSegment(raw: string): string {
65
+ if (raw.length === 0) throw new Error('cannot encode an empty path segment')
66
+ if (raw === '.') return '~002E'
67
+ if (raw === '..') return '~002E~002E'
68
+ let out = ''
69
+ for (let i = 0; i < raw.length; i++) {
70
+ const code = raw.charCodeAt(i)
71
+ const ch = String.fromCharCode(code)
72
+ if (ch !== '~' && /^[A-Za-z0-9._-]$/.test(ch)) out += ch
73
+ else out += '~' + code.toString(16).toUpperCase().padStart(4, '0')
74
+ }
75
+ return out
76
+ }
77
+
78
+ /**
79
+ * DSH 工作区目录键:cwd → `--<可读键>--`。分隔符(/ \ :)折叠为单个 `-`,
80
+ * 其余非安全码位转 ~XXXX,`--` 包裹、251 截断。与引擎 projectKey 一致。
81
+ * @author ddj 2026年09月02号
82
+ * @param cwd 会话工作区绝对路径(非空)
83
+ * @returns `~/.dsh/sessions` 下的工作区目录名
84
+ */
85
+ export function sessionWorkspaceKey(cwd: string): string {
86
+ if (cwd.length === 0) throw new Error('cannot encode an empty project path')
87
+ let readable = ''
88
+ let separatorRun = false
89
+ for (let i = 0; i < cwd.length; i++) {
90
+ const code = cwd.charCodeAt(i)
91
+ const ch = String.fromCharCode(code)
92
+ if (ch === '/' || ch === '\\' || ch === ':') {
93
+ if (!separatorRun) readable += '-'
94
+ separatorRun = true
95
+ } else if (ch !== '~' && /^[A-Za-z0-9._-]$/.test(ch)) {
96
+ readable += ch
97
+ separatorRun = false
98
+ } else {
99
+ readable += '~' + code.toString(16).toUpperCase().padStart(4, '0')
100
+ separatorRun = false
101
+ }
102
+ }
103
+ return `--${(readable.replace(/^-+/, '') || 'root').slice(0, 251)}--`
104
+ }
105
+
106
+ /** DSH 会话目录段:id → 路径安全段(与引擎 encodeSegment 一致)。 */
107
+ export function sessionIdSegment(id: string): string {
108
+ return encodeSegment(id)
109
+ }
110
+
111
+ /** DSH 会话根目录(~/.dsh/sessions;引擎 dsh-base 默认 root)。 */
112
+ export function sessionsRoot(home = dshHome()): string {
113
+ return join(home, 'sessions')
114
+ }
115
+ // --endregion
116
+
117
+ /**
118
+ * 解析 DSH home:DSH_HOME(去空白)→ ~/.dsh。
119
+ * @author ddj 2026年09月01号
120
+ * @param env 环境映射(缺省 process.env;测试可注入)
121
+ * @returns 归一化的 DSH home 绝对路径
122
+ */
123
+ export function dshHome(env: Record<string, string | undefined> = process.env): string {
124
+ const value = env[DSH_HOME_ENV]
125
+ if (value && value.trim()) return value
126
+ return join(homedir(), DSH_DIR_NAME)
127
+ }
128
+
129
+ /** 插件缓存根(~/.dsh/dsh-vscode-mode/cache,下分 workspace/ 与 user/ 两级)。 */
130
+ export function pluginCacheRoot(home = dshHome()): string {
131
+ return join(home, PLUGIN_ID, 'cache')
132
+ }
133
+
134
+ /** 工作区级缓存目录:按工作区 id(cwd hash)隔离,换工作区即失效。 */
135
+ export function workspaceCacheDir(workspaceId: string, home = dshHome()): string {
136
+ return join(pluginCacheRoot(home), 'workspace', workspaceId)
137
+ }
138
+
139
+ /** 用户级缓存目录(跨工作区共享;当前无实例,预留供全局偏好类缓存使用)。 */
140
+ export function userCacheDir(home = dshHome()): string {
141
+ return join(pluginCacheRoot(home), 'user')
142
+ }
143
+
144
+ /** 插件日志根(~/.dsh/dsh-vscode-mode/logs,用户级)。 */
145
+ export function pluginLogRoot(home = dshHome()): string {
146
+ return join(home, PLUGIN_ID, 'logs')
147
+ }
148
+
149
+ /** 每工作区树缓存文件(工作区级:workspace/<id>/tree.v<schema>.json,文件名含版本便于 sweep)。 */
150
+ export function treeCacheFile(cwd: string, home = dshHome(), schema = TREE_CACHE_SCHEMA): string {
151
+ return join(workspaceCacheDir(hashOf(cwd), home), 'tree.v' + schema + '.json')
152
+ }
153
+
154
+ /** 每工作区 debug 日志文件(用户级日志根下,按 cwd hash 隔离)。 */
155
+ export function debugLogFile(cwd: string, home = dshHome()): string {
156
+ return join(pluginLogRoot(home), 'debug.' + hashOf(cwd) + '.log')
157
+ }
158
+
159
+ /** 插件包 assets 目录(import.meta.url 派生;config.imageDir 可覆盖,见 imageDirOf)。 */
160
+ export function assetsDirOf(moduleUrl: string): string {
161
+ return join(dirname(fileURLToPath(moduleUrl)), '..', 'assets')
162
+ }
163
+
164
+ /** Monaco AMD 静态目录(随包发布)。 */
165
+ export function vendorDirOf(moduleUrl: string): string {
166
+ return join(assetsDirOf(moduleUrl), 'vendor')
167
+ }
168
+
169
+ /** 图标目录:config.imageDir 覆盖优先,否则插件包 assets/。 */
170
+ export function imageDirOf(config: unknown, moduleUrl: string): string {
171
+ const cfg = config as { imageDir?: unknown } | undefined
172
+ if (cfg && typeof cfg.imageDir === 'string' && cfg.imageDir) return cfg.imageDir.replace(/\\/g, '/')
173
+ return assetsDirOf(moduleUrl)
174
+ }
175
+
176
+ // --region 缓存清理
177
+ /** 树缓存文件名 → schema 版本(不匹配返回 null)。 */
178
+ export function treeSchemaOf(filename: string): number | null {
179
+ const m = /^tree\.v(\d+)\./.exec(filename)
180
+ return m ? Number(m[1]) : null
181
+ }
182
+
183
+ /** 按「schema 版本 + 保留期」清扫某目录内文件(目录级,返回相对路径清单)。 */
184
+ async function sweepDirFiles(dir: string, removed: string[], now: number, dropAll: boolean): Promise<void> {
185
+ const names = await readdir(dir).catch(() => [])
186
+ for (const name of names) {
187
+ const full = join(dir, name)
188
+ let drop = dropAll
189
+ if (!drop) {
190
+ const schema = treeSchemaOf(name)
191
+ if (schema === null) drop = true // 未知残留
192
+ else if (schema !== TREE_CACHE_SCHEMA) drop = true // 旧版本
193
+ else {
194
+ try {
195
+ const info = await stat(full)
196
+ if (now - info.mtimeMs > TREE_RETENTION_MS) drop = true
197
+ } catch (error) { drop = true }
198
+ }
199
+ }
200
+ if (!drop) continue
201
+ try {
202
+ await rm(full, { force: true })
203
+ removed.push(name)
204
+ } catch (error) { /* 删除失败静默 */ }
205
+ }
206
+ }
207
+
208
+ /** 清扫工作区级缓存:逐工作区目录按版本/TTL 清文件,空目录删除。 */
209
+ async function sweepWorkspaceTier(dir: string, removed: string[], now: number): Promise<void> {
210
+ const ids = await readdir(dir).catch(() => [])
211
+ for (const id of ids) {
212
+ const wsDir = join(dir, id)
213
+ await sweepDirFiles(wsDir, removed, now, false)
214
+ const rest = await readdir(wsDir).catch(() => [])
215
+ if (!rest.length) await rm(wsDir, { recursive: true, force: true }).catch(() => {})
216
+ }
217
+ }
218
+
219
+ /** 总预算兜底:递归收集全部缓存文件,按 mtime 从旧到新删,直到总大小 ≤ 预算。 */
220
+ async function sweepByTotalCap(root: string, removed: string[], cap: number): Promise<void> {
221
+ const files: { path: string; mtime: number; size: number }[] = []
222
+ const walk = async (dir: string): Promise<void> => {
223
+ const names = await readdir(dir).catch(() => [])
224
+ for (const name of names) {
225
+ const full = join(dir, name)
226
+ try {
227
+ const info = await stat(full)
228
+ if (info.isDirectory()) await walk(full)
229
+ else files.push({ path: full, mtime: info.mtimeMs, size: info.size })
230
+ } catch (error) { /* 忽略不可读项 */ }
231
+ }
232
+ }
233
+ await walk(root)
234
+ let total = files.reduce((sum, f) => sum + f.size, 0)
235
+ files.sort((a, b) => a.mtime - b.mtime)
236
+ for (const f of files) {
237
+ if (total <= cap) break
238
+ try {
239
+ await rm(f.path, { force: true })
240
+ total -= f.size
241
+ removed.push(f.path.slice(root.length + 1))
242
+ } catch (error) { /* 删除失败静默 */ }
243
+ }
244
+ }
245
+
246
+ /**
247
+ * 清理插件缓存根(分级 + 总预算兜底):
248
+ * - 根级残留文件(旧扁平格式/未知)→ 删
249
+ * - workspace/ 工作区级 → 按「schema 版本 + 保留期」清文件,空目录删除
250
+ * - user/ 用户级 → 按「schema 版本 + 保留期」清文件
251
+ * - 总大小超预算 → 按最旧先删(最后防线,防任意增长撑爆磁盘)
252
+ * best-effort 静默。
253
+ * @author ddj 2026年09月01号
254
+ * @param home DSH home(测试可注入)
255
+ * @param cap 总预算字节数(缺省 CACHE_TOTAL_CAP;测试可注入小预算)
256
+ * @returns 删除的文件相对路径清单
257
+ */
258
+ export async function sweepTreeCache(home = dshHome(), cap = CACHE_TOTAL_CAP): Promise<string[]> {
259
+ const dir = pluginCacheRoot(home)
260
+ const names = await readdir(dir).catch(() => [])
261
+ if (!names.length) return []
262
+ const removed: string[] = []
263
+ const now = Date.now()
264
+ await Promise.all(names.map(async (name) => {
265
+ const full = join(dir, name)
266
+ try {
267
+ const info = await stat(full)
268
+ if (info.isDirectory()) {
269
+ if (name === 'workspace') await sweepWorkspaceTier(full, removed, now)
270
+ else if (name === 'user') await sweepDirFiles(full, removed, now, false)
271
+ else {
272
+ await rm(full, { recursive: true, force: true })
273
+ removed.push(name)
274
+ }
275
+ } else {
276
+ await rm(full, { force: true })
277
+ removed.push(name) // 根级残留(迁移前扁平格式/未知文件)
278
+ }
279
+ } catch (error) { /* stat 失败忽略 */ }
280
+ }))
281
+ await sweepByTotalCap(dir, removed, cap)
282
+ return removed
283
+ }
284
+ // --endregion
package/src/perf.ts ADDED
@@ -0,0 +1,340 @@
1
+ /**
2
+ * dsh-vscode-mode host — DSH 会话性能管理(会话卫生 + 体积 stat + 侧车摘要)。
3
+ * 职责:
4
+ * - 盘点 ~/.dsh/sessions 全部工作区会话(每会话仅 stat 日志文件,不解压,轻量);
5
+ * - 移出/恢复/清除:会话目录在 ~/.dsh/sessions 与 ~/.dsh/sessions-archive 间可逆搬迁;
6
+ * - 当前会话体积 stat(对话头部指示器用,纯 stat);
7
+ * - 侧车摘要:agent/脚本取关键字段,避免整份读 .dsh-edit-review.json 进对话。
8
+ * 纯函数可单测(路径编码/移出规划);fs 操作 best-effort 返回逐项结果不抛。
9
+ * 作者 ddj 2026-09-02
10
+ */
11
+ import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
12
+ import { dirname, join, resolve, sep } from 'node:path'
13
+ import { parseSidecar } from './store.js'
14
+ import { recSummary } from './model.js'
15
+ import { SIDECAR, SIDECAR_ARCHIVE, dshHome, sessionIdSegment, sessionWorkspaceKey, sessionsRoot } from './paths.js'
16
+ import type { PerfMoveFailure, PerfMoveItem, PerfSession, PerfTotals, PerfWorkspace, SidecarPerfSummary } from './shared/rpc.js'
17
+ import type { Ctx } from './store.js'
18
+
19
+ /** 会话日志文件名(zstd 优先,降级纯 jsonl)。 */
20
+ const LOG_NAMES = ['session.jsonl.zstd', 'session.jsonl']
21
+
22
+ /** 归档根(~/.dsh/sessions-archive,与 sessions 同级,可逆移出的落点)。 */
23
+ export function sessionsArchiveRoot(home = dshHome()): string {
24
+ return join(home, 'sessions-archive')
25
+ }
26
+
27
+ /** 会话盘点结果(工作区聚合 + 会话明细 + 汇总)。 */
28
+ export interface SessionInventory {
29
+ workspaces: PerfWorkspace[]
30
+ sessions: PerfSession[]
31
+ totals: PerfTotals
32
+ }
33
+
34
+ /** 移出规划条件:显式集合或规则(minBytes / olderThanDays)。 */
35
+ export interface MoveCriteria {
36
+ workspaceKey?: string
37
+ sessionIds?: string[]
38
+ minBytes?: number
39
+ olderThanDays?: number
40
+ }
41
+
42
+ /** 路径段校验:非空、非 . / ..、不含分隔符与 Windows 非法字符(防 join 逃逸)。 */
43
+ export function validDirName(name: string): boolean {
44
+ return typeof name === 'string' && name.length > 0 && name !== '.' && name !== '..' && !/[/\\]/.test(name) && !/[:*?"<>|]/.test(name)
45
+ }
46
+
47
+ /** target 必须落在 root 内(自身或其子路径),防路径穿越。 */
48
+ export function withinDir(target: string, root: string): boolean {
49
+ const rel = resolve(target)
50
+ const base = resolve(root)
51
+ return rel === base || rel.startsWith(base + sep)
52
+ }
53
+
54
+ /** stat 一个会话目录里的日志文件(首个存在的),缺失 → null。 */
55
+ export async function statSessionLog(sessionDir: string): Promise<{ size: number; mtimeMs: number } | null> {
56
+ for (const name of LOG_NAMES) {
57
+ try {
58
+ const info = await stat(join(sessionDir, name))
59
+ if (info.isFile()) return { size: info.size, mtimeMs: info.mtimeMs }
60
+ } catch (error) { /* 尝试下一个日志名 */ }
61
+ }
62
+ return null
63
+ }
64
+
65
+ /**
66
+ * 盘点 ~/.dsh/sessions:按工作区分组,每会话仅 stat 日志文件体积与 mtime。
67
+ * 不解压日志(V8 展开成本高),活跃标记由调用方传入后置。
68
+ * @author ddj 2026年09月02号
69
+ * @param home DSH home(测试可注入)
70
+ * @param archive 归档根(默认 sessions-archive;测试可注入)
71
+ * @returns 工作区聚合(按体积降序)+ 会话明细(按体积降序)+ 汇总
72
+ */
73
+ export async function scanSessionInventory(home = dshHome(), archive = sessionsArchiveRoot(home)): Promise<SessionInventory> {
74
+ void archive
75
+ const root = sessionsRoot(home)
76
+ const workspaces: PerfWorkspace[] = []
77
+ const sessions: PerfSession[] = []
78
+ let totalBytes = 0
79
+ let totalCount = 0
80
+ const wsDirs = await readdir(root, { withFileTypes: true }).catch(() => [])
81
+ for (const ws of wsDirs) {
82
+ if (!ws.isDirectory() || !validDirName(ws.name)) continue
83
+ const wsDir = join(root, ws.name)
84
+ const wsSessions: PerfSession[] = []
85
+ let wsBytes = 0
86
+ let minMtime = Number.POSITIVE_INFINITY
87
+ let maxMtime = 0
88
+ const sessionDirs = await readdir(wsDir, { withFileTypes: true }).catch(() => [])
89
+ for (const sd of sessionDirs) {
90
+ if (!sd.isDirectory()) continue
91
+ const info = await statSessionLog(join(wsDir, sd.name))
92
+ if (!info) continue
93
+ const row: PerfSession = { sessionId: sd.name, workspaceKey: ws.name, bytes: info.size, mtime: info.mtimeMs, active: false }
94
+ wsSessions.push(row)
95
+ wsBytes += info.size
96
+ totalBytes += info.size
97
+ totalCount += 1
98
+ if (info.mtimeMs < minMtime) minMtime = info.mtimeMs
99
+ if (info.mtimeMs > maxMtime) maxMtime = info.mtimeMs
100
+ }
101
+ wsSessions.sort((a, b) => b.bytes - a.bytes)
102
+ sessions.push(...wsSessions)
103
+ workspaces.push({
104
+ workspaceKey: ws.name,
105
+ sessionCount: wsSessions.length,
106
+ totalBytes: wsBytes,
107
+ minMtime: Number.isFinite(minMtime) ? minMtime : 0,
108
+ maxMtime,
109
+ })
110
+ }
111
+ workspaces.sort((a, b) => b.totalBytes - a.totalBytes)
112
+ return { workspaces, sessions, totals: { workspaces: workspaces.length, sessions: totalCount, totalBytes } }
113
+ }
114
+
115
+ /** 标记活跃会话(live id 与目录段名双向匹配,兼容编码差异)。 */
116
+ export function markActiveSessions(sessions: PerfSession[], activeIds: Iterable<string>): void {
117
+ const raw = new Set(activeIds)
118
+ const encoded = new Set<string>()
119
+ for (const id of raw) encoded.add(sessionIdSegment(id))
120
+ for (const s of sessions) {
121
+ if (raw.has(s.sessionId) || encoded.has(s.sessionId)) s.active = true
122
+ }
123
+ }
124
+
125
+ /**
126
+ * 移出规划(纯函数):从盘点中按「显式集合」或「规则(minBytes / olderThanDays)」
127
+ * 圈选可移出会话。活跃会话一律排除;无显式集合且无规则时不选(防误移)。
128
+ * @author ddj 2026年09月02号
129
+ * @param inventory 盘点结果
130
+ * @param criteria 圈选条件
131
+ * @returns 待移出清单与释放字节
132
+ */
133
+ export function planMoveOut(inventory: SessionInventory, criteria: MoveCriteria): { items: PerfMoveItem[]; reclaimedBytes: number } {
134
+ const items: PerfMoveItem[] = []
135
+ const explicit = Array.isArray(criteria.sessionIds) && criteria.sessionIds.length > 0
136
+ const cutoff = criteria.olderThanDays ? Date.now() - criteria.olderThanDays * 24 * 60 * 60 * 1000 : 0
137
+ for (const s of inventory.sessions) {
138
+ if (s.active) continue
139
+ if (criteria.workspaceKey && s.workspaceKey !== criteria.workspaceKey) continue
140
+ if (explicit) {
141
+ if (!criteria.sessionIds!.includes(s.sessionId)) continue
142
+ } else {
143
+ if (criteria.minBytes && s.bytes < criteria.minBytes) continue
144
+ if (criteria.olderThanDays && s.mtime > cutoff) continue
145
+ if (!criteria.minBytes && !criteria.olderThanDays) continue
146
+ }
147
+ items.push({ workspaceKey: s.workspaceKey, sessionId: s.sessionId, bytes: s.bytes })
148
+ }
149
+ return { items, reclaimedBytes: items.reduce((sum, i) => sum + i.bytes, 0) }
150
+ }
151
+
152
+ /** 移出执行结果。 */
153
+ export interface MoveOutResult {
154
+ moved: PerfMoveItem[]
155
+ failures: PerfMoveFailure[]
156
+ reclaimedBytes: number
157
+ }
158
+
159
+ /**
160
+ * 移出会话目录到归档区(同卷 rename,可逆)。活跃会话拒绝;路径越界拒绝;
161
+ * 逐项 best-effort,成功项写归档 manifest。默认不 dry-run(dryRun 时只校验不搬迁)。
162
+ * @author ddj 2026年09月02号
163
+ * @param home DSH home
164
+ * @param archive 归档根
165
+ * @param items 待移出清单(来自 planMoveOut 或用户确认)
166
+ * @param activeIds 活跃会话 id 集合(执行期再校验一次)
167
+ * @param dryRun 仅校验不搬迁
168
+ */
169
+ export async function moveOutSessions(home: string, archive: string, items: PerfMoveItem[], activeIds: Iterable<string>, dryRun = false): Promise<MoveOutResult> {
170
+ const root = sessionsRoot(home)
171
+ const rawActive = new Set(activeIds)
172
+ const encodedActive = new Set<string>()
173
+ for (const id of rawActive) encodedActive.add(sessionIdSegment(id))
174
+ const isActive = (id: string): boolean => rawActive.has(id) || encodedActive.has(id)
175
+ const moved: PerfMoveItem[] = []
176
+ const failures: PerfMoveFailure[] = []
177
+ for (const item of items) {
178
+ if (!validDirName(item.workspaceKey)) {
179
+ failures.push({ workspaceKey: item.workspaceKey, sessionId: item.sessionId, error: '工作区键不合法' })
180
+ continue
181
+ }
182
+ if (isActive(item.sessionId)) {
183
+ failures.push({ workspaceKey: item.workspaceKey, sessionId: item.sessionId, error: '活跃会话,拒绝移出' })
184
+ continue
185
+ }
186
+ const seg = sessionIdSegment(item.sessionId)
187
+ const src = join(root, item.workspaceKey, seg)
188
+ const dst = join(archive, item.workspaceKey, seg)
189
+ if (!withinDir(src, root) || !withinDir(dst, archive)) {
190
+ failures.push({ workspaceKey: item.workspaceKey, sessionId: item.sessionId, error: '路径越界,拒绝执行' })
191
+ continue
192
+ }
193
+ if (dryRun) {
194
+ try {
195
+ const info = await stat(src)
196
+ if (info.isDirectory()) moved.push({ ...item, bytes: (await statSessionLog(src))?.size ?? item.bytes })
197
+ else failures.push({ workspaceKey: item.workspaceKey, sessionId: item.sessionId, error: '源目录不存在' })
198
+ } catch (error) {
199
+ failures.push({ workspaceKey: item.workspaceKey, sessionId: item.sessionId, error: '源目录不存在' })
200
+ }
201
+ continue
202
+ }
203
+ try {
204
+ await mkdir(dirname(dst), { recursive: true })
205
+ await rename(src, dst)
206
+ const bytes = (await statSessionLog(dst))?.size ?? item.bytes
207
+ moved.push({ ...item, bytes })
208
+ } catch (error) {
209
+ failures.push({ workspaceKey: item.workspaceKey, sessionId: item.sessionId, error: String(error) })
210
+ }
211
+ }
212
+ if (moved.length && !dryRun) await appendMoveManifest(archive, moved)
213
+ return { moved, failures, reclaimedBytes: moved.reduce((sum, i) => sum + i.bytes, 0) }
214
+ }
215
+
216
+ /** 恢复单个会话:归档 → sessions。目标已存在则拒绝(不覆盖)。 */
217
+ export async function restoreSession(home: string, archive: string, workspaceKey: string, sessionId: string): Promise<{ ok: boolean; error?: string }> {
218
+ if (!validDirName(workspaceKey)) return { ok: false, error: '工作区键不合法' }
219
+ const seg = sessionIdSegment(sessionId)
220
+ const root = sessionsRoot(home)
221
+ const src = join(archive, workspaceKey, seg)
222
+ const dst = join(root, workspaceKey, seg)
223
+ if (!withinDir(src, archive) || !withinDir(dst, root)) return { ok: false, error: '路径越界,拒绝执行' }
224
+ try {
225
+ await stat(src)
226
+ } catch (error) {
227
+ return { ok: false, error: '归档中不存在该会话' }
228
+ }
229
+ try {
230
+ await stat(dst)
231
+ return { ok: false, error: '目标位置已存在同名会话' }
232
+ } catch (error) { /* 目标不存在,可恢复 */ }
233
+ try {
234
+ await mkdir(dirname(dst), { recursive: true })
235
+ await rename(src, dst)
236
+ return { ok: true }
237
+ } catch (error) {
238
+ return { ok: false, error: String(error) }
239
+ }
240
+ }
241
+
242
+ /** 清除归档区早于 N 天的会话(破坏性,仅限归档区)。 */
243
+ export async function purgeArchive(archive: string, olderThanDays: number): Promise<{ removed: number; reclaimedBytes: number }> {
244
+ const cutoff = Date.now() - olderThanDays * 24 * 60 * 60 * 1000
245
+ let removed = 0
246
+ let reclaimedBytes = 0
247
+ const wsDirs = await readdir(archive, { withFileTypes: true }).catch(() => [])
248
+ for (const ws of wsDirs) {
249
+ if (!ws.isDirectory()) continue
250
+ const wsDir = join(archive, ws.name)
251
+ const sessionDirs = await readdir(wsDir, { withFileTypes: true }).catch(() => [])
252
+ for (const sd of sessionDirs) {
253
+ if (!sd.isDirectory()) continue
254
+ const info = await statSessionLog(join(wsDir, sd.name))
255
+ if (!info || info.mtimeMs > cutoff) continue
256
+ try {
257
+ await rm(join(wsDir, sd.name), { recursive: true, force: true })
258
+ removed += 1
259
+ reclaimedBytes += info.size
260
+ } catch (error) { /* 删除失败跳过 */ }
261
+ }
262
+ }
263
+ return { removed, reclaimedBytes }
264
+ }
265
+
266
+ /** 当前会话持久化体积(对话头部指示器用):仅 stat,近零成本。 */
267
+ export async function sessionSizeOf(home: string, cwd: string, sessionId: string): Promise<{ bytes: number; exists: boolean }> {
268
+ const dir = join(sessionsRoot(home), sessionWorkspaceKey(cwd), sessionIdSegment(sessionId))
269
+ for (const name of LOG_NAMES) {
270
+ try {
271
+ const info = await stat(join(dir, name))
272
+ if (info.isFile()) return { bytes: info.size, exists: true }
273
+ } catch (error) { /* 尝试下一个日志名 */ }
274
+ }
275
+ return { bytes: 0, exists: false }
276
+ }
277
+
278
+ /**
279
+ * 侧车摘要:返回活跃记录数、每文件 pending 计数、归档体积。
280
+ * agent/脚本取关键字段即可,不必整份读 .dsh-edit-review.json(避免大对象进对话)。
281
+ * @author ddj 2026年09月02号
282
+ * @param ctx DSH host 上下文(fs 服务)
283
+ * @param cwd 工作区路径(null 时返回空摘要)
284
+ */
285
+ export async function sidecarSummaryOf(ctx: Ctx, cwd: string | null): Promise<SidecarPerfSummary> {
286
+ const fs = ctx.get('fs')
287
+ const summary: SidecarPerfSummary = { active: 0, pendingByFile: [], archiveBytes: 0 }
288
+ if (!fs || !cwd) return summary
289
+ try {
290
+ const text = await fs.readText(await fs.resolve(SIDECAR, { cwd }))
291
+ const data = parseSidecar(text)
292
+ const recs = data?.workspaces?.[cwd]?.records
293
+ if (recs) {
294
+ const byFile = new Map<string, number>()
295
+ let active = 0
296
+ for (const rec of Object.values(recs)) {
297
+ if (rec.archived) continue
298
+ active += 1
299
+ const sm = recSummary(rec)
300
+ if (sm.pending > 0 && rec.path) byFile.set(rec.path, (byFile.get(rec.path) ?? 0) + sm.pending)
301
+ }
302
+ summary.active = active
303
+ summary.pendingByFile = [...byFile.entries()].map(([path, pending]) => ({ path, pending })).sort((a, b) => b.pending - a.pending)
304
+ }
305
+ try {
306
+ const info = await fs.stat(await fs.resolve(SIDECAR_ARCHIVE, { cwd }))
307
+ summary.archiveBytes = info?.size ?? 0
308
+ } catch (error) { /* 归档缺失忽略 */ }
309
+ } catch (error) { /* sidecar 不可读返回空摘要 */ }
310
+ return summary
311
+ }
312
+
313
+ /** 归档 manifest 条目。 */
314
+ interface MoveManifestEntry { at: string; workspaceKey: string; sessionId: string }
315
+
316
+ /** 归档 manifest(可逆移出的索引,供恢复/审计)。 */
317
+ interface MoveManifest { version: 1; items: MoveManifestEntry[] }
318
+
319
+ /** 解析归档 manifest(损坏/缺失 → 空)。 */
320
+ function parseMoveManifest(text: string | null): MoveManifest {
321
+ if (!text) return { version: 1, items: [] }
322
+ try {
323
+ const data = JSON.parse(text) as MoveManifest
324
+ return { version: 1, items: Array.isArray(data.items) ? data.items : [] }
325
+ } catch (error) {
326
+ return { version: 1, items: [] }
327
+ }
328
+ }
329
+
330
+ /** 追加移出记录到归档 manifest(best-effort,失败不影响主流程)。 */
331
+ async function appendMoveManifest(archive: string, items: PerfMoveItem[]): Promise<void> {
332
+ try {
333
+ const file = join(archive, '.manifest.json')
334
+ const prev = parseMoveManifest(await readFile(file, 'utf8').catch(() => null))
335
+ const at = new Date().toISOString()
336
+ for (const item of items) prev.items.push({ at, workspaceKey: item.workspaceKey, sessionId: item.sessionId })
337
+ await mkdir(archive, { recursive: true })
338
+ await writeFile(file, JSON.stringify(prev, null, 2), 'utf8')
339
+ } catch (error) { /* manifest 写失败静默 */ }
340
+ }