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.
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,250 +1,250 @@
1
- /**
2
- * 官方归档集合(archive set)的读取与清理 —— 自 dsh-session-search-toggle
3
- * `src/host/archive-source.ts` **逐字迁入**(只依赖 node:fs/os/path),行为与迁移前等价。
4
- *
5
- * 读取顺序:**优先直接读规范存储文件**(workspace domain 把 `archivedSessionIds`
6
- * 落在 `global` 段:storage-json 即 `~/.dsh/storages/workspace.json`),
7
- * 文件缺失时才回退到进程内 `workspaceRegistry` 服务。
8
- * 之所以不再让 registry 优先:prune 只能改文件,列表若以 registry 为准,
9
- * 清理成功后面板不会发生任何变化。registry 仍被读取,但只用作诊断面
10
- * (`registryIds`:指出「已出文件、仍在内存里生效」的悬挂项)。
11
- * 每次读取独立决定来源;失败降级为「空归档集合」并通过 diagnostics 面暴露。
12
- *
13
- * 写入(清理):官方后端没有 unarchive 端点,因此按 storage-json 自身的协议直接编辑
14
- * 规范文件 —— 备份、同目录临时文件、原子改名。运行中的宿主只在启动时加载该集合,
15
- * 调用方必须提示需要重启 DSH。
16
- */
17
- import { copyFileSync, existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
18
- import { homedir } from 'node:os'
19
- import { join } from 'node:path'
20
-
21
- /** 一次归档读取的结果:ids 以及服务它的来源。 */
22
- export interface StewardArchiveRead {
23
- ids: readonly string[]
24
- source: 'registry' | 'storage-file' | 'none'
25
- /**
26
- * 宿主内存 registry 里的同一集合(尽力而为,读不到时缺省)。
27
- *
28
- * 与 `ids` 的差集 = 「已从存储文件移除、但本进程仍生效」的悬挂项:
29
- * registry 是宿主启动时载入的快照,prune 写不到它,重启才会重载。
30
- */
31
- registryIds?: readonly string[]
32
- }
33
-
34
- /** workspaceRegistry 镜像面(只读 getter)。 */
35
- export interface StewardRegistryFace {
36
- readonly archivedSessionIds: readonly string[]
37
- }
38
-
39
- /** 描述归档集合解析情况的诊断信息。 */
40
- export interface StewardArchiveDiagnostics {
41
- source: 'registry' | 'storage-file' | 'none'
42
- ids: number
43
- error?: string
44
- }
45
-
46
- /** workspace domain 的 DSH 存储中枢候选路径(json 后端)。 */
47
- export function storageFileCandidates(): string[] {
48
- return [
49
- join(homedir(), '.dsh', 'storages', 'workspace.json'),
50
- ]
51
- }
52
-
53
- /** 解析存储文件 global.archivedSessionIds;内容畸形时抛错。 */
54
- function readStorageFile(path: string): readonly string[] {
55
- const parsed = JSON.parse(readFileSync(path, 'utf8')) as {
56
- global?: { archivedSessionIds?: unknown }
57
- }
58
- const ids = parsed.global?.archivedSessionIds
59
- if (!Array.isArray(ids)) throw new Error(`storage hub "${path}" holds no global.archivedSessionIds array`)
60
- return ids.filter((id): id is string => typeof id === 'string')
61
- }
62
-
63
- /** 尽力读取宿主内存里的归档集合;不可用时返回 undefined。 */
64
- function readRegistryIds(registry?: StewardRegistryFace): readonly string[] | undefined {
65
- if (registry === undefined) return undefined
66
- try {
67
- const ids = registry.archivedSessionIds
68
- return Array.isArray(ids) ? ids : undefined
69
- } catch {
70
- return undefined
71
- }
72
- }
73
-
74
- /**
75
- * 解析一次官方归档集合。
76
- *
77
- * **存储文件优先**:prune 唯一能改的就是这份文件,列表必须与「能被改的那份」
78
- * 同源,否则清理成功后面板看起来毫无变化 —— 宿主 registry 是进程启动时的快照,
79
- * 直接编辑文件不会回写它,于是「文件在瘦身、列表纹丝不动」。
80
- * registry 退居为文件缺失时的兜底,同时作为诊断面(`registryIds`)返回。
81
- * @param registry - 惰性 workspaceRegistry 面(可能缺失或抛错)。
82
- * @param searchPaths - 可选候选路径覆盖(DSH_HOME 非默认值或测试注入时使用)。
83
- * @returns 归档 ids、服务它的来源,以及宿主内存里的同一集合。
84
- */
85
- export function readArchiveSet(registry?: StewardRegistryFace, searchPaths?: readonly string[]): StewardArchiveRead {
86
- const registryIds = readRegistryIds(registry)
87
- for (const path of searchPaths ?? storageFileCandidates()) {
88
- if (!existsSync(path)) continue
89
- try {
90
- const ids = readStorageFile(path)
91
- return { ids, source: 'storage-file', ...(registryIds === undefined ? {} : { registryIds }) }
92
- } catch { /* 文件畸形 —— 试下一个候选 */ }
93
- }
94
- if (registryIds !== undefined) return { ids: registryIds, source: 'registry', registryIds }
95
- return { ids: [], source: 'none' }
96
- }
97
-
98
- /** 一次存储文件编辑的结果。 */
99
- export type StewardEditOutcome =
100
- | { ok: true; changed: boolean; file: string }
101
- | { ok: false; reason: string }
102
-
103
- /**
104
- * **本插件写 workspace.json 的唯一入口。**
105
- *
106
- * 任何「改归档集合 / 改工作区成员表」都必须走这里 —— 出现第二个写文件的实现,
107
- * 就会重现「读的人和写的人不是同一份数据」那类问题。
108
- *
109
- * 协议与 storage-json 自身一致:读 → `edit(document)` 就地改 → 备份 → 同目录临时写入
110
- * → 原子改名。`edit` 返回 `false` 表示无需写入(此时**不产生备份**)。
111
- * @param path - 规范存储文件路径。
112
- * @param edit - 就地修改文档;返回是否真的改了。
113
- * @param log - 可选日志出口。
114
- * @returns 编辑结果;失败时给出原因而非抛错。
115
- */
116
- export function editWorkspaceDocument(
117
- path: string,
118
- edit: (document: Record<string, unknown>) => boolean,
119
- log?: (msg: string) => void,
120
- ): StewardEditOutcome {
121
- if (!existsSync(path)) return { ok: false, reason: `存储文件不存在:${path}` }
122
- let document: Record<string, unknown>
123
- try {
124
- document = JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>
125
- } catch (err) {
126
- return { ok: false, reason: `无法解析 "${path}":${String(err instanceof Error ? err.message : err)}` }
127
- }
128
- let changed: boolean
129
- try {
130
- changed = edit(document)
131
- } catch (err) {
132
- return { ok: false, reason: `编辑 "${path}" 失败:${String(err instanceof Error ? err.message : err)}` }
133
- }
134
- if (!changed) return { ok: true, changed: false, file: path }
135
- // 在文件旁备份,然后原子替换(tmp + rename),序列化与 storage-json 一致。
136
- const backup = `${path}.bak-${Date.now()}`
137
- copyFileSync(path, backup)
138
- const tmp = `${path}.prune-tmp`
139
- writeFileSync(tmp, `${JSON.stringify(document, null, 2)}
140
- `, 'utf8')
141
- renameSync(tmp, path)
142
- log?.(`workspace.json edited; backup=${backup}`)
143
- return { ok: true, changed: true, file: path }
144
- }
145
-
146
- /**
147
- * 从存储中枢的 `global.archivedSessionIds` 中移除会话 id(= 取消归档状态)。
148
- *
149
- * 官方后端没有 unarchive 端点,因此直接编辑规范文件;运行中的宿主把集合留在内存里、
150
- * 只在启动时重载 —— 调用方必须提示需要重启 DSH。
151
- * @param ids - 要从归档数组中移除的会话 id。
152
- * @param log - 可选日志出口。
153
- * @param searchPaths - 可选候选路径覆盖(测试注入用)。
154
- * @returns 实际移除数量与剩余数量。
155
- */
156
- export function pruneArchiveFile(
157
- ids: readonly string[],
158
- log?: (msg: string) => void,
159
- searchPaths?: readonly string[],
160
- ): {
161
- removed: number
162
- remaining: number
163
- file?: string
164
- } {
165
- const wanted = new Set(ids)
166
- let lastReason = 'workspace storage file not found (searched ~/.dsh/storages/workspace.json)'
167
- for (const path of searchPaths ?? storageFileCandidates()) {
168
- let removed = 0
169
- let remaining = 0
170
- const outcome = editWorkspaceDocument(path, (document) => {
171
- const global = document.global as { archivedSessionIds?: unknown } | undefined
172
- const current = global?.archivedSessionIds
173
- if (!Array.isArray(current)) throw new Error(`storage hub "${path}" holds no global.archivedSessionIds array`)
174
- const kept = current.filter((id): id is string => typeof id === 'string' && !wanted.has(id))
175
- removed = current.length - kept.length
176
- remaining = kept.length
177
- if (removed === 0) return false
178
- document.global = { ...global, archivedSessionIds: kept }
179
- return true
180
- }, log)
181
- if (!outcome.ok) { lastReason = outcome.reason; continue }
182
- if (outcome.changed) log?.(`archive prune: removed ${removed} ids; remaining ${remaining}`)
183
- return { removed, remaining, file: outcome.file }
184
- }
185
- throw new Error(lastReason)
186
- }
187
-
188
- /**
189
- * 把会话 id 加入存储中枢的 `global.archivedSessionIds`(= 归档,prune 的逆操作)。
190
- *
191
- * 与 prune 同一写入口与协议(备份 + 临时文件 + 原子改名);已在集合中的 id
192
- * 不重复追加。运行中的宿主把集合留在内存里、只在启动时重载 —— 调用方必须
193
- * 提示需要重启 DSH。
194
- * @param ids - 要加入归档数组的会话 id。
195
- * @param log - 可选日志出口。
196
- * @param searchPaths - 可选候选路径覆盖(测试注入用)。
197
- * @returns 实际新增数量与操作后的集合总数。
198
- */
199
- export function archiveArchiveFile(
200
- ids: readonly string[],
201
- log?: (msg: string) => void,
202
- searchPaths?: readonly string[],
203
- ): {
204
- added: number
205
- total: number
206
- file?: string
207
- } {
208
- const wanted = new Set(ids)
209
- let lastReason = 'workspace storage file not found (searched ~/.dsh/storages/workspace.json)'
210
- for (const path of searchPaths ?? storageFileCandidates()) {
211
- let added = 0
212
- let total = 0
213
- const outcome = editWorkspaceDocument(path, (document) => {
214
- const global = document.global as { archivedSessionIds?: unknown } | undefined
215
- const current = global?.archivedSessionIds
216
- if (!Array.isArray(current)) throw new Error(`storage hub "${path}" holds no global.archivedSessionIds array`)
217
- const existing = new Set(current.filter((id): id is string => typeof id === 'string'))
218
- const fresh = ids.filter((id) => !existing.has(id))
219
- if (fresh.length === 0) {
220
- added = 0
221
- total = existing.size
222
- return false
223
- }
224
- document.global = { ...global, archivedSessionIds: [...current, ...fresh] }
225
- added = fresh.length
226
- total = existing.size + fresh.length
227
- return true
228
- }, log)
229
- if (!outcome.ok) { lastReason = outcome.reason; continue }
230
- if (outcome.changed) log?.(`archive: added ${added} ids; total ${total}`)
231
- return { added, total, file: outcome.file }
232
- }
233
- throw new Error(lastReason)
234
- }
235
-
236
- /** 构造同步器期望的惰性来源面,并记录诊断。 */
237
- export function createArchiveSource(getRegistry: () => StewardRegistryFace | undefined): {
238
- read: () => StewardArchiveRead
239
- diagnostics: () => StewardArchiveDiagnostics
240
- } {
241
- let last: StewardArchiveDiagnostics = { source: 'none', ids: 0 }
242
- return {
243
- read: () => {
244
- const read = readArchiveSet(getRegistry())
245
- last = { source: read.source, ids: read.ids.length }
246
- return read
247
- },
248
- diagnostics: () => ({ ...last }),
249
- }
250
- }
1
+ /**
2
+ * 官方归档集合(archive set)的读取与清理 —— 自 dsh-session-search-toggle
3
+ * `src/host/archive-source.ts` **逐字迁入**(只依赖 node:fs/os/path),行为与迁移前等价。
4
+ *
5
+ * 读取顺序:**优先直接读规范存储文件**(workspace domain 把 `archivedSessionIds`
6
+ * 落在 `global` 段:storage-json 即 `~/.dsh/storages/workspace.json`),
7
+ * 文件缺失时才回退到进程内 `workspaceRegistry` 服务。
8
+ * 之所以不再让 registry 优先:prune 只能改文件,列表若以 registry 为准,
9
+ * 清理成功后面板不会发生任何变化。registry 仍被读取,但只用作诊断面
10
+ * (`registryIds`:指出「已出文件、仍在内存里生效」的悬挂项)。
11
+ * 每次读取独立决定来源;失败降级为「空归档集合」并通过 diagnostics 面暴露。
12
+ *
13
+ * 写入(清理):官方后端没有 unarchive 端点,因此按 storage-json 自身的协议直接编辑
14
+ * 规范文件 —— 备份、同目录临时文件、原子改名。运行中的宿主只在启动时加载该集合,
15
+ * 调用方必须提示需要重启 DSH。
16
+ */
17
+ import { copyFileSync, existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
18
+ import { homedir } from 'node:os'
19
+ import { join } from 'node:path'
20
+
21
+ /** 一次归档读取的结果:ids 以及服务它的来源。 */
22
+ export interface StewardArchiveRead {
23
+ ids: readonly string[]
24
+ source: 'registry' | 'storage-file' | 'none'
25
+ /**
26
+ * 宿主内存 registry 里的同一集合(尽力而为,读不到时缺省)。
27
+ *
28
+ * 与 `ids` 的差集 = 「已从存储文件移除、但本进程仍生效」的悬挂项:
29
+ * registry 是宿主启动时载入的快照,prune 写不到它,重启才会重载。
30
+ */
31
+ registryIds?: readonly string[]
32
+ }
33
+
34
+ /** workspaceRegistry 镜像面(只读 getter)。 */
35
+ export interface StewardRegistryFace {
36
+ readonly archivedSessionIds: readonly string[]
37
+ }
38
+
39
+ /** 描述归档集合解析情况的诊断信息。 */
40
+ export interface StewardArchiveDiagnostics {
41
+ source: 'registry' | 'storage-file' | 'none'
42
+ ids: number
43
+ error?: string
44
+ }
45
+
46
+ /** workspace domain 的 DSH 存储中枢候选路径(json 后端)。 */
47
+ export function storageFileCandidates(): string[] {
48
+ return [
49
+ join(homedir(), '.dsh', 'storages', 'workspace.json'),
50
+ ]
51
+ }
52
+
53
+ /** 解析存储文件 global.archivedSessionIds;内容畸形时抛错。 */
54
+ function readStorageFile(path: string): readonly string[] {
55
+ const parsed = JSON.parse(readFileSync(path, 'utf8')) as {
56
+ global?: { archivedSessionIds?: unknown }
57
+ }
58
+ const ids = parsed.global?.archivedSessionIds
59
+ if (!Array.isArray(ids)) throw new Error(`storage hub "${path}" holds no global.archivedSessionIds array`)
60
+ return ids.filter((id): id is string => typeof id === 'string')
61
+ }
62
+
63
+ /** 尽力读取宿主内存里的归档集合;不可用时返回 undefined。 */
64
+ function readRegistryIds(registry?: StewardRegistryFace): readonly string[] | undefined {
65
+ if (registry === undefined) return undefined
66
+ try {
67
+ const ids = registry.archivedSessionIds
68
+ return Array.isArray(ids) ? ids : undefined
69
+ } catch {
70
+ return undefined
71
+ }
72
+ }
73
+
74
+ /**
75
+ * 解析一次官方归档集合。
76
+ *
77
+ * **存储文件优先**:prune 唯一能改的就是这份文件,列表必须与「能被改的那份」
78
+ * 同源,否则清理成功后面板看起来毫无变化 —— 宿主 registry 是进程启动时的快照,
79
+ * 直接编辑文件不会回写它,于是「文件在瘦身、列表纹丝不动」。
80
+ * registry 退居为文件缺失时的兜底,同时作为诊断面(`registryIds`)返回。
81
+ * @param registry - 惰性 workspaceRegistry 面(可能缺失或抛错)。
82
+ * @param searchPaths - 可选候选路径覆盖(DSH_HOME 非默认值或测试注入时使用)。
83
+ * @returns 归档 ids、服务它的来源,以及宿主内存里的同一集合。
84
+ */
85
+ export function readArchiveSet(registry?: StewardRegistryFace, searchPaths?: readonly string[]): StewardArchiveRead {
86
+ const registryIds = readRegistryIds(registry)
87
+ for (const path of searchPaths ?? storageFileCandidates()) {
88
+ if (!existsSync(path)) continue
89
+ try {
90
+ const ids = readStorageFile(path)
91
+ return { ids, source: 'storage-file', ...(registryIds === undefined ? {} : { registryIds }) }
92
+ } catch { /* 文件畸形 —— 试下一个候选 */ }
93
+ }
94
+ if (registryIds !== undefined) return { ids: registryIds, source: 'registry', registryIds }
95
+ return { ids: [], source: 'none' }
96
+ }
97
+
98
+ /** 一次存储文件编辑的结果。 */
99
+ export type StewardEditOutcome =
100
+ | { ok: true; changed: boolean; file: string }
101
+ | { ok: false; reason: string }
102
+
103
+ /**
104
+ * **本插件写 workspace.json 的唯一入口。**
105
+ *
106
+ * 任何「改归档集合 / 改工作区成员表」都必须走这里 —— 出现第二个写文件的实现,
107
+ * 就会重现「读的人和写的人不是同一份数据」那类问题。
108
+ *
109
+ * 协议与 storage-json 自身一致:读 → `edit(document)` 就地改 → 备份 → 同目录临时写入
110
+ * → 原子改名。`edit` 返回 `false` 表示无需写入(此时**不产生备份**)。
111
+ * @param path - 规范存储文件路径。
112
+ * @param edit - 就地修改文档;返回是否真的改了。
113
+ * @param log - 可选日志出口。
114
+ * @returns 编辑结果;失败时给出原因而非抛错。
115
+ */
116
+ export function editWorkspaceDocument(
117
+ path: string,
118
+ edit: (document: Record<string, unknown>) => boolean,
119
+ log?: (msg: string) => void,
120
+ ): StewardEditOutcome {
121
+ if (!existsSync(path)) return { ok: false, reason: `存储文件不存在:${path}` }
122
+ let document: Record<string, unknown>
123
+ try {
124
+ document = JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>
125
+ } catch (err) {
126
+ return { ok: false, reason: `无法解析 "${path}":${String(err instanceof Error ? err.message : err)}` }
127
+ }
128
+ let changed: boolean
129
+ try {
130
+ changed = edit(document)
131
+ } catch (err) {
132
+ return { ok: false, reason: `编辑 "${path}" 失败:${String(err instanceof Error ? err.message : err)}` }
133
+ }
134
+ if (!changed) return { ok: true, changed: false, file: path }
135
+ // 在文件旁备份,然后原子替换(tmp + rename),序列化与 storage-json 一致。
136
+ const backup = `${path}.bak-${Date.now()}`
137
+ copyFileSync(path, backup)
138
+ const tmp = `${path}.prune-tmp`
139
+ writeFileSync(tmp, `${JSON.stringify(document, null, 2)}
140
+ `, 'utf8')
141
+ renameSync(tmp, path)
142
+ log?.(`workspace.json edited; backup=${backup}`)
143
+ return { ok: true, changed: true, file: path }
144
+ }
145
+
146
+ /**
147
+ * 从存储中枢的 `global.archivedSessionIds` 中移除会话 id(= 取消归档状态)。
148
+ *
149
+ * 官方后端没有 unarchive 端点,因此直接编辑规范文件;运行中的宿主把集合留在内存里、
150
+ * 只在启动时重载 —— 调用方必须提示需要重启 DSH。
151
+ * @param ids - 要从归档数组中移除的会话 id。
152
+ * @param log - 可选日志出口。
153
+ * @param searchPaths - 可选候选路径覆盖(测试注入用)。
154
+ * @returns 实际移除数量与剩余数量。
155
+ */
156
+ export function pruneArchiveFile(
157
+ ids: readonly string[],
158
+ log?: (msg: string) => void,
159
+ searchPaths?: readonly string[],
160
+ ): {
161
+ removed: number
162
+ remaining: number
163
+ file?: string
164
+ } {
165
+ const wanted = new Set(ids)
166
+ let lastReason = 'workspace storage file not found (searched ~/.dsh/storages/workspace.json)'
167
+ for (const path of searchPaths ?? storageFileCandidates()) {
168
+ let removed = 0
169
+ let remaining = 0
170
+ const outcome = editWorkspaceDocument(path, (document) => {
171
+ const global = document.global as { archivedSessionIds?: unknown } | undefined
172
+ const current = global?.archivedSessionIds
173
+ if (!Array.isArray(current)) throw new Error(`storage hub "${path}" holds no global.archivedSessionIds array`)
174
+ const kept = current.filter((id): id is string => typeof id === 'string' && !wanted.has(id))
175
+ removed = current.length - kept.length
176
+ remaining = kept.length
177
+ if (removed === 0) return false
178
+ document.global = { ...global, archivedSessionIds: kept }
179
+ return true
180
+ }, log)
181
+ if (!outcome.ok) { lastReason = outcome.reason; continue }
182
+ if (outcome.changed) log?.(`archive prune: removed ${removed} ids; remaining ${remaining}`)
183
+ return { removed, remaining, file: outcome.file }
184
+ }
185
+ throw new Error(lastReason)
186
+ }
187
+
188
+ /**
189
+ * 把会话 id 加入存储中枢的 `global.archivedSessionIds`(= 归档,prune 的逆操作)。
190
+ *
191
+ * 与 prune 同一写入口与协议(备份 + 临时文件 + 原子改名);已在集合中的 id
192
+ * 不重复追加。运行中的宿主把集合留在内存里、只在启动时重载 —— 调用方必须
193
+ * 提示需要重启 DSH。
194
+ * @param ids - 要加入归档数组的会话 id。
195
+ * @param log - 可选日志出口。
196
+ * @param searchPaths - 可选候选路径覆盖(测试注入用)。
197
+ * @returns 实际新增数量与操作后的集合总数。
198
+ */
199
+ export function archiveArchiveFile(
200
+ ids: readonly string[],
201
+ log?: (msg: string) => void,
202
+ searchPaths?: readonly string[],
203
+ ): {
204
+ added: number
205
+ total: number
206
+ file?: string
207
+ } {
208
+ const wanted = new Set(ids)
209
+ let lastReason = 'workspace storage file not found (searched ~/.dsh/storages/workspace.json)'
210
+ for (const path of searchPaths ?? storageFileCandidates()) {
211
+ let added = 0
212
+ let total = 0
213
+ const outcome = editWorkspaceDocument(path, (document) => {
214
+ const global = document.global as { archivedSessionIds?: unknown } | undefined
215
+ const current = global?.archivedSessionIds
216
+ if (!Array.isArray(current)) throw new Error(`storage hub "${path}" holds no global.archivedSessionIds array`)
217
+ const existing = new Set(current.filter((id): id is string => typeof id === 'string'))
218
+ const fresh = ids.filter((id) => !existing.has(id))
219
+ if (fresh.length === 0) {
220
+ added = 0
221
+ total = existing.size
222
+ return false
223
+ }
224
+ document.global = { ...global, archivedSessionIds: [...current, ...fresh] }
225
+ added = fresh.length
226
+ total = existing.size + fresh.length
227
+ return true
228
+ }, log)
229
+ if (!outcome.ok) { lastReason = outcome.reason; continue }
230
+ if (outcome.changed) log?.(`archive: added ${added} ids; total ${total}`)
231
+ return { added, total, file: outcome.file }
232
+ }
233
+ throw new Error(lastReason)
234
+ }
235
+
236
+ /** 构造同步器期望的惰性来源面,并记录诊断。 */
237
+ export function createArchiveSource(getRegistry: () => StewardRegistryFace | undefined): {
238
+ read: () => StewardArchiveRead
239
+ diagnostics: () => StewardArchiveDiagnostics
240
+ } {
241
+ let last: StewardArchiveDiagnostics = { source: 'none', ids: 0 }
242
+ return {
243
+ read: () => {
244
+ const read = readArchiveSet(getRegistry())
245
+ last = { source: read.source, ids: read.ids.length }
246
+ return read
247
+ },
248
+ diagnostics: () => ({ ...last }),
249
+ }
250
+ }