dsh-config-manager 0.1.78 → 0.1.80

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 (302) hide show
  1. package/lib/adapters/credentials.d.ts +6 -2
  2. package/lib/adapters/credentials.js +19 -3
  3. package/lib/adapters/file-collection.js +2 -1
  4. package/lib/adapters/mcp.d.ts +48 -2
  5. package/lib/adapters/mcp.js +265 -3
  6. package/lib/adapters/messages.d.ts +3 -0
  7. package/lib/adapters/messages.js +7 -0
  8. package/lib/adapters/plugin-files.js +2 -1
  9. package/lib/adapters/plugins.js +105 -20
  10. package/lib/adapters/pnpm-workspace.d.ts +32 -0
  11. package/lib/adapters/pnpm-workspace.js +113 -2
  12. package/lib/adapters/prompts.d.ts +6 -2
  13. package/lib/adapters/prompts.js +17 -2
  14. package/lib/adapters/providers.d.ts +19 -2
  15. package/lib/adapters/providers.js +59 -3
  16. package/lib/adapters/settings.d.ts +6 -2
  17. package/lib/adapters/settings.js +13 -2
  18. package/lib/adapters/skills.d.ts +25 -1
  19. package/lib/adapters/skills.js +60 -0
  20. package/lib/adapters/ui.d.ts +6 -2
  21. package/lib/adapters/ui.js +13 -2
  22. package/lib/adapters/units.d.ts +18 -0
  23. package/lib/adapters/units.js +39 -0
  24. package/lib/adapters/workspaces.js +2 -1
  25. package/lib/client.d.ts +401 -112
  26. package/lib/client.js +2899 -1525
  27. package/lib/core/backup.js +20 -0
  28. package/lib/core/mcp-connector-store.d.ts +159 -0
  29. package/lib/core/mcp-connector-store.js +445 -0
  30. package/lib/core/messages-core.d.ts +2 -0
  31. package/lib/core/messages-core.js +4 -0
  32. package/lib/core/messages.d.ts +20 -0
  33. package/lib/core/messages.js +44 -0
  34. package/lib/core/phase3-host.js +18 -1
  35. package/lib/core/plugin-cli.d.ts +22 -2
  36. package/lib/core/plugin-cli.js +22 -3
  37. package/lib/core/recovery-orchestrator.d.ts +3 -1
  38. package/lib/core/recovery-orchestrator.js +75 -16
  39. package/lib/core/rollback-activity.d.ts +89 -0
  40. package/lib/core/rollback-activity.js +350 -0
  41. package/lib/core/rollback-ledger.d.ts +0 -9
  42. package/lib/core/rollback-ledger.js +37 -0
  43. package/lib/core/rollback-merge.d.ts +22 -2
  44. package/lib/core/rollback-merge.js +63 -13
  45. package/lib/core/rollback-point.d.ts +93 -0
  46. package/lib/core/rollback-point.js +420 -101
  47. package/lib/core/rollback-undo-journal.d.ts +123 -0
  48. package/lib/core/rollback-undo-journal.js +517 -0
  49. package/lib/core/self-update.js +2 -4
  50. package/lib/core/text-fidelity.d.ts +89 -0
  51. package/lib/core/text-fidelity.js +176 -0
  52. package/lib/core/types.d.ts +12 -3
  53. package/lib/core/update-check.js +2 -4
  54. package/lib/core/validator.d.ts +9 -0
  55. package/lib/core/validator.js +12 -0
  56. package/lib/foreign/bundle.js +42 -3
  57. package/lib/foreign/claude-code.js +358 -26
  58. package/lib/foreign/claude-sessions.d.ts +51 -8
  59. package/lib/foreign/claude-sessions.js +111 -13
  60. package/lib/foreign/read-claude-code.d.ts +33 -4
  61. package/lib/foreign/read-claude-code.js +446 -65
  62. package/lib/foreign/read-claude-plugins.d.ts +36 -0
  63. package/lib/foreign/read-claude-plugins.js +403 -0
  64. package/lib/foreign/registry.js +25 -2
  65. package/lib/foreign/truth-table.js +6 -4
  66. package/lib/foreign/types.d.ts +170 -2
  67. package/lib/index.d.ts +14 -5
  68. package/lib/index.js +60 -16
  69. package/lib/market/git-file-writer.js +7 -1
  70. package/lib/profiles/dsh-profile-launcher.d.ts +20 -0
  71. package/lib/profiles/dsh-profile-launcher.js +58 -1
  72. package/lib/profiles/dsh-profile-shared.d.ts +8 -2
  73. package/lib/profiles/dsh-profile-shared.js +8 -2
  74. package/lib/routes/foreign.js +8 -1
  75. package/lib/routes/index.d.ts +1 -1
  76. package/lib/routes/kit.d.ts +2 -2
  77. package/lib/routes/sync.js +173 -9
  78. package/lib/schema/manifest.d.ts +6 -1
  79. package/lib/schema/manifest.js +55 -0
  80. package/lib/schema/types.d.ts +47 -2
  81. package/lib/sync/ancestor.d.ts +22 -0
  82. package/lib/sync/ancestor.js +66 -2
  83. package/lib/sync/autosync-config.d.ts +73 -0
  84. package/lib/sync/autosync-config.js +89 -0
  85. package/lib/sync/autosync-scheduler.d.ts +80 -6
  86. package/lib/sync/autosync-scheduler.js +263 -40
  87. package/lib/sync/gist/gist-transport.d.ts +18 -1
  88. package/lib/sync/gist/gist-transport.js +42 -3
  89. package/lib/sync/gist/messages.d.ts +2 -0
  90. package/lib/sync/gist/messages.js +5 -0
  91. package/lib/sync/git/git-transport.d.ts +42 -3
  92. package/lib/sync/git/git-transport.js +146 -3
  93. package/lib/sync/merge.d.ts +47 -14
  94. package/lib/sync/merge.js +242 -222
  95. package/lib/sync/messages.d.ts +8 -0
  96. package/lib/sync/messages.js +19 -0
  97. package/lib/sync/risk.d.ts +19 -2
  98. package/lib/sync/risk.js +10 -3
  99. package/lib/sync/s3/messages.d.ts +6 -0
  100. package/lib/sync/s3/messages.js +13 -0
  101. package/lib/sync/s3/s3-transport.d.ts +9 -1
  102. package/lib/sync/s3/s3-transport.js +78 -6
  103. package/lib/sync/s3/sigv4.js +11 -2
  104. package/lib/sync/sync-engine.d.ts +212 -8
  105. package/lib/sync/sync-engine.js +514 -58
  106. package/lib/sync/sync-history.d.ts +11 -0
  107. package/lib/sync/sync-history.js +20 -0
  108. package/lib/sync/sync-state.d.ts +43 -0
  109. package/lib/sync/sync-state.js +44 -0
  110. package/lib/sync/sync-watcher.d.ts +76 -0
  111. package/lib/sync/sync-watcher.js +202 -0
  112. package/lib/sync/text-merge.d.ts +85 -0
  113. package/lib/sync/text-merge.js +513 -0
  114. package/lib/sync/transport.d.ts +48 -0
  115. package/lib/sync/transport.js +12 -0
  116. package/lib/sync/watch-scope.d.ts +38 -0
  117. package/lib/sync/watch-scope.js +95 -0
  118. package/lib/sync/webdav/webdav-transport.d.ts +16 -2
  119. package/lib/sync/webdav/webdav-transport.js +128 -11
  120. package/lib/ui/foreign-view.js +10 -0
  121. package/lib/ui/i18n.d.ts +9 -0
  122. package/lib/ui/i18n.js +22 -0
  123. package/lib/ui/rollback-view.d.ts +12 -0
  124. package/lib/ui/rollback-view.js +2 -0
  125. package/lib/ui/sync-settings-view.d.ts +47 -0
  126. package/lib/ui/sync-settings-view.js +24 -0
  127. package/lib/ui/types.d.ts +21 -0
  128. package/lib/utils/env-lock.d.ts +1 -0
  129. package/lib/utils/env-lock.js +7 -4
  130. package/package.json +6 -6
  131. package/src/adapters/credentials.ts +22 -4
  132. package/src/adapters/file-collection.ts +2 -1
  133. package/src/adapters/mcp.test.ts +235 -1
  134. package/src/adapters/mcp.ts +276 -5
  135. package/src/adapters/messages.ts +7 -0
  136. package/src/adapters/plugin-files.ts +2 -1
  137. package/src/adapters/plugins.test.ts +176 -2
  138. package/src/adapters/plugins.ts +107 -21
  139. package/src/adapters/pnpm-workspace.test.ts +89 -1
  140. package/src/adapters/pnpm-workspace.ts +127 -2
  141. package/src/adapters/prompts.ts +20 -3
  142. package/src/adapters/providers.ts +62 -4
  143. package/src/adapters/settings.ts +16 -3
  144. package/src/adapters/skills.test.ts +141 -0
  145. package/src/adapters/skills.ts +58 -2
  146. package/src/adapters/ui.ts +16 -3
  147. package/src/adapters/units-entries.test.ts +190 -0
  148. package/src/adapters/units.ts +50 -0
  149. package/src/adapters/workspaces.ts +2 -1
  150. package/src/client/about/PluginUpdateDialog.tsx +4 -2
  151. package/src/client/about/ReleaseNotesDialog.tsx +4 -2
  152. package/src/client/chat/RollbackReviewDialog.tsx +3 -2
  153. package/src/client/common/Skeleton.tsx +32 -0
  154. package/src/client/common/api-base.test.ts +36 -0
  155. package/src/client/common/http.test.ts +12 -1
  156. package/src/client/common/http.ts +8 -2
  157. package/src/client/common/routes.ts +46 -3
  158. package/src/client/config-manager.module.css +321 -11
  159. package/src/client/home/HomePanel.tsx +14 -6
  160. package/src/client/import/ForeignImportView.tsx +3 -1
  161. package/src/client/import/import-wizard-steps.tsx +2 -1
  162. package/src/client/library/ArtifactRow.tsx +12 -2
  163. package/src/client/library/LibraryActions.tsx +6 -3
  164. package/src/client/library/LibraryPanel.tsx +21 -3
  165. package/src/client/library/RestorePlanView.tsx +4 -2
  166. package/src/client/locales.ts +3 -0
  167. package/src/client/market/market-api.test.ts +3 -2
  168. package/src/client/recovery/RecoveryPanel.tsx +54 -7
  169. package/src/client/recovery/RollbackCard.tsx +7 -2
  170. package/src/client/recovery/RollbackDiffPane.tsx +3 -2
  171. package/src/client/recovery/recovery-locales.ts +20 -2
  172. package/src/client/recovery/recovery-view.test.ts +39 -1
  173. package/src/client/recovery/recovery-view.ts +23 -1
  174. package/src/client/run-store.test.ts +1 -1
  175. package/src/client/run-store.ts +4 -0
  176. package/src/client/sync/AutosyncCard.tsx +49 -6
  177. package/src/client/sync/MergeModal.tsx +220 -0
  178. package/src/client/sync/MergePreviewView.tsx +306 -0
  179. package/src/client/sync/SyncActivityBar.tsx +31 -0
  180. package/src/client/sync/SyncConfirmView.tsx +2 -1
  181. package/src/client/sync/SyncPanel.tsx +130 -87
  182. package/src/client/sync/SyncProbeRow.tsx +162 -0
  183. package/src/client/sync/SyncSettingsView.test.ts +27 -2
  184. package/src/client/sync/WorkDirCard.tsx +44 -6
  185. package/src/client/sync/history-model.ts +4 -1
  186. package/src/client/sync/merge-view.test.ts +248 -0
  187. package/src/client/sync/merge-view.ts +398 -0
  188. package/src/client/sync/probe-view.test.ts +90 -0
  189. package/src/client/sync/probe-view.ts +63 -0
  190. package/src/client/sync/remote-device-view.test.ts +35 -0
  191. package/src/client/sync/remote-device-view.ts +25 -0
  192. package/src/client/sync/sync-api.test.ts +5 -3
  193. package/src/client/sync/sync-api.ts +212 -2
  194. package/src/client/sync/sync-entry.test.ts +78 -0
  195. package/src/client/sync/sync-entry.ts +53 -0
  196. package/src/client/sync/sync-locales.ts +234 -2
  197. package/src/client/sync/sync-view.test.ts +18 -1
  198. package/src/client/sync/sync-view.ts +20 -0
  199. package/src/core/backup.test.ts +41 -0
  200. package/src/core/backup.ts +20 -0
  201. package/src/core/mcp-connector-store.test.ts +265 -0
  202. package/src/core/mcp-connector-store.ts +516 -0
  203. package/src/core/messages-core.ts +4 -0
  204. package/src/core/messages.ts +46 -0
  205. package/src/core/phase3-host.ts +18 -1
  206. package/src/core/phase3-production-integration.test.ts +4 -0
  207. package/src/core/plugin-cli.test.ts +26 -0
  208. package/src/core/plugin-cli.ts +30 -4
  209. package/src/core/recovery-orchestrator.test.ts +104 -5
  210. package/src/core/recovery-orchestrator.ts +74 -17
  211. package/src/core/rollback-activity.test.ts +311 -0
  212. package/src/core/rollback-activity.ts +367 -0
  213. package/src/core/rollback-cross-session.test.ts +694 -0
  214. package/src/core/rollback-interrupted.test.ts +137 -0
  215. package/src/core/rollback-ledger.ts +32 -0
  216. package/src/core/rollback-merge.test.ts +130 -0
  217. package/src/core/rollback-merge.ts +78 -14
  218. package/src/core/rollback-point.ts +468 -91
  219. package/src/core/rollback-undo-journal.test.ts +508 -0
  220. package/src/core/rollback-undo-journal.ts +571 -0
  221. package/src/core/self-update.ts +2 -5
  222. package/src/core/text-fidelity.test.ts +189 -0
  223. package/src/core/text-fidelity.ts +197 -0
  224. package/src/core/types.ts +11 -3
  225. package/src/core/update-check.ts +2 -5
  226. package/src/core/validator.ts +14 -0
  227. package/src/foreign/bundle-origin.test.ts +265 -0
  228. package/src/foreign/bundle.ts +45 -5
  229. package/src/foreign/claude-code.test.ts +268 -0
  230. package/src/foreign/claude-code.ts +406 -25
  231. package/src/foreign/claude-plugins.test.ts +234 -0
  232. package/src/foreign/claude-sessions.test.ts +141 -0
  233. package/src/foreign/claude-sessions.ts +179 -23
  234. package/src/foreign/file-budget.test.ts +4 -1
  235. package/src/foreign/read-claude-code.test.ts +320 -0
  236. package/src/foreign/read-claude-code.ts +490 -71
  237. package/src/foreign/read-claude-plugins.ts +465 -0
  238. package/src/foreign/registry.ts +25 -2
  239. package/src/foreign/session-ir.test.ts +65 -0
  240. package/src/foreign/truth-table.test.ts +81 -0
  241. package/src/foreign/truth-table.ts +6 -3
  242. package/src/foreign/types.ts +162 -2
  243. package/src/index.facade.test.ts +26 -4
  244. package/src/index.ts +76 -19
  245. package/src/market/git-file-writer.test.ts +4 -0
  246. package/src/market/git-file-writer.ts +7 -1
  247. package/src/profiles/dsh-profile-launcher.test.ts +68 -3
  248. package/src/profiles/dsh-profile-launcher.ts +56 -1
  249. package/src/profiles/dsh-profile-shared.ts +8 -2
  250. package/src/routes/foreign.ts +8 -1
  251. package/src/routes/index.ts +1 -1
  252. package/src/routes/kit.ts +2 -2
  253. package/src/routes/sync.ts +176 -9
  254. package/src/schema/manifest-schema.test.ts +87 -2
  255. package/src/schema/manifest.ts +57 -1
  256. package/src/schema/types.ts +45 -2
  257. package/src/sync/ancestor.ts +76 -2
  258. package/src/sync/autosync-config.test.ts +73 -1
  259. package/src/sync/autosync-config.ts +124 -0
  260. package/src/sync/autosync-risk-tier.test.ts +38 -2
  261. package/src/sync/autosync-scheduler.test.ts +264 -12
  262. package/src/sync/autosync-scheduler.ts +296 -44
  263. package/src/sync/gist/gist-conditional-write.test.ts +203 -0
  264. package/src/sync/gist/gist-transport.ts +53 -5
  265. package/src/sync/gist/messages.ts +5 -0
  266. package/src/sync/git/git-probe.test.ts +80 -0
  267. package/src/sync/git/git-transport.test.ts +185 -0
  268. package/src/sync/git/git-transport.ts +178 -5
  269. package/src/sync/merge.test.ts +188 -55
  270. package/src/sync/merge.ts +282 -224
  271. package/src/sync/messages.ts +19 -0
  272. package/src/sync/risk.ts +29 -5
  273. package/src/sync/s3/messages.ts +13 -0
  274. package/src/sync/s3/s3-conditional-write.test.ts +330 -0
  275. package/src/sync/s3/s3-transport.test.ts +2 -1
  276. package/src/sync/s3/s3-transport.ts +87 -9
  277. package/src/sync/s3/sigv4.test.ts +4 -1
  278. package/src/sync/s3/sigv4.ts +11 -2
  279. package/src/sync/sync-device-identity.test.ts +267 -0
  280. package/src/sync/sync-engine.test.ts +410 -19
  281. package/src/sync/sync-engine.ts +602 -57
  282. package/src/sync/sync-history.test.ts +16 -1
  283. package/src/sync/sync-history.ts +26 -0
  284. package/src/sync/sync-state.test.ts +64 -0
  285. package/src/sync/sync-state.ts +79 -0
  286. package/src/sync/sync-watcher.test.ts +197 -0
  287. package/src/sync/sync-watcher.ts +246 -0
  288. package/src/sync/text-merge.test.ts +358 -0
  289. package/src/sync/text-merge.ts +554 -0
  290. package/src/sync/transport.ts +57 -0
  291. package/src/sync/watch-scope.test.ts +122 -0
  292. package/src/sync/watch-scope.ts +120 -0
  293. package/src/sync/webdav/webdav-conditional-write.test.ts +360 -0
  294. package/src/sync/webdav/webdav-transport.ts +137 -14
  295. package/src/ui/foreign-view.test.ts +34 -0
  296. package/src/ui/foreign-view.ts +10 -0
  297. package/src/ui/i18n.ts +22 -0
  298. package/src/ui/rollback-view.ts +14 -0
  299. package/src/ui/sync-settings-view.test.ts +28 -1
  300. package/src/ui/sync-settings-view.ts +43 -0
  301. package/src/ui/types.ts +22 -0
  302. package/src/utils/env-lock.ts +8 -5
