dsh-manage-session 0.4.8 → 0.5.3

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.md +36 -0
  2. package/README.en.md +4 -0
  3. package/README.md +4 -0
  4. package/cordis.patch.yml +6 -1
  5. package/dsh.plugin.json +22 -22
  6. package/lib/client.js +3848 -202
  7. package/lib/index.d.ts +759 -29
  8. package/lib/index.js +2604 -93
  9. package/locale/de.json +6 -0
  10. package/locale/en.json +6 -0
  11. package/locale/es.json +6 -0
  12. package/locale/fr.json +6 -0
  13. package/locale/it.json +6 -0
  14. package/locale/ja.json +6 -0
  15. package/locale/ko.json +6 -0
  16. package/locale/ru.json +6 -0
  17. package/locale/zh.json +6 -0
  18. package/package.json +43 -32
  19. package/src/client/bridge-scope.ts +213 -0
  20. package/src/client/index.ts +154 -26
  21. package/src/client/locales.ts +857 -223
  22. package/src/client/panel.tsx +139 -118
  23. package/src/client/search/card.tsx +563 -0
  24. package/src/client/search/host-api.ts +208 -0
  25. package/src/client/search/locales.ts +780 -0
  26. package/src/client/search/manage.tsx +400 -0
  27. package/src/client/search/panel.tsx +815 -0
  28. package/src/client/search/platform.ts +136 -0
  29. package/src/config.ts +54 -5
  30. package/src/host/history/archive-source.ts +48 -0
  31. package/src/host/history/archive.ts +56 -1
  32. package/src/host/history/favorites.ts +71 -0
  33. package/src/host/index/archive-source.ts +90 -0
  34. package/src/host/index/engine.ts +571 -0
  35. package/src/host/index/extract.ts +272 -0
  36. package/src/host/index/peers.ts +62 -0
  37. package/src/host/index/rebuild.ts +368 -0
  38. package/src/host/index/schema.ts +136 -0
  39. package/src/host/index/snapshot.ts +135 -0
  40. package/src/host/index/sync.ts +286 -0
  41. package/src/host/registry/archive-registry.ts +317 -0
  42. package/src/host/settings-bridge.ts +237 -0
  43. package/src/index.ts +1421 -532
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Platform detection and the keyboard-label vocabulary, for the client half.
3
+ *
4
+ * Why this module exists
5
+ * ----------------------
6
+ * A shortcut hint is the one place where a single hard-coded string is wrong
7
+ * for most users: `⌘K` is meaningless on Windows, `Ctrl K` is wrong on macOS.
8
+ * The obvious implementation — one `navigator.platform` test — is also the
9
+ * fragile one: that API is deprecated, and it returns an empty string in some
10
+ * privacy configurations, which silently degrades every Mac user to the
11
+ * Windows vocabulary without any error to notice.
12
+ *
13
+ * This module is the single owner of both the platform answer and every label
14
+ * derived from it. The hint rendered in the sidebar, the hint rendered in the
15
+ * panel footer, and the chord the keydown handler actually matches all read
16
+ * from here — so a platform fix cannot land in one of them and miss another,
17
+ * and the advertised key cannot drift away from the bound key.
18
+ */
19
+
20
+ /** The navigator fields the detection reads (all optional, all untrusted). */
21
+ export interface NavigatorLike {
22
+ userAgentData?: { platform?: unknown }
23
+ platform?: unknown
24
+ userAgent?: unknown
25
+ }
26
+
27
+ /**
28
+ * Resolve the running platform string, three tiers deep.
29
+ *
30
+ * 1. UA-CH (`navigator.userAgentData.platform`) — modern and precise.
31
+ * 2. `navigator.platform` — broadly available, deprecated, and empty in some
32
+ * privacy modes.
33
+ * 3. The UA string — last resort, still non-empty in practice.
34
+ *
35
+ * @param nav - the navigator to read; `undefined` is allowed (non-DOM host).
36
+ * @returns a lower-cased platform string, or `''` when nothing could be read.
37
+ */
38
+ export function detectPlatform(nav: NavigatorLike | undefined): string {
39
+ const chPlatform = nav?.userAgentData?.platform
40
+ if (typeof chPlatform === 'string' && chPlatform !== '') return chPlatform.toLowerCase()
41
+ const legacy = nav?.platform
42
+ if (typeof legacy === 'string' && legacy !== '') return legacy.toLowerCase()
43
+ const agent = nav?.userAgent
44
+ if (typeof agent === 'string' && agent !== '') return agent.toLowerCase()
45
+ return ''
46
+ }
47
+
48
+ /** The keyboard vocabulary for one platform. */
49
+ export interface PlatformLabels {
50
+ /** The lower-cased platform string these labels were derived from. */
51
+ platform: string
52
+ isMac: boolean
53
+ isWindows: boolean
54
+ /** Invoke-key modifier: `⌘` on macOS, `Ctrl` elsewhere. */
55
+ modLabel: string
56
+ /** Secondary modifier: `⌥` on macOS, `Alt` elsewhere. */
57
+ altLabel: string
58
+ /** Shift: `⇧` on macOS, `Shift` elsewhere. */
59
+ shiftLabel: string
60
+ /** Confirm: the arrow glyph is legible on every platform. */
61
+ enterLabel: string
62
+ /** Escape: `esc` is the macOS spelling, `Esc` the one everywhere else. */
63
+ escLabel: string
64
+ /**
65
+ * The whole invoke chord as one face, ready for a key chip: `⌘K` on macOS,
66
+ * `Ctrl K` on Windows and Linux. `Ctrl K` keeps the space because `CtrlK`
67
+ * reads as a word rather than as two keys.
68
+ */
69
+ invokeLabel: string
70
+ }
71
+
72
+ /**
73
+ * Derive the label vocabulary from a platform string.
74
+ *
75
+ * An unrecognised platform — an empty string, or a UA that names neither
76
+ * system — takes the non-Mac vocabulary on purpose: `Ctrl` is legible to
77
+ * everyone, while `⌘` is opaque to anyone who has never used a Mac.
78
+ *
79
+ * @param platform - the string produced by {@link detectPlatform}.
80
+ * @returns the label vocabulary for that platform.
81
+ */
82
+ export function platformLabels(platform: string): PlatformLabels {
83
+ const isMac = /mac|iphone|ipad|ipod/u.test(platform)
84
+ const isWindows = /win/u.test(platform)
85
+ const modLabel = isMac ? '⌘' : 'Ctrl'
86
+ return {
87
+ platform,
88
+ isMac,
89
+ isWindows,
90
+ modLabel,
91
+ altLabel: isMac ? '⌥' : 'Alt',
92
+ shiftLabel: isMac ? '⇧' : 'Shift',
93
+ enterLabel: '↵',
94
+ escLabel: isMac ? 'esc' : 'Esc',
95
+ invokeLabel: isMac ? `${modLabel}K` : `${modLabel} K`,
96
+ }
97
+ }
98
+
99
+ /** Read `globalThis.navigator` without assuming a DOM (or a DOM lib). */
100
+ function currentNavigator(): NavigatorLike | undefined {
101
+ const candidate = (globalThis as { navigator?: unknown }).navigator
102
+ if (candidate === null || typeof candidate !== 'object') return undefined
103
+ return candidate as NavigatorLike
104
+ }
105
+
106
+ /** The labels for the platform this bundle is running on. */
107
+ export const LABELS: PlatformLabels = platformLabels(detectPlatform(currentNavigator()))
108
+
109
+ /** The minimal keydown face the chord test reads. */
110
+ export interface KeyChordEvent {
111
+ key: string
112
+ ctrlKey: boolean
113
+ metaKey: boolean
114
+ altKey: boolean
115
+ shiftKey: boolean
116
+ }
117
+
118
+ /**
119
+ * Whether a keydown event is the invoke chord advertised by
120
+ * {@link PlatformLabels.invokeLabel}.
121
+ *
122
+ * The hint and the binding have to agree: a footer promising `⌘K` while the
123
+ * handler matches `Ctrl+K` is worse than showing no hint at all. Both sides
124
+ * call this, so neither can be changed alone. Requiring the other modifier to
125
+ * be *absent* keeps the two chords distinct instead of letting either one
126
+ * satisfy both platforms.
127
+ *
128
+ * @param event - the keydown payload.
129
+ * @param isMac - the running platform's macOS-ness, from {@link LABELS}.
130
+ * @returns `true` when the event is the invoke chord.
131
+ */
132
+ export function isInvokeChord(event: KeyChordEvent, isMac: boolean): boolean {
133
+ if (event.key !== 'k' && event.key !== 'K') return false
134
+ if (event.altKey || event.shiftKey) return false
135
+ return isMac ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey
136
+ }
package/src/config.ts CHANGED
@@ -1,19 +1,26 @@
1
1
  /**
2
- * dsh-manage-session 的共享配置面。
2
+ * dsh-manage-session 的共享配置面(合并包:管家子域 + 搜索索引子域)。
3
3
  *
4
4
  * 与 host 半身(schemastery schema 在 src/index.ts)以及浏览器半身共用同一形状,
5
- * 使一处 admitted 的值在另一处也 admitted —— 与 dsh-session-search-toggle /
6
- * dsh-thinking-levels 的 config 面惯例一致。
5
+ * 使一处 admitted 的值在另一处也 admitted。
6
+ *
7
+ * 两个设置命名空间**并存且互不迁移**:
8
+ * - `session-steward` —— 管家子域(enabled/historyFiles/healthCheck/search 等共享键);
9
+ * - `switch-search` —— 搜索子域的历史命名空间,存储键承诺保持稳定(见
10
+ * dsh-search-index README「存储键保持稳定,不做迁移」);0.1.7 起两者实际
11
+ * 都落在同一 composition entry(configForms),旧宿主才按命名空间分流。
7
12
  */
