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,249 +1,249 @@
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 { archiveArchiveFile, 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
- }
195
-
196
- /** 归档结果。 */
197
- export interface StewardHistoryArchiveResult {
198
- ok: boolean
199
- /** 本次新加入归档集合的数量(已在集合中的 id 不重复计)。 */
200
- added?: number
201
- /** 操作后归档集合总数。 */
202
- total?: number
203
- requiresRestart?: boolean
204
- error?: string
205
- }
206
-
207
- /** 单次归档的最大 id 数(与 prune/purge 一致)。 */
208
- export const MAX_ARCHIVE_IDS = 5000
209
-
210
- /**
211
- * `session-history-archive`:把会话批量加入官方归档集合(prune 的逆操作)。
212
- * 与 prune 同一条纪律:校验入参 → 备份并原子替换存储文件 → 读回校验 →
213
- * 要求重启(宿主内存集合只在启动时重载)。会话文件不动、工作区成员表不动
214
- * (归档只改可见性,与宿主 archiveSession 的文件语义一致)。
215
- * @param payload - `{ sessionIds: string[] }`。
216
- * @param log - 可选日志出口。
217
- * @param searchPaths - 可选候选路径覆盖(测试注入用)。
218
- */
219
- export function archiveHistory(
220
- payload: unknown,
221
- log?: (msg: string) => void,
222
- searchPaths?: readonly string[],
223
- ): StewardHistoryArchiveResult {
224
- const record = payload as { sessionIds?: unknown } | null
225
- if (!Array.isArray(record?.sessionIds) || record.sessionIds.length === 0) {
226
- return { ok: false, error: '缺少 sessionIds 数组' }
227
- }
228
- const ids = [...new Set(record.sessionIds.filter((id): id is string => typeof id === 'string' && id !== ''))]
229
- if (ids.length === 0) return { ok: false, error: 'sessionIds 无有效值' }
230
- if (ids.length > MAX_ARCHIVE_IDS) return { ok: false, error: `单次最多归档 ${MAX_ARCHIVE_IDS} 个` }
231
- let result: { added: number; total: number; file?: string }
232
- try {
233
- result = archiveArchiveFile(ids, log, searchPaths)
234
- } catch (err) {
235
- return { ok: false, error: String(err instanceof Error ? err.message : err) }
236
- }
237
- // 写后校验:读回文件确认目标 id 确实进了归档集合。
238
- const after = readArchiveSet(undefined, searchPaths)
239
- if (after.source !== 'storage-file') {
240
- return { ok: false, error: '写入后无法读回存储文件,无法确认归档结果' }
241
- }
242
- const now = new Set(after.ids)
243
- const missing = ids.filter((id) => !now.has(id))
244
- if (missing.length > 0) {
245
- return { ok: false, error: `存储文件在写入后仍缺少 ${missing.length} 个目标 id,归档未生效` }
246
- }
247
- log?.(`archive requested ${ids.length}, added ${result.added}, total ${result.total}`)
248
- return { ok: true, added: result.added, total: result.total, requiresRestart: true }
249
- }
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 { archiveArchiveFile, 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
+ }
195
+
196
+ /** 归档结果。 */
197
+ export interface StewardHistoryArchiveResult {
198
+ ok: boolean
199
+ /** 本次新加入归档集合的数量(已在集合中的 id 不重复计)。 */
200
+ added?: number
201
+ /** 操作后归档集合总数。 */
202
+ total?: number
203
+ requiresRestart?: boolean
204
+ error?: string
205
+ }
206
+
207
+ /** 单次归档的最大 id 数(与 prune/purge 一致)。 */
208
+ export const MAX_ARCHIVE_IDS = 5000
209
+
210
+ /**
211
+ * `session-history-archive`:把会话批量加入官方归档集合(prune 的逆操作)。
212
+ * 与 prune 同一条纪律:校验入参 → 备份并原子替换存储文件 → 读回校验 →
213
+ * 要求重启(宿主内存集合只在启动时重载)。会话文件不动、工作区成员表不动
214
+ * (归档只改可见性,与宿主 archiveSession 的文件语义一致)。
215
+ * @param payload - `{ sessionIds: string[] }`。
216
+ * @param log - 可选日志出口。
217
+ * @param searchPaths - 可选候选路径覆盖(测试注入用)。
218
+ */
219
+ export function archiveHistory(
220
+ payload: unknown,
221
+ log?: (msg: string) => void,
222
+ searchPaths?: readonly string[],
223
+ ): StewardHistoryArchiveResult {
224
+ const record = payload as { sessionIds?: unknown } | null
225
+ if (!Array.isArray(record?.sessionIds) || record.sessionIds.length === 0) {
226
+ return { ok: false, error: '缺少 sessionIds 数组' }
227
+ }
228
+ const ids = [...new Set(record.sessionIds.filter((id): id is string => typeof id === 'string' && id !== ''))]
229
+ if (ids.length === 0) return { ok: false, error: 'sessionIds 无有效值' }
230
+ if (ids.length > MAX_ARCHIVE_IDS) return { ok: false, error: `单次最多归档 ${MAX_ARCHIVE_IDS} 个` }
231
+ let result: { added: number; total: number; file?: string }
232
+ try {
233
+ result = archiveArchiveFile(ids, log, searchPaths)
234
+ } catch (err) {
235
+ return { ok: false, error: String(err instanceof Error ? err.message : err) }
236
+ }
237
+ // 写后校验:读回文件确认目标 id 确实进了归档集合。
238
+ const after = readArchiveSet(undefined, searchPaths)
239
+ if (after.source !== 'storage-file') {
240
+ return { ok: false, error: '写入后无法读回存储文件,无法确认归档结果' }
241
+ }
242
+ const now = new Set(after.ids)
243
+ const missing = ids.filter((id) => !now.has(id))
244
+ if (missing.length > 0) {
245
+ return { ok: false, error: `存储文件在写入后仍缺少 ${missing.length} 个目标 id,归档未生效` }
246
+ }
247
+ log?.(`archive requested ${ids.length}, added ${result.added}, total ${result.total}`)
248
+ return { ok: true, added: result.added, total: result.total, requiresRestart: true }
249
+ }
@@ -1,71 +1,71 @@
1
- /**
2
- * 收藏域(R6)—— 插件自有的 JSON 文件,与索引文件/官方存储彻底分离:
3
- * 索引会因 schema 重置/影子切换整体更换,收藏不能放里面;官方存储中枢
4
- * 只读不写(唯一写方纪律不破)。文件形如
5
- * `<dshHome>/storages/manage-session-favorites.json`:
6
- * `{ unit: { name, version }, favoriteSessionIds: string[] }`
7
- * 原子写(tmp+rename),丢失可重建(收藏只是标记,不是数据)。
8
- */
9
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
10
- import { dirname, join } from 'node:path'
11
-
12
- /** 收藏文件候选路径(dshHome 注入;测试可覆盖)。 */
13
- export function favoritesFileCandidates(dshHome: string): string[] {
14
- return [join(dshHome, 'storages', 'manage-session-favorites.json')]
15
- }
16
-
17
- /** 读取收藏集合;文件缺失/畸形一律空数组(收藏只是标记,丢失不致命)。 */
18
- export function listFavorites(searchPaths?: readonly string[]): string[] {
19
- for (const path of searchPaths ?? []) {
20
- if (!existsSync(path)) continue
21
- try {
22
- const parsed = JSON.parse(readFileSync(path, 'utf8')) as { favoriteSessionIds?: unknown }
23
- if (!Array.isArray(parsed.favoriteSessionIds)) return []
24
- return parsed.favoriteSessionIds.filter((id): id is string => typeof id === 'string')
25
- } catch {
26
- return []
27
- }
28
- }
29
- return []
30
- }
31
-
32
- /** 收藏写入结果。 */
33
- export interface FavoritesSetResult {
34
- ok: boolean
35
- /** 操作后收藏总数。 */
36
- total?: number
37
- error?: string
38
- }
39
-
40
- /** 设置单条收藏状态(原子写)。 */
41
- export function setFavoriteState(
42
- sessionId: string,
43
- favorite: boolean,
44
- searchPaths?: readonly string[],
45
- ): FavoritesSetResult {
46
- const path = searchPaths?.[0]
47
- if (path === undefined || path === '') return { ok: false, error: '缺少收藏文件路径' }
48
- let ids: string[] = []
49
- if (existsSync(path)) {
50
- try {
51
- const parsed = JSON.parse(readFileSync(path, 'utf8')) as { favoriteSessionIds?: unknown }
52
- if (Array.isArray(parsed.favoriteSessionIds)) {
53
- ids = parsed.favoriteSessionIds.filter((id): id is string => typeof id === 'string')
54
- }
55
- } catch { /* 畸形文件按空集合重建 */ }
56
- }
57
- const without = ids.filter(id => id !== sessionId)
58
- const next = favorite ? [...without, sessionId] : without
59
- try {
60
- mkdirSync(dirname(path), { recursive: true })
61
- const tmp = `${path}.fav-tmp`
62
- writeFileSync(tmp, `${JSON.stringify({
63
- unit: { name: 'manage-session-favorites', version: 1 },
64
- favoriteSessionIds: next,
65
- }, null, 2)}\n`, 'utf8')
66
- renameSync(tmp, path)
67
- return { ok: true, total: next.length }
68
- } catch (err) {
69
- return { ok: false, error: String(err instanceof Error ? err.message : err) }
70
- }
71
- }
1
+ /**
2
+ * 收藏域(R6)—— 插件自有的 JSON 文件,与索引文件/官方存储彻底分离:
3
+ * 索引会因 schema 重置/影子切换整体更换,收藏不能放里面;官方存储中枢
4
+ * 只读不写(唯一写方纪律不破)。文件形如
5
+ * `<dshHome>/storages/manage-session-favorites.json`:
6
+ * `{ unit: { name, version }, favoriteSessionIds: string[] }`
7
+ * 原子写(tmp+rename),丢失可重建(收藏只是标记,不是数据)。
8
+ */
9
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
10
+ import { dirname, join } from 'node:path'
11
+
12
+ /** 收藏文件候选路径(dshHome 注入;测试可覆盖)。 */
13
+ export function favoritesFileCandidates(dshHome: string): string[] {
14
+ return [join(dshHome, 'storages', 'manage-session-favorites.json')]
15
+ }
16
+
17
+ /** 读取收藏集合;文件缺失/畸形一律空数组(收藏只是标记,丢失不致命)。 */
18
+ export function listFavorites(searchPaths?: readonly string[]): string[] {
19
+ for (const path of searchPaths ?? []) {
20
+ if (!existsSync(path)) continue
21
+ try {
22
+ const parsed = JSON.parse(readFileSync(path, 'utf8')) as { favoriteSessionIds?: unknown }
23
+ if (!Array.isArray(parsed.favoriteSessionIds)) return []
24
+ return parsed.favoriteSessionIds.filter((id): id is string => typeof id === 'string')
25
+ } catch {
26
+ return []
27
+ }
28
+ }
29
+ return []
30
+ }
31
+
32
+ /** 收藏写入结果。 */
33
+ export interface FavoritesSetResult {
34
+ ok: boolean
35
+ /** 操作后收藏总数。 */
36
+ total?: number
37
+ error?: string
38
+ }
39
+
40
+ /** 设置单条收藏状态(原子写)。 */
41
+ export function setFavoriteState(
42
+ sessionId: string,
43
+ favorite: boolean,
44
+ searchPaths?: readonly string[],
45
+ ): FavoritesSetResult {
46
+ const path = searchPaths?.[0]
47
+ if (path === undefined || path === '') return { ok: false, error: '缺少收藏文件路径' }
48
+ let ids: string[] = []
49
+ if (existsSync(path)) {
50
+ try {
51
+ const parsed = JSON.parse(readFileSync(path, 'utf8')) as { favoriteSessionIds?: unknown }
52
+ if (Array.isArray(parsed.favoriteSessionIds)) {
53
+ ids = parsed.favoriteSessionIds.filter((id): id is string => typeof id === 'string')
54
+ }
55
+ } catch { /* 畸形文件按空集合重建 */ }
56
+ }
57
+ const without = ids.filter(id => id !== sessionId)
58
+ const next = favorite ? [...without, sessionId] : without
59
+ try {
60
+ mkdirSync(dirname(path), { recursive: true })
61
+ const tmp = `${path}.fav-tmp`
62
+ writeFileSync(tmp, `${JSON.stringify({
63
+ unit: { name: 'manage-session-favorites', version: 1 },
64
+ favoriteSessionIds: next,
65
+ }, null, 2)}\n`, 'utf8')
66
+ renameSync(tmp, path)
67
+ return { ok: true, total: next.length }
68
+ } catch (err) {
69
+ return { ok: false, error: String(err instanceof Error ? err.message : err) }
70
+ }
71
+ }