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,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
|
+
}
|