8
13
 
9
14
  /** 会话管家运行时配置(设置命名空间 + 组合入口)。 */
10
15
  export interface StewardConfig {
11
- /** 插件总开关。 */
16
+ /** 插件总开关(合并包唯一 master:关闭后不注册任何子域路由)。 */
12
17
  enabled: boolean
13
18
  /** 会话历史文件(归档浏览与清理)。关闭后不注册 history 路由、不渲染「养老院」页签。 */
14
19
  historyFiles?: boolean
15
20
  /** 会话健康检查(体检 → 处方 → 出院)。关闭后不注册 health 路由、不渲染「体检」页签。 */
16
21
  healthCheck?: boolean
22
+ /** 搜索索引子域(`index-*` 路由与侧栏搜索入口)。关闭后索引子域整体停摆。 */
23
+ search?: boolean
17
24
  }
18
25
 
19
26
  /** 缺省值。 */
@@ -21,6 +28,7 @@ export const DEFAULT_CONFIG: Required<StewardConfig> = {
21
28
  enabled: true,
22
29
  historyFiles: true,
23
30
  healthCheck: true,
31
+ search: true,
24
32
  }
25
33
 
26
34
  /** host 半身注册的设置命名空间(与 src/index.ts 保持一致)。 */
@@ -29,5 +37,46 @@ export const STEWARD_SETTINGS_NAMESPACE = 'session-steward'
29
37
  /** 侧边栏入口 id(客户端注册 id)。 */