@@ -0,0 +1,311 @@
1
+ /**
2
+ * 会话活跃时间窗(2026-10-18 定案:写入归属的**时间维度**)单元测试。
3
+ *
4
+ * 八条判据逐条钉住:合并/切分、乱序、多会话交错、落盘往返与原子写、跨进程并集、
5
+ * 损坏只 warn、上限与 fail-closed 输入、flush 幂等与 dispose。
6
+ * 所有用例只碰 os.tmpdir() 下的临时目录,绝不写真实用户数据。
7
+ */
8
+ import { test } from 'node:test'
9
+ import assert from 'node:assert/strict'
10
+ import fs from 'node:fs/promises'
11
+ import os from 'node:os'
12
+ import path from 'node:path'
13
+ import { setTimeout as delay } from 'node:timers/promises'
14
+
15
+ import {
16
+ ROLLBACK_ACTIVITY_DEFAULT_GAP_MS,
17
+ ROLLBACK_ACTIVITY_FILE_NAME,
18
+ ROLLBACK_ACTIVITY_MAX_INTERVALS,
19
+ RollbackSessionActivity,
20
+ type RollbackActivityInterval,
21
+ } from './rollback-activity.ts'
22
+
23
+ interface ActivityPayload {
24
+ version: number
25
+ intervals: RollbackActivityInterval[]
26
+ }
27
+
28
+ /** 只在临时目录里建实例(文件可能被写到,绝不指向真实数据) */
29
+ async function withTempDir<T>(run: (dir: string, file: string) => Promise<T>): Promise<T> {
30
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'activity-'))
31
+ try {
32
+ return await run(dir, path.join(dir, 'activity.json'))
33
+ } finally {
34
+ await fs.rm(dir, { recursive: true, force: true })
35
+ }
36
+ }
37
+
38
+ /** 读落盘载荷(测试里要逐字段核对,文件里才是真相) */
39
+ async function readPayload(file: string): Promise<ActivityPayload> {
40
+ return JSON.parse(await fs.readFile(file, 'utf8')) as ActivityPayload
41
+ }
42
+
43
+ /** 等定时落盘:轮询到文件出现为止(有上限,绝不死等) */
44
+ async function waitForFile(file: string, timeoutMs = 2000): Promise<string> {
45
+ const deadline = Date.now() + timeoutMs
46
+ for (;;) {
47
+ try {
48
+ return await fs.readFile(file, 'utf8')
49
+ } catch (error) {
50
+ if ((error as { code?: unknown }).code !== 'ENOENT' || Date.now() > deadline) throw error
51
+ await delay(10)
52
+ }
53
+ }
54
+ }
55
+
56
+ test('A-1 同一会话:间隔 <= gapMs 合并成一条,超过就新开;from/to 单调', async () => {
57
+ await withTempDir(async (_dir, file) => {
58
+ const activity = new RollbackSessionActivity({ file, gapMs: 1000, flushDelayMs: 0 })
59
+ activity.noteActivity('s', 0)
60
+ activity.noteActivity('s', 500)
61
+ activity.noteActivity('s', 1500) // 1500 - 500 = 1000 === gapMs ⇒ 并入(判据是「<=」)
62
+ assert.equal(activity.size, 1, '间隔恰好等于 gapMs 必须合并')
63
+ assert.equal(activity.lastActivityAt('s'), 1500)
64
+ assert.deepEqual(activity.sessionsActiveAt(800), ['s'])
65
+
66
+ activity.noteActivity('s', 2501) // 2501 - 1500 = 1001 > gapMs ⇒ 新开一条
67
+ assert.equal(activity.size, 2, '超过 gapMs 必须新开一条窗口')
68
+ assert.deepEqual(activity.sessionsActiveAt(2500), [], '两条窗口之间的空档不算活跃')
69
+
70
+ await activity.flush()
71
+ const payload = await readPayload(file)
72
+ assert.equal(payload.version, 1)
73
+ assert.deepEqual(payload.intervals, [
74
+ { sessionId: 's', from: 0, to: 1500 },
75
+ { sessionId: 's', from: 2501, to: 2501 },
76
+ ])
77
+ })
78
+ })
79
+
80
+ test('A-2 乱序到达(时钟回拨):插入后仍按 from 升序、互不相接', async () => {
81
+ await withTempDir(async (_dir, file) => {
82
+ const activity = new RollbackSessionActivity({ file, gapMs: 100, flushDelayMs: 0 })
83
+ activity.noteActivity('s', 5000)
84
+ activity.noteActivity('s', 100) // 早于已有窗口 ⇒ 新开一条且排在前
85
+ activity.noteActivity('s', 950) // 与 5000 的缝 4050 > 100 ⇒ 独立;与 100 的缝 850 > 100 ⇒ 独立
86
+ await activity.flush()
87
+ assert.deepEqual((await readPayload(file)).intervals, [
88
+ { sessionId: 's', from: 100, to: 100 },
89
+ { sessionId: 's', from: 950, to: 950 },
90
+ { sessionId: 's', from: 5000, to: 5000 },
91
+ ])
92
+ // 乱序不能把缝隙填上(填上就等于凭空多出一段「活跃」)
93
+ assert.deepEqual(activity.sessionsActiveAt(3000), [])
94
+ })
95
+ })
96
+
97
+ test('A-3 多会话交错:只返回窗口覆盖该时刻的会话,按 id 升序去重', async () => {
98
+ const activity = new RollbackSessionActivity({ file: path.join(os.tmpdir(), 'activity-never-written.json'), gapMs: 100, flushDelayMs: 0 })
99
+ activity.noteActivity('s-b', 50)
100
+ activity.noteActivity('s-b', 60)
101
+ activity.noteActivity('s-a', 0)
102
+ activity.noteActivity('s-a', 100) // 100 - 0 === gapMs ⇒ 并成 [0, 100]
103
+
104
+ assert.deepEqual(activity.sessionsActiveAt(55), ['s-a', 's-b'])
105
+ assert.deepEqual(activity.sessionsActiveAt(70), ['s-a'], '窗口之外的会话绝不能被返回')
106
+ assert.deepEqual(activity.sessionsActiveAt(200), [])
107
+ assert.deepEqual(activity.sessionsActiveAt(105, 5), ['s-a'], '容忍度只放宽边界,不虚构窗口')
108
+ assert.deepEqual(activity.sessionsActiveAt(111, 5), [])
109
+ assert.deepEqual(activity.sessionsActiveAt(Number.NaN), [])
110
+
111
+ assert.equal(activity.isActiveAt('s-b', 55), true)
112
+ assert.equal(activity.isActiveAt('s-b', 100), false)
113
+ assert.equal(activity.isActiveAt('missing', 55), false)
114
+ assert.equal(activity.lastActivityAt('s-a'), 100)
115
+ assert.equal(activity.lastActivityAt('missing'), null, '没记过 = null,绝不是 0')
116
+ assert.equal(activity.hasDataFor('s-b'), true)
117
+ assert.equal(activity.hasDataFor('s-c'), false, '未采集 ≠ 无活动,必须能区分')
118
+ })
119
+
120
+ test('A-4 落盘 / 回读往返逐字段一致,且原子写不留 .tmp', async () => {
121
+ await withTempDir(async (_dir, file) => {
122
+ const activity = new RollbackSessionActivity({ file, gapMs: 100, flushDelayMs: 0 })
123
+ activity.noteActivity('x', 10)
124
+ activity.noteActivity('x', 50)
125
+ activity.noteActivity('y', 120)
126
+ await activity.flush()
127
+
128
+ const reread = new RollbackSessionActivity({ file, gapMs: 100 })
129
+ await reread.load()
130
+ assert.equal(reread.size, activity.size)
131
+ assert.equal(reread.lastActivityAt('x'), 50)
132
+ assert.equal(reread.lastActivityAt('y'), 120)
133
+ assert.deepEqual(reread.sessionsActiveAt(30), ['x'])
134
+ assert.deepEqual(reread.sessionsActiveAt(25), ['x'])
135
+ assert.deepEqual(reread.sessionsActiveAt(200), [])
136
+ await assert.rejects(fs.stat(file + '.tmp'), '原子写不得留下临时文件')
137
+ })
138
+ })
139
+
140
+ test('A-5 写前先读:两个实例各写同一个文件,谁的记录都不能被抹掉', async () => {
141
+ await withTempDir(async (_dir, file) => {
142
+ const one = new RollbackSessionActivity({ file, gapMs: 1000, flushDelayMs: 0 })
143
+ one.noteActivity('s-1', 1000)
144
+ one.noteActivity('m', 0)
145
+ await one.flush()
146
+
147
+ // 第二个实例**故意不 load**:它只知道自己见过的活动
148
+ const two = new RollbackSessionActivity({ file, gapMs: 1000, flushDelayMs: 0 })
149
+ two.noteActivity('s-2', 2000)
150
+ two.noteActivity('m', 500) // 与磁盘上 m 的 [0,0] 缝 500 <= gapMs ⇒ 并集后应合成一条
151
+ await two.flush()
152
+
153
+ const three = new RollbackSessionActivity({ file, gapMs: 1000, flushDelayMs: 0 })
154
+ await three.load()
155
+ assert.equal(three.hasDataFor('s-1'), true, '先写者的记录必须还在')
156
+ assert.equal(three.hasDataFor('s-2'), true, '后写者也不得覆盖式丢掉对方的窗口')
157
+ assert.deepEqual(three.sessionsActiveAt(1000), ['s-1'])
158
+ assert.deepEqual(three.sessionsActiveAt(2000), ['s-2'])
159
+ assert.deepEqual(three.sessionsActiveAt(250), ['m'], '两个进程各自的 m 窗口应并成一条')
160
+ assert.deepEqual((await readPayload(file)).intervals, [
161
+ { sessionId: 'm', from: 0, to: 500 },
162
+ { sessionId: 's-1', from: 1000, to: 1000 },
163
+ { sessionId: 's-2', from: 2000, to: 2000 },
164
+ ])
165
+ })
166
+ })
167
+
168
+ test('A-6 损坏 / 形状不符 / 写失败 ⇒ 只 warn,内存数据仍可用,绝不抛', async () => {
169
+ await withTempDir(async (dir, file) => {
170
+ const warnings: string[] = []
171
+ // ① 非法 JSON
172
+ await fs.writeFile(file, '{not json', 'utf8')
173
+ const a = new RollbackSessionActivity({ file, flushDelayMs: 0, warn: (line) => warnings.push(line) })
174
+ a.noteActivity('live', 5) // 读盘之前先记一条 ⇒ 内存数据必须活下来
175
+ await a.load()
176
+ assert.equal(a.size, 1, '读盘失败不得清空内存')
177
+ assert.deepEqual(a.sessionsActiveAt(5), ['live'])
178
+ assert.equal(warnings.length, 1)
179
+
180
+ // ② 版本 / 形状不符 = 空表(绝不猜字段含义)
181
+ await fs.writeFile(file, JSON.stringify({ version: 2, intervals: [{ sessionId: 'x', from: 1, to: 2 }] }), 'utf8')
182
+ const b = new RollbackSessionActivity({ file, flushDelayMs: 0, warn: (line) => warnings.push(line) })
183
+ await b.load()
184
+ assert.equal(b.size, 0)
185
+
186
+ await fs.writeFile(file, JSON.stringify({ version: 1, intervals: 'nope' }), 'utf8')
187
+ const b2 = new RollbackSessionActivity({ file, flushDelayMs: 0 })
188
+ await b2.load()
189
+ assert.equal(b2.size, 0)
190
+
191
+ // ③ 单条字段形状不符 ⇒ 跳过该条,其余照收
192
+ await fs.writeFile(file, JSON.stringify({
193
+ version: 1,
194
+ intervals: [
195
+ { sessionId: 'ok', from: 1, to: 2 },
196
+ { sessionId: '', from: 1, to: 2 },
197
+ { sessionId: 'bad', from: '1', to: 2 },
198
+ { sessionId: 'bad', from: 5, to: 2 },
199
+ { sessionId: 'bad', from: -1, to: 2 },
200
+ null,
201
+ ],
202
+ }), 'utf8')
203
+ const c = new RollbackSessionActivity({ file, flushDelayMs: 0 })
204
+ await c.load()
205
+ assert.equal(c.size, 1)
206
+ assert.deepEqual(c.sessionsActiveAt(1), ['ok'])
207
+
208
+ // ④ 文件不存在 = 正常(不 warn)
209
+ const quiet: string[] = []
210
+ const d = new RollbackSessionActivity({ file: path.join(dir, 'missing.json'), flushDelayMs: 0, warn: (line) => quiet.push(line) })
211
+ await d.load()
212
+ assert.equal(d.size, 0)
213
+ assert.deepEqual(quiet, [], 'ENOENT 是「还没写过」,不是错误')
214
+
215
+ // ⑤ 落盘失败(目标路径上是个目录)⇒ warn,不上抛,内存照旧
216
+ const blocked = path.join(dir, 'as-file')
217
+ await fs.mkdir(path.join(blocked, 'activity.json'), { recursive: true })
218
+ const failed: string[] = []
219
+ const e = new RollbackSessionActivity({ file: path.join(blocked, 'activity.json'), flushDelayMs: 0, warn: (line) => failed.push(line) })
220
+ e.noteActivity('s', 1)
221
+ await e.flush()
222
+ assert.equal(failed.some((line) => line.includes('write failed')), true, '写不进去必须留痕')
223
+ assert.deepEqual(e.sessionsActiveAt(1), ['s'])
224
+ })
225
+ })
226
+
227
+ test('A-7 条目上限淘汰最旧;空 id / 非有限 / 负数时间一律忽略(fail-closed)', async () => {
228
+ const activity = new RollbackSessionActivity({
229
+ file: path.join(os.tmpdir(), 'activity-never-written.json'),
230
+ gapMs: 10,
231
+ maxIntervals: 2,
232
+ flushDelayMs: 0,
233
+ })
234
+ activity.noteActivity('old', 100)
235
+ activity.noteActivity('mid', 200)
236
+ activity.noteActivity('new', 300)
237
+ assert.equal(activity.size, 2, '超出上限必须淘汰')
238
+ assert.equal(activity.hasDataFor('old'), false, '最旧的先走')
239
+ assert.equal(activity.hasDataFor('mid'), true)
240
+ assert.equal(activity.hasDataFor('new'), true)
241
+
242
+ const before = activity.size
243
+ activity.noteActivity('', 400)
244
+ activity.noteActivity(' ', 400)
245
+ activity.noteActivity('s', Number.NaN)
246
+ activity.noteActivity('s', Number.POSITIVE_INFINITY)
247
+ activity.noteActivity('s', Number.NEGATIVE_INFINITY)
248
+ activity.noteActivity('s', -1)
249
+ assert.equal(activity.size, before, '非法输入一律不得进表')
250
+ assert.equal(activity.hasDataFor('s'), false)
251
+ assert.equal(activity.lastActivityAt('s'), null)
252
+ })
253
+
254
+ test('A-8 flush 幂等:无未落盘数据时不重复写;dispose 只写还没落盘的部分', async () => {
255
+ await withTempDir(async (_dir, file) => {
256
+ const activity = new RollbackSessionActivity({ file, gapMs: 10, flushDelayMs: 0 })
257
+ await activity.flush()
258
+ await assert.rejects(fs.stat(file), '没有数据就不该建文件')
259
+
260
+ activity.noteActivity('s', 1)
261
+ await Promise.all([activity.flush(), activity.flush()]) // 并发调用共用同一个在飞写入
262
+ assert.deepEqual((await readPayload(file)).intervals, [{ sessionId: 's', from: 1, to: 1 }])
263
+
264
+ // 落盘后再无新数据 ⇒ 删掉文件也不会被重建(证明 flush 没有重复写)
265
+ await fs.rm(file)
266
+ await activity.flush()
267
+ await assert.rejects(fs.stat(file), '没有未落盘数据时 flush 绝不写盘')
268
+
269
+ // dispose:还有没落盘的活动 ⇒ 关停时写下去
270
+ activity.noteActivity('s', 2)
271
+ await activity.dispose()
272
+ assert.deepEqual((await readPayload(file)).intervals, [{ sessionId: 's', from: 1, to: 2 }])
273
+
274
+ // 再 dispose 一次:无新数据 ⇒ 不重复写(删掉文件后依然不会被重建)
275
+ await fs.rm(file)
276
+ await activity.dispose()
277
+ await assert.rejects(fs.stat(file), 'dispose 无未落盘数据时不得重复写')
278
+ })
279
+ })
280
+
281
+ test('A-9 debounce:多次 noteActivity 只落一次盘(定时器触发)', async () => {
282
+ await withTempDir(async (_dir, file) => {
283
+ const activity = new RollbackSessionActivity({ file, gapMs: 10, flushDelayMs: 20 })
284
+ activity.noteActivity('s', 7)
285
+ activity.noteActivity('s', 9)
286
+ activity.scheduleFlush()
287
+ activity.scheduleFlush()
288
+ const text = await waitForFile(file) // 只能由定时器写出来(测试从未调用 flush)
289
+ const payload = JSON.parse(text) as ActivityPayload
290
+ assert.deepEqual(payload.intervals, [{ sessionId: 's', from: 7, to: 9 }])
291
+ await activity.dispose()
292
+ })
293
+ })
294
+
295
+ test('A-10 缺省口径:gapMs 缺省 10 分钟、时间戳缺省取注入时钟、上限常量有限', () => {
296
+ assert.equal(ROLLBACK_ACTIVITY_FILE_NAME, 'activity.json')
297
+ assert.equal(ROLLBACK_ACTIVITY_DEFAULT_GAP_MS, 10 * 60 * 1000)
298
+ assert.equal(Number.isFinite(ROLLBACK_ACTIVITY_MAX_INTERVALS) && ROLLBACK_ACTIVITY_MAX_INTERVALS > 0, true)
299
+
300
+ const activity = new RollbackSessionActivity({
301
+ file: path.join(os.tmpdir(), 'activity-never-written.json'),
302
+ now: () => 12345,
303
+ flushDelayMs: 0,
304
+ })
305
+ activity.noteActivity('s') // 不传 at ⇒ 用注入时钟
306
+ assert.equal(activity.lastActivityAt('s'), 12345)
307
+ activity.noteActivity('s', 12345 + ROLLBACK_ACTIVITY_DEFAULT_GAP_MS) // 恰好等于缺省缝 ⇒ 合并
308
+ assert.equal(activity.size, 1)
309
+ activity.noteActivity('s', 12345 + ROLLBACK_ACTIVITY_DEFAULT_GAP_MS * 2 + 1) // 超出 ⇒ 新开
310
+ assert.equal(activity.size, 2)
311
+ })
@@ -0,0 +1,367 @@
1
+ /**
2
+ * 会话**活跃时间窗**台账 —— 「某个时刻有哪些会话正在活着」。
3
+ *
4
+ * 为什么需要(2026-10-17 用户反馈 + 真机复盘):写入归属(`rollback-attribution.ts`)只能从
5
+ * `tool/call` 的 arguments 里抽 `file_path`;`pwsh` / `run_code` / 脚本 / 外部编辑器写文件时
6
+ * **没有可解析的路径参数** ⇒ 这些写入完全没有归属,回退时识别不出「别的对话刚写过」,
7
+ * 于是把它一起抹掉(真机:B 对话给 README 加了一行,回退 A 时那一行也没了)。
8
+ *
9
+ * 这里补的是**时间维度**:文件 mtime 若落在某个会话的活跃窗口内,该会话就是候选写入者 ——
10
+ * 与工具参数完全无关,对 bash / 脚本 / 外部编辑器同样有效。
11
+ *
12
+ * ⚠️ 边界(不得越界解读):时间窗只能证明「该会话在那时候有活动」,**不能单独证明「就是它写的」**。
13
+ * 它是旁证,不是判决;引擎必须把它与归属台账合起来用(见 rollback-point.ts 的判定)。
14
+ *
15
+ * 三条不变量(不得放宽):
16
+ * ① 采集**尽力而为**:只记「会话 id + 时间」,任何读写失败只 warn,绝不参与留点 / 回退的成败判定。
17
+ * ② 窗口只增不减:同一会话的多次活动按 gapMs 合并;落盘前先读旧文件做**并集**,
18
+ * 两个 DSH 进程同写一个文件时绝不互相抹掉(落盘纪律照抄 `rollback-attribution.ts`)。
19
+ * ③ 形状不符 = 空表:读不出来时宁可少一个窗口,也绝不猜字段含义(猜错会让回退误判归属)。
20
+ */
21
+ import fs from 'node:fs/promises'
22
+ import path from 'node:path'
23
+
24
+ /** 台账文件名(与回滚点库同目录:dataDir/rollback-points/activity.json) */
25
+ export const ROLLBACK_ACTIVITY_FILE_NAME = 'activity.json'
26
+ /** 同一会话两条活动的间隔 <= 该值就并成一个窗口(缺省 10 分钟) */
27
+ export const ROLLBACK_ACTIVITY_DEFAULT_GAP_MS = 10 * 60 * 1000
28
+ /** 窗口条目上限(超出按 to 淘汰最旧)—— 无界增长会把宿主拖垮(见 issue #89 的教训) */
29
+ export const ROLLBACK_ACTIVITY_MAX_INTERVALS = 20000
30
+
31
+ /** 一条会话活跃窗口:会话 sessionId 在 [from, to] 之间有过活动 */
32
+ export interface RollbackActivityInterval {
33
+ sessionId: string
34
+ from: number
35
+ to: number
36
+ }
37
+
38
+ /** 落盘载荷形状(形状不符一律当空表:读不出 ≠ 某会话活跃) */
39
+ interface ActivityFileV1 {
40
+ version: 1
41
+ intervals: RollbackActivityInterval[]
42
+ }
43
+
44
+ export interface RollbackActivityOptions {
45
+ /** 台账文件绝对路径(引擎传 dataDir/rollback-points/activity.json) */
46
+ file: string
47
+ /** 缺省时钟;`noteActivity` 不传 at 时用它(事件时间优先,取数时刻只是兜底) */
48
+ now?: () => number
49
+ /** 同一会话两条活动间隔 <= gapMs 就合并(缺省 ROLLBACK_ACTIVITY_DEFAULT_GAP_MS) */
50
+ gapMs?: number
51
+ /** 窗口总条数上限(缺省 ROLLBACK_ACTIVITY_MAX_INTERVALS),超出淘汰最旧的 */
52
+ maxIntervals?: number
53
+ /** 落盘延迟(毫秒);<= 0 表示不自动落盘(测试用) */
54
+ flushDelayMs?: number
55
+ /** 读写失败只 warn,绝不抛 */
56
+ warn?: (line: string) => void
57
+ }
58
+
59
+ /** 按会话分组的窗口表(key = sessionId;value 按 from 升序、**互不相接**:任意两条的缝 > gapMs) */
60
+ type ActivityTable = Map<string, RollbackActivityInterval[]>
61
+
62
+ /** 读一条窗口;形状不符返回 null(跳过,不猜) */
63
+ function readInterval(value: unknown): RollbackActivityInterval | null {
64
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return null
65
+ const record = value as Record<string, unknown>
66
+ const sessionId = record.sessionId
67
+ const from = record.from
68
+ const to = record.to
69
+ if (typeof sessionId !== 'string' || sessionId === '') return null
70
+ if (typeof from !== 'number' || !Number.isFinite(from) || from < 0) return null
71
+ if (typeof to !== 'number' || !Number.isFinite(to) || to < 0) return null
72
+ if (to < from) return null
73
+ return { sessionId, from, to }
74
+ }
75
+
76
+ /**
77
+ * 把一条窗口 [from, to] 并进窗口表(**纯函数**;落盘的「写前先读」也复用它)。
78
+ *
79
+ * 合并口径:与已有窗口的距离 <= gapMs 就并成一条(含重叠、相接);
80
+ * 表内窗口按 from 升序且互不相接,所以「向后找不到可并的」即可停 —— 更早的窗口 to 只会更小。
81
+ */
82
+ function mergeRangeInto(table: ActivityTable, gapMs: number, sessionId: string, from: number, to: number): void {
83
+ let list = table.get(sessionId)
84
+ if (list === undefined) {
85
+ list = []
86
+ table.set(sessionId, list)
87
+ }
88
+ let hit = -1
89
+ for (let i = list.length - 1; i >= 0; i -= 1) {
90
+ const iv = list[i]
91
+ if (iv === undefined) continue
92
+ if (from <= iv.to + gapMs && to >= iv.from - gapMs) {
93
+ hit = i
94
+ break
95
+ }
96
+ if (from > iv.to + gapMs) break
97
+ }
98
+ if (hit === -1) {
99
+ // 新开一条,按 from 升序插入(时钟回拨 / 乱序事件也不能让表失序)
100
+ let pos = list.length
101
+ while (pos > 0) {
102
+ const prev = list[pos - 1]
103
+ if (prev === undefined || prev.from <= from) break
104
+ pos -= 1
105
+ }
106
+ list.splice(pos, 0, { sessionId, from, to })
107
+ return
108
+ }
109
+ const target = list[hit]
110
+ if (target === undefined) return
111
+ target.from = Math.min(target.from, from)
112
+ target.to = Math.max(target.to, to)
113
+ // 扩张后可能把左右邻居也拉进 gapMs 内 ⇒ 一并吸收,维持「互不相接」不变量
114
+ let index = hit
115
+ while (index > 0) {
116
+ const left = list[index - 1]
117
+ const right = list[index]
118
+ if (left === undefined || right === undefined || right.from - left.to > gapMs) break
119
+ left.from = Math.min(left.from, right.from)
120
+ left.to = Math.max(left.to, right.to)
121
+ list.splice(index, 1)
122
+ index -= 1
123
+ }
124
+ while (index + 1 < list.length) {
125
+ const left = list[index]
126
+ const right = list[index + 1]
127
+ if (left === undefined || right === undefined || right.from - left.to > gapMs) break
128
+ left.to = Math.max(left.to, right.to)
129
+ list.splice(index + 1, 1)
130
+ }
131
+ }
132
+
133
+ /** 窗口表 → 有稳定顺序的数组(先按会话 id,再按 from/to;落盘内容不随 Map 顺序抖动) */
134
+ function flattenIntervals(table: ActivityTable): RollbackActivityInterval[] {
135
+ const out: RollbackActivityInterval[] = []
136
+ for (const list of table.values()) {
137
+ for (const iv of list) out.push({ sessionId: iv.sessionId, from: iv.from, to: iv.to })
138
+ }
139
+ out.sort((a, b) => (a.sessionId < b.sessionId ? -1 : a.sessionId > b.sessionId ? 1 : a.from - b.from || a.to - b.to))
140
+ return out
141
+ }
142
+
143
+ /**
144
+ * 内存窗口表 + best-effort 落盘。
145
+ *
146
+ * 落盘的作用只有一个:**进程重启后**给旧会话回退时,活跃窗口还在(否则新进程只有本次启动后看到的窗口)。
147
+ * 因此写入是延迟合并的(一次会话事件一行,立即落盘会把 I/O 打成筛子)。
148
+ */
149
+ export class RollbackSessionActivity {
150
+ private readonly table: ActivityTable = new Map()
151
+ private readonly file: string
152
+ private readonly now: () => number
153
+ private readonly gapMs: number
154
+ private readonly maxIntervals: number
155
+ private readonly flushDelayMs: number
156
+ private readonly warn: (line: string) => void
157
+ private loadPromise: Promise<void> | null = null
158
+ private flushTimer: ReturnType<typeof setTimeout> | null = null
159
+ private writing: Promise<void> | null = null
160
+ /** 内存变更计数:与 lastFlushedRevision 相等 ⇒ 没有未落盘数据(flush 幂等靠它,不靠时间戳) */
161
+ private revision = 0
162
+ private lastFlushedRevision = 0
163
+
164
+ constructor(options: RollbackActivityOptions) {
165
+ this.file = path.resolve(options.file)
166
+ this.now = options.now ?? (() => Date.now())
167
+ const gap = options.gapMs ?? ROLLBACK_ACTIVITY_DEFAULT_GAP_MS
168
+ this.gapMs = Number.isFinite(gap) && gap >= 0 ? gap : ROLLBACK_ACTIVITY_DEFAULT_GAP_MS
169
+ const max = options.maxIntervals ?? ROLLBACK_ACTIVITY_MAX_INTERVALS
170
+ this.maxIntervals = Number.isFinite(max) && max >= 1 ? Math.floor(max) : ROLLBACK_ACTIVITY_MAX_INTERVALS
171
+ const delay = options.flushDelayMs ?? 1000
172
+ this.flushDelayMs = Number.isFinite(delay) ? delay : 1000
173
+ this.warn = options.warn ?? (() => {})
174
+ }
175
+
176
+ /** 窗口总条数(诊断与测试用;上限见 maxIntervals。等价于「条目」,不是会话数) */
177
+ get size(): number {
178
+ let total = 0
179
+ for (const list of this.table.values()) total += list.length
180
+ return total
181
+ }
182
+
183
+ /** 惰性读盘(幂等;读失败 = 空表,只 warn) */
184
+ async load(): Promise<void> {
185
+ if (this.loadPromise === null) this.loadPromise = this.read()
186
+ return this.loadPromise
187
+ }
188
+
189
+ private async read(): Promise<void> {
190
+ try {
191
+ const text = await fs.readFile(this.file, 'utf8')
192
+ const parsed = JSON.parse(text) as { version?: unknown; intervals?: unknown } | null
193
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed) || parsed.version !== 1 || !Array.isArray(parsed.intervals)) {
194
+ this.warn('rollback activity ignored (shape): ' + this.file)
195
+ return
196
+ }
197
+ for (const raw of parsed.intervals) {
198
+ const iv = readInterval(raw)
199
+ if (iv === null) continue
200
+ // 磁盘只是旧快照:**并集合并** ⇒ 读盘不得让本进程刚记的窗口消失(新记录优先)
201
+ mergeRangeInto(this.table, this.gapMs, iv.sessionId, iv.from, iv.to)
202
+ }
203
+ if (this.size > this.maxIntervals) this.evictOldest()
204
+ } catch (error) {
205
+ const code = (error as { code?: unknown }).code
206
+ // ENOENT = 还没写过台账(正常);其它错误如实留痕但不抛
207
+ if (code !== 'ENOENT') this.warn('rollback activity unreadable: ' + this.file + ' ' + String(code ?? error))
208
+ }
209
+ }
210
+
211
+ /**
212
+ * 记一次活动。窗口只增不减:与上一条间隔 <= gapMs 就并入同一条窗口,否则新开一条。
213
+ *
214
+ * `at` 缺省取注入时钟(`options.now`):引擎把**事件时间**显式传进来时以它为准
215
+ * (事件时间才是那次活动的真实时刻,取数时刻可能晚很多)。
216
+ * 空会话 id、非有限数字、负数一律**忽略**(fail-closed:宁可少一个窗口,绝不记错归属)。
217
+ */
218
+ noteActivity(sessionId: string, at: number = this.clockNow()): void {
219
+ if (typeof sessionId !== 'string' || sessionId.trim() === '') return
220
+ if (typeof at !== 'number' || !Number.isFinite(at) || at < 0) return
221
+ mergeRangeInto(this.table, this.gapMs, sessionId, at, at)
222
+ this.revision += 1
223
+ if (this.size > this.maxIntervals) this.evictOldest()
224
+ this.scheduleFlush()
225
+ }
226
+
227
+ /** 缺省时钟:注入的 now 抛错或给出非有限值时 fail-closed(返回 NaN,由调用方校验丢弃) */
228
+ private clockNow(): number {
229
+ try {
230
+ return this.now()
231
+ } catch {
232
+ return Number.NaN
233
+ }
234
+ }
235
+
236
+ /** 该会话最后一次活动时刻;没记过 = null(**不是 0**,0 会被误当成有效时间戳) */
237
+ lastActivityAt(sessionId: string): number | null {
238
+ const list = this.table.get(sessionId)
239
+ if (list === undefined || list.length === 0) return null
240
+ const last = list[list.length - 1]
241
+ return last === undefined ? null : last.to
242
+ }
243
+
244
+ /** 该会话是否有任何窗口(用于区分「确知没活动」与「根本没采到」) */
245
+ hasDataFor(sessionId: string): boolean {
246
+ const list = this.table.get(sessionId)
247
+ return list !== undefined && list.length > 0
248
+ }
249
+
250
+ /** 活跃窗口包含 at(可容忍 toleranceMs,缺省 0)的会话 id,按 id 升序、去重 */
251
+ sessionsActiveAt(at: number, toleranceMs = 0): string[] {
252
+ const out: string[] = []
253
+ if (typeof at !== 'number' || !Number.isFinite(at)) return out
254
+ const tolerance = Number.isFinite(toleranceMs) && toleranceMs > 0 ? toleranceMs : 0
255
+ for (const [sessionId, windows] of this.table) {
256
+ for (const iv of windows) {
257
+ if (at >= iv.from - tolerance && at <= iv.to + tolerance) {
258
+ out.push(sessionId)
259
+ break
260
+ }
261
+ }
262
+ }
263
+ return out.sort()
264
+ }
265
+
266
+ /** 单个会话是否活跃(与 sessionsActiveAt 同口径,不另写一套判定) */
267
+ isActiveAt(sessionId: string, at: number, toleranceMs = 0): boolean {
268
+ if (typeof sessionId !== 'string' || sessionId === '') return false
269
+ return this.sessionsActiveAt(at, toleranceMs).includes(sessionId)
270
+ }
271
+
272
+ /** 延迟落盘(多次调用只落一次) */
273
+ scheduleFlush(): void {
274
+ if (this.flushDelayMs <= 0 || this.flushTimer !== null) return
275
+ this.flushTimer = setTimeout(() => {
276
+ this.flushTimer = null
277
+ void this.flush()
278
+ }, this.flushDelayMs)
279
+ // 台账不该阻止进程退出
280
+ ;(this.flushTimer as { unref?: () => void }).unref?.()
281
+ }
282
+
283
+ /** 内存快照(深拷一层:落盘期间的新活动不得改写正在序列化的数据) */
284
+ private snapshot(): ActivityTable {
285
+ const clone: ActivityTable = new Map()
286
+ for (const [sessionId, list] of this.table) {
287
+ clone.set(
288
+ sessionId,
289
+ list.map((iv) => ({ sessionId: iv.sessionId, from: iv.from, to: iv.to })),
290
+ )
291
+ }
292
+ return clone
293
+ }
294
+
295
+ /** 淘汰最旧的窗口(按 to 升序删到上限内;条目 = 窗口,不是会话) */
296
+ private evictOldest(): void {
297
+ const flat: RollbackActivityInterval[] = []
298
+ for (const list of this.table.values()) flat.push(...list)
299
+ const over = flat.length - this.maxIntervals
300
+ if (over <= 0) return
301
+ flat.sort((a, b) => a.to - b.to || a.from - b.from)
302
+ const doomed = new Set<RollbackActivityInterval>(flat.slice(0, over))
303
+ for (const [sessionId, list] of [...this.table]) {
304
+ const kept = list.filter((iv) => !doomed.has(iv))
305
+ if (kept.length === 0) this.table.delete(sessionId)
306
+ else this.table.set(sessionId, kept)
307
+ }
308
+ }
309
+
310
+ /**
311
+ * 立刻落盘(原子:同目录临时文件 + rename;失败只 warn,绝不上抛)。
312
+ *
313
+ * 幂等:没有未落盘数据(revision == lastFlushedRevision)时**直接返回、不碰磁盘**;
314
+ * 并发调用共用同一个在飞写入。
315
+ */
316
+ async flush(): Promise<void> {
317
+ if (this.writing !== null) return this.writing
318
+ if (this.revision === this.lastFlushedRevision) return
319
+ this.writing = (async () => {
320
+ const captured = this.revision
321
+ try {
322
+ await fs.mkdir(path.dirname(this.file), { recursive: true })
323
+ const merged = this.snapshot()
324
+ // **写前合并**(2026-10-17 修):本进程内存里只有自己见过的活动,而同一个工作区可能同时被
325
+ // 另一个 DSH 进程写台账 —— 各自「整体覆盖」会把对方的时间窗抹掉。窗口只增不减
326
+ // ⇒ 与磁盘做**并集合并**是唯一不丢信息的口径(同键取较新的那套在这里不适用:
327
+ // 时间窗不是「最后一次是谁」,而是「谁在这段时间活过」,两个进程的窗口都要留)。
328
+ try {
329
+ const existing = JSON.parse(await fs.readFile(this.file, 'utf8')) as { version?: unknown; intervals?: unknown } | null
330
+ if (existing !== null && typeof existing === 'object' && !Array.isArray(existing) && existing.version === 1 && Array.isArray(existing.intervals)) {
331
+ for (const raw of existing.intervals) {
332
+ const iv = readInterval(raw)
333
+ if (iv === null) continue
334
+ mergeRangeInto(merged, this.gapMs, iv.sessionId, iv.from, iv.to)
335
+ }
336
+ }
337
+ } catch {
338
+ /* 读不到就当没有别人的记录:只写自己这份(绝不因为读失败而放弃落盘) */
339
+ }
340
+ const payload: ActivityFileV1 = { version: 1, intervals: flattenIntervals(merged) }
341
+ const tmp = this.file + '.tmp'
342
+ await fs.writeFile(tmp, JSON.stringify(payload), 'utf8')
343
+ await fs.rename(tmp, this.file)
344
+ this.lastFlushedRevision = captured
345
+ } catch (error) {
346
+ this.warn('rollback activity write failed: ' + this.file + ' ' + String((error as { code?: unknown }).code ?? error))
347
+ } finally {
348
+ this.writing = null
349
+ }
350
+ })()
351
+ return this.writing
352
+ }
353
+
354
+ /**
355
+ * 关闭:取消待落盘的定时器,并把还没落盘的活动写下去。
356
+ * 没有未落盘数据时不会重复写(flush 幂等);写入失败只 warn,不透出(关停路径不得被台账拖垮)。
357
+ */
358
+ async dispose(): Promise<void> {
359
+ if (this.flushTimer !== null) {
360
+ clearTimeout(this.flushTimer)
361
+ this.flushTimer = null
362
+ }
363
+ await this.flush()
364
+ // 在飞写入期间又记了新活动 ⇒ 再落一次(最多两次,绝不循环重试)
365
+ if (this.revision !== this.lastFlushedRevision) await this.flush()
366
+ }
367
+ }