dsh-manage-session 0.3.3 → 0.5.1

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.
@@ -1,191 +1,194 @@
1
- /**
2
- * 会话历史文件(归档)域的服务端处理。
3
- *
4
- * 与 dsh-session-search-toggle 的差异(**刻意且必须记入交付说明**):
5
- * toggle 的 `list-archived` 从它自己的独立索引里列出归档行(`engine.listArchived()`),
6
- * 而索引已随搜索索引包离开本包。因此本包改为从**官方归档集合**列出来源真值
7
- * (`readArchiveSet`:存储文件优先、registry 兜底),标题等元数据为尽力而为:
8
- * 依次尝试 `sessionQuery.readTitleSnapshots` → 投影缓存记录里的 title。
9
- * 列表与 prune **同源**(都认存储文件),否则清理成功后面板不会刷新;
10
- * registry 与文件的差集作为 `pendingRestart` 返回,面板据此提示重启。
11
- * 清理(prune)路径与迁移前**逐字等价**(备份 + 原子替换 + 需重启提示),
12
- * 另加一道写后校验:读回文件确认目标 id 确实消失。
13
- */
14
- import { existsSync, readFileSync } from 'node:fs'
15
- import { homedir } from 'node:os'
16
- import { join } from 'node:path'
17
- import { pruneArchiveFile, readArchiveSet, type StewardRegistryFace } from './archive-source.ts'
18
- import { locateSessionUsage } from './purge.ts'
19
-
20
- /** 一行历史文件条目(尽力而为的元数据 + 磁盘占用)。 */
21
- export interface StewardHistoryRow {
22
- sessionId: string
23
- title: string
24
- cwd: string
25
- updatedAt: number
26
- /** 转录目录占用字节数(未知/未解析时为 0)。 */
27
- bytes: number
28
- /** 投影缓存占用字节数。 */
29
- cacheBytes: number
30
- }
31
-
32
- /** 列表结果。 */
33
- export interface StewardHistoryListResult {
34
- ok: boolean
35
- items?: StewardHistoryRow[]
36
- /** 归档集合来源;`none` 表示两处都没读到。 */
37
- source?: 'registry' | 'storage-file' | 'none'
38
- /** 元数据降级原因(标题/cwd 缺失时给出)。 */
39
- degraded?: string
40
- /**
41
- * 已从存储文件移除、但宿主内存 registry 里仍生效的 id 数。
42
- *
43
- * 大于 0 时列表展示的是**存储文件真值**(已不含这些 id),但它们在本进程内
44
- * 仍然隐藏着对应会话 —— 面板据此提示「需重启 DSH 才彻底消失」。
45
- */
46
- pendingRestart?: number
47
- error?: string
48
- }
49
-
50
- /** 标题快照读取面(结构化镜像,零 value import)。 */
51
- export interface StewardTitleQueryFace {
52
- readTitleSnapshots?(ids: readonly string[]): Promise<readonly {
53
- status: 'fulfilled' | 'rejected'
54
- value?: { title?: { title: string } }
55
- }[]>
56
- }
57
-
58
- /** 投影缓存记录里可能存在的标题(同目录第二来源)。 */
59
- function titleFromProjectionCache(sessionId: string): string {
60
- const path = join(homedir(), '.dsh', 'storages', 'session_projcache', 'sessions', `${sessionId}.json`)
61
- if (!existsSync(path)) return ''
62
- try {
63
- const parsed = JSON.parse(readFileSync(path, 'utf8')) as {
64
- record?: { rows?: { title?: { val?: unknown } } }
65
- }
66
- const value = parsed.record?.rows?.title?.val
67
- return typeof value === 'string' ? value : ''
68
- } catch {
69
- return ''
70
- }
71
- }
72
-
73
- /**
74
- * `session-history-list`:列出官方归档集合(尽力附带标题等元数据)。
75
- * @param getRegistry - 惰性 workspaceRegistry 面。
76
- * @param query - 可选标题快照面。
77
- * @param searchPaths - 可选存储文件候选路径(DSH_HOME 非默认值/测试注入)。
78
- * @returns 归档行清单。
79
- */
80
- export async function listHistory(
81
- getRegistry: () => StewardRegistryFace | undefined,
82
- query?: StewardTitleQueryFace,
83
- searchPaths?: readonly string[],
84
- dshHome?: string,
85
- ): Promise<StewardHistoryListResult> {
86
- const read = readArchiveSet(getRegistry(), searchPaths)
87
- if (read.source === 'none') {
88
- return { ok: false, error: 'workspace 存储文件与 workspaceRegistry 均不可用,无法读取归档集合' }
89
- }
90
- const ids = [...read.ids]
91
- const titles = new Map<string, string>()
92
- let degraded: string | undefined
93
- if (query?.readTitleSnapshots !== undefined && ids.length > 0) {
94
- try {
95
- const observations = await query.readTitleSnapshots(ids)
96
- ids.forEach((id, index) => {
97
- const title = observations[index]?.value?.title?.title
98
- if (typeof title === 'string' && title !== '') titles.set(id, title)
99
- })
100
- } catch {
101
- degraded = 'sessionQuery 标题快照不可用,标题回退到投影缓存/空'
102
- }
103
- } else if (ids.length > 0) {
104
- degraded = 'sessionQuery 不可用,标题回退到投影缓存/空'
105
- }
106
- const items: StewardHistoryRow[] = ids.map((sessionId) => {
107
- const title = titles.get(sessionId) ?? titleFromProjectionCache(sessionId)
108
- return { sessionId, title, cwd: '', updatedAt: 0, bytes: 0, cacheBytes: 0 }
109
- })
110
- // 按行的磁盘占用:**以单元为单位**算,不给一个总量糊弄(实测单条可从 0 到 23 MB)。
111
- // 未提供 dshHome 时保持 0 —— 调方拿不到主目录就不猜路径。
112
- if (dshHome !== undefined && dshHome !== '' && items.length > 0) {
113
- try {
114
- const usage = locateSessionUsage(ids, dshHome)
115
- for (const item of items) {
116
- const entry = usage.get(item.sessionId)
117
- if (entry === undefined) continue
118
- item.bytes = entry.bytes
119
- item.cacheBytes = entry.cacheBytes
120
- }
121
- } catch { /* 体积是尽力而为,读不到不影响列表本身 */ }
122
- }
123
- // 悬挂项:宿主内存里还留着、文件里已经没有了 —— 这些是「清理已落盘但尚未生效」的 id。
124
- const fileIds = new Set(ids)
125
- const pendingRestart = (read.registryIds ?? []).filter((id) => !fileIds.has(id)).length
126
- return {
127
- ok: true,
128
- items,
129
- source: read.source,
130
- ...(degraded === undefined ? {} : { degraded }),
131
- ...(pendingRestart === 0 ? {} : { pendingRestart }),
132
- }
133
- }
134
-
135
- /** 按 DSH_HOME 解析存储文件候选(与 archive-source 的默认候选保持同一形状)。 */
136
- export function storagePathsFor(dshHome: string): string[] {
137
- return [join(dshHome, 'storages', 'workspace.json')]
138
- }
139
-
140
- /** 清理结果。 */
141
- export interface StewardHistoryPruneResult {
142
- ok: boolean
143
- removed?: number
144
- remaining?: number
145
- requiresRestart?: boolean
146
- error?: string
147
- }
148
-
149
- /** 单次清理的最大 id 数(与迁移前一致)。 */
150
- export const MAX_PRUNE_IDS = 5000
151
-
152
- /**
153
- * `session-history-prune`:从官方归档数组中批量移除会话 id。
154
- * 行为与迁移前等价:校验入参 → 备份并原子替换存储文件 → 返回 removed/remaining,
155
- * 并明确要求重启 DSH(宿主内存中的集合只在启动时重载)。
156
- * @param payload - `{ sessionIds: string[] }`。
157
- * @param log - 可选日志出口。
158
- * @param searchPaths - 可选候选路径覆盖(测试注入用)。
159
- */
160
- export function pruneHistory(
161
- payload: unknown,
162
- log?: (msg: string) => void,
163
- searchPaths?: readonly string[],
164
- ): StewardHistoryPruneResult {
165
- const record = payload as { sessionIds?: unknown } | null
166
- if (!Array.isArray(record?.sessionIds) || record.sessionIds.length === 0) {
167
- return { ok: false, error: '缺少 sessionIds 数组' }
168
- }
169
- const ids = [...new Set(record.sessionIds.filter((id): id is string => typeof id === 'string' && id !== ''))]
170
- if (ids.length === 0) return { ok: false, error: 'sessionIds 无有效值' }
171
- if (ids.length > MAX_PRUNE_IDS) return { ok: false, error: `单次最多清理 ${MAX_PRUNE_IDS} 个` }
172
- let result: { removed: number; remaining: number; file?: string }
173
- try {
174
- result = pruneArchiveFile(ids, log, searchPaths)
175
- } catch (err) {
176
- return { ok: false, error: String(err instanceof Error ? err.message : err) }
177
- }
178
- // 写后校验:读回文件确认目标 id 确实不在了。
179
- // 没有这一步,「面板说清理成功、文件其实没变」只能靠用户肉眼发现。
180
- const after = readArchiveSet(undefined, searchPaths)
181
- if (after.source !== 'storage-file') {
182
- return { ok: false, error: '写入后无法读回存储文件,无法确认清理结果' }
183
- }
184
- const stillThere = new Set(after.ids)
185
- const leftover = ids.filter((id) => stillThere.has(id))
186
- if (leftover.length > 0) {
187
- return { ok: false, error: `存储文件在写入后仍包含 ${leftover.length} 个目标 id,清理未生效` }
188
- }
189
- log?.(`archive prune requested ${ids.length}, removed ${result.removed}, remaining ${result.remaining}`)
190
- return { ok: true, removed: result.removed, remaining: result.remaining, requiresRestart: true }
191
- }
1
+ /**
2
+ * 会话历史文件(归档)域的服务端处理。
3
+ *
4
+ * 与 dsh-session-search-toggle 的差异(**刻意且必须记入交付说明**):
5
+ * toggle 的 `list-archived` 从它自己的独立索引里列出归档行(`engine.listArchived()`),
6
+ * 而索引已随搜索索引包离开本包。因此本包改为从**官方归档集合**列出来源真值
7
+ * (`readArchiveSet`:存储文件优先、registry 兜底),标题等元数据为尽力而为:
8
+ * 依次尝试 `sessionQuery.readTitleSnapshots` → 投影缓存记录里的 title。
9
+ * 列表与 prune **同源**(都认存储文件),否则清理成功后面板不会刷新;
10
+ * registry 与文件的差集作为 `pendingRestart` 返回,面板据此提示重启。
11
+ * 清理(prune)路径与迁移前**逐字等价**(备份 + 原子替换 + 需重启提示),
12
+ * 另加一道写后校验:读回文件确认目标 id 确实消失。
13
+ */
14
+ import { existsSync, readFileSync } from 'node:fs'
15
+ import { homedir } from 'node:os'
16
+ import { join } from 'node:path'
17
+ import { pruneArchiveFile, readArchiveSet, type StewardRegistryFace } from './archive-source.ts'
18
+ import { locateSessionUsage } from './purge.ts'
19
+
20
+ /** 一行历史文件条目(尽力而为的元数据 + 磁盘占用)。 */
21
+ export interface StewardHistoryRow {
22
+ sessionId: string
23
+ title: string
24
+ cwd: string
25
+ updatedAt: number
26
+ /** 转录目录占用字节数(未知/未解析时为 0)。 */
27
+ bytes: number
28
+ /** 投影缓存占用字节数。 */
29
+ cacheBytes: number
30
+ /** 署名转换备份(`*.pre-sourcemigrate-<ts>`)占用字节数——`bytes` 的子集,单列防误读为日志体积。 */
31
+ backupBytes: number
32
+ }
33
+
34
+ /** 列表结果。 */
35
+ export interface StewardHistoryListResult {
36
+ ok: boolean
37
+ items?: StewardHistoryRow[]
38
+ /** 归档集合来源;`none` 表示两处都没读到。 */
39
+ source?: 'registry' | 'storage-file' | 'none'
40
+ /** 元数据降级原因(标题/cwd 缺失时给出)。 */
41
+ degraded?: string
42
+ /**
43
+ * 已从存储文件移除、但宿主内存 registry 里仍生效的 id 数。
44
+ *
45
+ * 大于 0 时列表展示的是**存储文件真值**(已不含这些 id),但它们在本进程内
46
+ * 仍然隐藏着对应会话 —— 面板据此提示「需重启 DSH 才彻底消失」。
47
+ */
48
+ pendingRestart?: number
49
+ error?: string
50
+ }
51
+
52
+ /** 标题快照读取面(结构化镜像,零 value import)。 */
53
+ export interface StewardTitleQueryFace {
54
+ readTitleSnapshots?(ids: readonly string[]): Promise<readonly {
55
+ status: 'fulfilled' | 'rejected'
56
+ value?: { title?: { title: string } }
57
+ }[]>
58
+ }
59
+
60
+ /** 投影缓存记录里可能存在的标题(同目录第二来源)。 */
61
+ function titleFromProjectionCache(sessionId: string): string {
62
+ const path = join(homedir(), '.dsh', 'storages', 'session_projcache', 'sessions', `${sessionId}.json`)
63
+ if (!existsSync(path)) return ''
64
+ try {
65
+ const parsed = JSON.parse(readFileSync(path, 'utf8')) as {
66
+ record?: { rows?: { title?: { val?: unknown } } }
67
+ }
68
+ const value = parsed.record?.rows?.title?.val
69
+ return typeof value === 'string' ? value : ''
70
+ } catch {
71
+ return ''
72
+ }
73
+ }
74
+
75
+ /**
76
+ * `session-history-list`:列出官方归档集合(尽力附带标题等元数据)。
77
+ * @param getRegistry - 惰性 workspaceRegistry 面。
78
+ * @param query - 可选标题快照面。
79
+ * @param searchPaths - 可选存储文件候选路径(DSH_HOME 非默认值/测试注入)。
80
+ * @returns 归档行清单。
81
+ */
82
+ export async function listHistory(
83
+ getRegistry: () => StewardRegistryFace | undefined,
84
+ query?: StewardTitleQueryFace,
85
+ searchPaths?: readonly string[],
86
+ dshHome?: string,
87
+ ): Promise<StewardHistoryListResult> {
88
+ const read = readArchiveSet(getRegistry(), searchPaths)
89
+ if (read.source === 'none') {
90
+ return { ok: false, error: 'workspace 存储文件与 workspaceRegistry 均不可用,无法读取归档集合' }
91
+ }
92
+ const ids = [...read.ids]
93
+ const titles = new Map<string, string>()
94
+ let degraded: string | undefined
95
+ if (query?.readTitleSnapshots !== undefined && ids.length > 0) {
96
+ try {
97
+ const observations = await query.readTitleSnapshots(ids)
98
+ ids.forEach((id, index) => {
99
+ const title = observations[index]?.value?.title?.title
100
+ if (typeof title === 'string' && title !== '') titles.set(id, title)
101
+ })
102
+ } catch {
103
+ degraded = 'sessionQuery 标题快照不可用,标题回退到投影缓存/空'
104
+ }
105
+ } else if (ids.length > 0) {
106
+ degraded = 'sessionQuery 不可用,标题回退到投影缓存/空'
107
+ }
108
+ const items: StewardHistoryRow[] = ids.map((sessionId) => {
109
+ const title = titles.get(sessionId) ?? titleFromProjectionCache(sessionId)
110
+ return { sessionId, title, cwd: '', updatedAt: 0, bytes: 0, cacheBytes: 0, backupBytes: 0 }
111
+ })
112
+ // 按行的磁盘占用:**以单元为单位**算,不给一个总量糊弄(实测单条可从 0 到 23 MB)。
113
+ // 未提供 dshHome 时保持 0 —— 调方拿不到主目录就不猜路径。
114
+ if (dshHome !== undefined && dshHome !== '' && items.length > 0) {
115
+ try {
116
+ const usage = locateSessionUsage(ids, dshHome)
117
+ for (const item of items) {
118
+ const entry = usage.get(item.sessionId)
119
+ if (entry === undefined) continue
120
+ item.bytes = entry.bytes
121
+ item.cacheBytes = entry.cacheBytes
122
+ item.backupBytes = entry.backupBytes
123
+ }
124
+ } catch { /* 体积是尽力而为,读不到不影响列表本身 */ }
125
+ }
126
+ // 悬挂项:宿主内存里还留着、文件里已经没有了 —— 这些是「清理已落盘但尚未生效」的 id。
127
+ const fileIds = new Set(ids)
128
+ const pendingRestart = (read.registryIds ?? []).filter((id) => !fileIds.has(id)).length
129
+ return {
130
+ ok: true,
131
+ items,
132
+ source: read.source,
133
+ ...(degraded === undefined ? {} : { degraded }),
134
+ ...(pendingRestart === 0 ? {} : { pendingRestart }),
135
+ }
136
+ }
137
+
138
+ /** 按 DSH_HOME 解析存储文件候选(与 archive-source 的默认候选保持同一形状)。 */
139
+ export function storagePathsFor(dshHome: string): string[] {
140
+ return [join(dshHome, 'storages', 'workspace.json')]
141
+ }
142
+
143
+ /** 清理结果。 */
144
+ export interface StewardHistoryPruneResult {
145
+ ok: boolean
146
+ removed?: number
147
+ remaining?: number
148
+ requiresRestart?: boolean
149
+ error?: string
150
+ }
151
+
152
+ /** 单次清理的最大 id 数(与迁移前一致)。 */
153
+ export const MAX_PRUNE_IDS = 5000
154
+
155
+ /**
156
+ * `session-history-prune`:从官方归档数组中批量移除会话 id。
157
+ * 行为与迁移前等价:校验入参 → 备份并原子替换存储文件 → 返回 removed/remaining,
158
+ * 并明确要求重启 DSH(宿主内存中的集合只在启动时重载)。
159
+ * @param payload - `{ sessionIds: string[] }`。
160
+ * @param log - 可选日志出口。
161
+ * @param searchPaths - 可选候选路径覆盖(测试注入用)。
162
+ */
163
+ export function pruneHistory(
164
+ payload: unknown,
165
+ log?: (msg: string) => void,
166
+ searchPaths?: readonly string[],
167
+ ): StewardHistoryPruneResult {
168
+ const record = payload as { sessionIds?: unknown } | null
169
+ if (!Array.isArray(record?.sessionIds) || record.sessionIds.length === 0) {
170
+ return { ok: false, error: '缺少 sessionIds 数组' }
171
+ }
172
+ const ids = [...new Set(record.sessionIds.filter((id): id is string => typeof id === 'string' && id !== ''))]
173
+ if (ids.length === 0) return { ok: false, error: 'sessionIds 无有效值' }
174
+ if (ids.length > MAX_PRUNE_IDS) return { ok: false, error: `单次最多清理 ${MAX_PRUNE_IDS} 个` }
175
+ let result: { removed: number; remaining: number; file?: string }
176
+ try {
177
+ result = pruneArchiveFile(ids, log, searchPaths)
178
+ } catch (err) {
179
+ return { ok: false, error: String(err instanceof Error ? err.message : err) }
180
+ }
181
+ // 写后校验:读回文件确认目标 id 确实不在了。
182
+ // 没有这一步,「面板说清理成功、文件其实没变」只能靠用户肉眼发现。
183
+ const after = readArchiveSet(undefined, searchPaths)
184
+ if (after.source !== 'storage-file') {
185
+ return { ok: false, error: '写入后无法读回存储文件,无法确认清理结果' }
186
+ }
187
+ const stillThere = new Set(after.ids)
188
+ const leftover = ids.filter((id) => stillThere.has(id))
189
+ if (leftover.length > 0) {
190
+ return { ok: false, error: `存储文件在写入后仍包含 ${leftover.length} 个目标 id,清理未生效` }
191
+ }
192
+ log?.(`archive prune requested ${ids.length}, removed ${result.removed}, remaining ${result.remaining}`)
193
+ return { ok: true, removed: result.removed, remaining: result.remaining, requiresRestart: true }
194
+ }