30
38
  export const STEWARD_ENTRY_ID = 'dsh-manage-session'
31
39
 
32
- /** host 路由前缀(与搜索索引插件的 /switch-search/api 互不干扰)。 */
40
+ /** host 路由前缀(管家子域;与搜索子域的 /switch-search/api 互不干扰)。 */
33
41
  export const STEWARD_API_PREFIX = '/session-steward/api'
42
+
43
+ /** ------------------------------------------------------------------ 搜索子域 */
44
+
45
+ /** 搜索子域的历史设置命名空间(存储键稳定承诺,永不迁移)。 */
46
+ export const SWITCH_SEARCH_SETTINGS_NAMESPACE = 'switch-search'
47
+
48
+ /** 搜索子域路由前缀(历史值,浏览器旧 bundle 与快照文件名依赖它,保持不变)。 */
49
+ export const SWITCH_API_PREFIX = '/switch-search/api'
50
+
51
+ /** 搜索面板打开时的默认模式。 */
52
+ export type SwitchSearchDefaultMode = 'title' | 'content'
53
+
54
+ /** 搜索子域可调配置(独立于 StewardConfig 的历史形状,字段名保持稳定)。 */
55
+ export interface SwitchSearchConfig {
56
+ /**
57
+ * 搜索卡上的「启用」开关。合并包里它与管家 `enabled` 是**同一个**组合入口
58
+ * 字段(0.1.7 configForms 单 scope);保留在此处是为了 legacy settingsScope
59
+ * 回退路径(旧 switch-search 命名空间的存储形状)与设置卡类型不变。
60
+ */
61
+ enabled?: boolean
62
+ /** 面板默认模式。 */
63
+ defaultMode: SwitchSearchDefaultMode
64
+ /** 独立索引是否后台自动同步(水位轮询)。 */
65
+ autoSync?: boolean
66
+ /** 增量同步间隔(ms)。 */
67
+ syncIntervalMs?: number
68
+ /** 整理索引后保留的归档份数。 */
69
+ archiveKeep?: number
70
+ /** 独立索引文件的可选绝对目录。 */
71
+ indexDir?: string
72
+ }
73
+
74
+ /** 搜索子域缺省值。 */
75
+ export const SWITCH_DEFAULT_CONFIG: Required<SwitchSearchConfig> = {
76
+ enabled: true,
77
+ defaultMode: 'title',
78
+ autoSync: true,
79
+ syncIntervalMs: 30_000,
80
+ archiveKeep: 2,
81
+ indexDir: '',
82
+ }
@@ -185,6 +185,54 @@ export function pruneArchiveFile(
185
185
  throw new Error(lastReason)
186
186
  }
187
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
+
188
236
  /** 构造同步器期望的惰性来源面,并记录诊断。 */
189
237
  export function createArchiveSource(getRegistry: () => StewardRegistryFace | undefined): {
190
238
  read: () => StewardArchiveRead
@@ -14,7 +14,7 @@
14
14
  import { existsSync, readFileSync } from 'node:fs'
15
15
  import { homedir } from 'node:os'
16
16
  import { join } from 'node:path'
17
- import { pruneArchiveFile, readArchiveSet, type StewardRegistryFace } from './archive-source.ts'
17
+ import { archiveArchiveFile, pruneArchiveFile, readArchiveSet, type StewardRegistryFace } from './archive-source.ts'
18
18
  import { locateSessionUsage } from './purge.ts'
19
19
 
20
20
  /** 一行历史文件条目(尽力而为的元数据 + 磁盘占用)。 */
@@ -192,3 +192,58 @@ export function pruneHistory(
192
192
  log?.(`archive prune requested ${ids.length}, removed ${result.removed}, remaining ${result.remaining}`)
193
193
  return { ok: true, removed: result.removed, remaining: result.remaining, requiresRestart: true }
194
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
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * 收藏域(R6)—— 插件自有的 JSON 文件,与索引文件/官方存储彻底分离:
3
+ * 索引会因 schema 重置/影子切换整体更换,收藏不能放里面;官方存储中枢
4
+ * 只读不写(唯一写方纪律不破)。文件形如
5
+ * `<dshHome>/storages/session-steward-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', 'session-steward-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: 'session-steward-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
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Official archive-set source resolution.
3
+ *
4
+ * Primary: the in-process `workspaceRegistry` service (the same fact the UI
5
+ * filters by). Fallback: read the canonical storage hub file directly — the
6
+ * workspace domain persists `archivedSessionIds` under the `global` segment
7
+ * (storage-json: `~/.dsh/storages/workspace.json`; storage-sqlite variant
8
+ * exists but the JSON fallback file is what stock web profiles ship).
9
+ * Resolution order is decided per read; failures degrade to "no archive set"
10
+ * and are reported through the diagnostics face.
11
+ *
12
+ * READ-ONLY by contract. The archive set is WRITTEN by dsh-manage-session
13
+ * (会话管家 → 病案室); this package only consumes it to keep archived sessions
14
+ * out of the index. Format contract: `dsh-归档文件格式契约-20260914.md`.
15
+ */
16
+ import { existsSync, readFileSync } from 'node:fs'
17
+ import { homedir } from 'node:os'
18
+ import { join } from 'node:path'
19
+
20
+ /** One resolved archive read: ids plus which source served them. */
21
+ export interface SwitchArchiveRead {
22
+ ids: readonly string[]
23
+ source: 'registry' | 'storage-file' | 'none'
24
+ }
25
+
26
+ /** The workspaceRegistry mirror face (getter only). */
27
+ export interface SwitchRegistryFace {
28
+ readonly archivedSessionIds: readonly string[]
29
+ }
30
+
31
+ /** Diagnostics describing how the archive set is being resolved. */
32
+ export interface SwitchArchiveDiagnostics {
33
+ source: 'registry' | 'storage-file' | 'none'
34
+ ids: number
35
+ error?: string
36
+ }
37
+
38
+ /** DSH storage hub candidates for the workspace domain (json backend). */
39
+ function storageFileCandidates(): string[] {
40
+ return [
41
+ join(homedir(), '.dsh', 'storages', 'workspace.json'),
42
+ ]
43
+ }
44
+
45
+ /** Parse the storage hub file's global.archivedSessionIds; throw on malformed content. */
46
+ function readStorageFile(path: string): readonly string[] {
47
+ const parsed = JSON.parse(readFileSync(path, 'utf8')) as {
48
+ global?: { archivedSessionIds?: unknown }
49
+ }
50
+ const ids = parsed.global?.archivedSessionIds
51
+ if (!Array.isArray(ids)) throw new Error(`storage hub "${path}" holds no global.archivedSessionIds array`)
52
+ return ids.filter((id): id is string => typeof id === 'string')
53
+ }
54
+
55
+ /**
56
+ * Resolve the official archive set once.
57
+ * @param registry - lazy workspaceRegistry face (may be absent or throw).
58
+ * @returns the archive ids plus which source served them.
59
+ */
60
+ export function readArchiveSet(registry?: SwitchRegistryFace): SwitchArchiveRead {
61
+ if (registry !== undefined) {
62
+ try {
63
+ const ids = registry.archivedSessionIds
64
+ if (Array.isArray(ids)) return { ids, source: 'registry' }
65
+ } catch { /* registry not started yet — fall through to the file */ }
66
+ }
67
+ for (const path of storageFileCandidates()) {
68
+ if (!existsSync(path)) continue
69
+ try {
70
+ return { ids: readStorageFile(path), source: 'storage-file' }
71
+ } catch { /* malformed file — try the next candidate */ }
72
+ }
73
+ return { ids: [], source: 'none' }
74
+ }
75
+
76
+ /** Build the lazy source face the syncer expects, with diagnostics capture. */
77
+ export function createArchiveSource(getRegistry: () => SwitchRegistryFace | undefined): {
78
+ read: () => SwitchArchiveRead
79
+ diagnostics: () => SwitchArchiveDiagnostics
80
+ } {
81
+ let last: SwitchArchiveDiagnostics = { source: 'none', ids: 0 }
82
+ return {
83
+ read: () => {
84
+ const read = readArchiveSet(getRegistry())
85
+ last = { source: read.source, ids: read.ids.length }
86
+ return read
87
+ },
88
+ diagnostics: () => ({ ...last }),
89
+ }
90
+ }