@logictan/dsh-config-manager 0.1.60

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 (429) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +105 -0
  3. package/cordis.patch.yml +21 -0
  4. package/lib/adapters/agent-instructions.d.ts +13 -0
  5. package/lib/adapters/agent-instructions.js +46 -0
  6. package/lib/adapters/agent-presets.d.ts +13 -0
  7. package/lib/adapters/agent-presets.js +14 -0
  8. package/lib/adapters/credentials.d.ts +37 -0
  9. package/lib/adapters/credentials.js +122 -0
  10. package/lib/adapters/file-collection.d.ts +15 -0
  11. package/lib/adapters/file-collection.js +127 -0
  12. package/lib/adapters/index.d.ts +57 -0
  13. package/lib/adapters/index.js +55 -0
  14. package/lib/adapters/link-report.d.ts +16 -0
  15. package/lib/adapters/link-report.js +47 -0
  16. package/lib/adapters/mcp.d.ts +28 -0
  17. package/lib/adapters/mcp.js +178 -0
  18. package/lib/adapters/plugin-files.d.ts +18 -0
  19. package/lib/adapters/plugin-files.js +142 -0
  20. package/lib/adapters/plugins.d.ts +41 -0
  21. package/lib/adapters/plugins.js +603 -0
  22. package/lib/adapters/pnpm-workspace.d.ts +26 -0
  23. package/lib/adapters/pnpm-workspace.js +147 -0
  24. package/lib/adapters/prompts.d.ts +30 -0
  25. package/lib/adapters/prompts.js +231 -0
  26. package/lib/adapters/providers.d.ts +34 -0
  27. package/lib/adapters/providers.js +155 -0
  28. package/lib/adapters/self.d.ts +17 -0
  29. package/lib/adapters/self.js +71 -0
  30. package/lib/adapters/sessions.d.ts +14 -0
  31. package/lib/adapters/sessions.js +15 -0
  32. package/lib/adapters/settings.d.ts +28 -0
  33. package/lib/adapters/settings.js +165 -0
  34. package/lib/adapters/skills.d.ts +13 -0
  35. package/lib/adapters/skills.js +14 -0
  36. package/lib/adapters/test-helpers.d.ts +125 -0
  37. package/lib/adapters/test-helpers.js +236 -0
  38. package/lib/adapters/ui.d.ts +27 -0
  39. package/lib/adapters/ui.js +89 -0
  40. package/lib/adapters/workspaces.d.ts +13 -0
  41. package/lib/adapters/workspaces.js +106 -0
  42. package/lib/client.d.ts +1026 -0
  43. package/lib/client.js +10047 -0
  44. package/lib/core/analyzer.d.ts +99 -0
  45. package/lib/core/analyzer.js +934 -0
  46. package/lib/core/backup.d.ts +138 -0
  47. package/lib/core/backup.js +513 -0
  48. package/lib/core/boot-rescue.d.ts +146 -0
  49. package/lib/core/boot-rescue.js +618 -0
  50. package/lib/core/cache-cleaner.d.ts +31 -0
  51. package/lib/core/cache-cleaner.js +95 -0
  52. package/lib/core/config-lifecycle.d.ts +180 -0
  53. package/lib/core/config-lifecycle.js +481 -0
  54. package/lib/core/config-snapshot.d.ts +135 -0
  55. package/lib/core/config-snapshot.js +424 -0
  56. package/lib/core/config-state.d.ts +70 -0
  57. package/lib/core/config-state.js +177 -0
  58. package/lib/core/consult-source.d.ts +58 -0
  59. package/lib/core/consult-source.js +260 -0
  60. package/lib/core/crash-report.d.ts +84 -0
  61. package/lib/core/crash-report.js +317 -0
  62. package/lib/core/exporter.d.ts +68 -0
  63. package/lib/core/exporter.js +375 -0
  64. package/lib/core/ghost-sweep.d.ts +19 -0
  65. package/lib/core/ghost-sweep.js +52 -0
  66. package/lib/core/importer.d.ts +60 -0
  67. package/lib/core/importer.js +29 -0
  68. package/lib/core/index.d.ts +14 -0
  69. package/lib/core/index.js +14 -0
  70. package/lib/core/journal.d.ts +197 -0
  71. package/lib/core/journal.js +418 -0
  72. package/lib/core/local-plugin-host.d.ts +35 -0
  73. package/lib/core/local-plugin-host.js +120 -0
  74. package/lib/core/local-plugin-pack.d.ts +135 -0
  75. package/lib/core/local-plugin-pack.js +278 -0
  76. package/lib/core/messages.d.ts +310 -0
  77. package/lib/core/messages.js +621 -0
  78. package/lib/core/migration-consult.d.ts +117 -0
  79. package/lib/core/migration-consult.js +363 -0
  80. package/lib/core/migration-history.d.ts +153 -0
  81. package/lib/core/migration-history.js +411 -0
  82. package/lib/core/model-tools.d.ts +50 -0
  83. package/lib/core/model-tools.js +148 -0
  84. package/lib/core/msg-types.d.ts +8 -0
  85. package/lib/core/msg-types.js +6 -0
  86. package/lib/core/phase3-child-crash.d.ts +1 -0
  87. package/lib/core/phase3-child-crash.js +87 -0
  88. package/lib/core/phase3-host.d.ts +138 -0
  89. package/lib/core/phase3-host.js +359 -0
  90. package/lib/core/phase3-prod-child.d.ts +1 -0
  91. package/lib/core/phase3-prod-child.js +35 -0
  92. package/lib/core/phase4-crash-child.d.ts +1 -0
  93. package/lib/core/phase4-crash-child.js +103 -0
  94. package/lib/core/plugin-cli.d.ts +150 -0
  95. package/lib/core/plugin-cli.js +497 -0
  96. package/lib/core/reconcile.d.ts +120 -0
  97. package/lib/core/reconcile.js +389 -0
  98. package/lib/core/recovery-orchestrator.d.ts +94 -0
  99. package/lib/core/recovery-orchestrator.js +327 -0
  100. package/lib/core/restore.d.ts +162 -0
  101. package/lib/core/restore.js +703 -0
  102. package/lib/core/rollback.d.ts +12 -0
  103. package/lib/core/rollback.js +145 -0
  104. package/lib/core/run-registry.d.ts +85 -0
  105. package/lib/core/run-registry.js +157 -0
  106. package/lib/core/startup-barrier.d.ts +53 -0
  107. package/lib/core/startup-barrier.js +77 -0
  108. package/lib/core/types.d.ts +515 -0
  109. package/lib/core/types.js +27 -0
  110. package/lib/core/undo.d.ts +73 -0
  111. package/lib/core/undo.js +87 -0
  112. package/lib/core/validator.d.ts +18 -0
  113. package/lib/core/validator.js +77 -0
  114. package/lib/core/verify-recovery.d.ts +21 -0
  115. package/lib/core/verify-recovery.js +256 -0
  116. package/lib/core/watcher.d.ts +101 -0
  117. package/lib/core/watcher.js +219 -0
  118. package/lib/index.d.ts +238 -0
  119. package/lib/index.js +2764 -0
  120. package/lib/migrations/index.d.ts +29 -0
  121. package/lib/migrations/index.js +56 -0
  122. package/lib/migrations/v1-to-v2.d.ts +9 -0
  123. package/lib/migrations/v1-to-v2.js +10 -0
  124. package/lib/profiles/index.d.ts +4 -0
  125. package/lib/profiles/index.js +5 -0
  126. package/lib/profiles/profile-manager.d.ts +106 -0
  127. package/lib/profiles/profile-manager.js +543 -0
  128. package/lib/schema/config.d.ts +28 -0
  129. package/lib/schema/config.js +165 -0
  130. package/lib/schema/index.d.ts +24 -0
  131. package/lib/schema/index.js +26 -0
  132. package/lib/schema/manifest.d.ts +28 -0
  133. package/lib/schema/manifest.js +109 -0
  134. package/lib/schema/tombstones.d.ts +46 -0
  135. package/lib/schema/tombstones.js +64 -0
  136. package/lib/schema/types.d.ts +232 -0
  137. package/lib/schema/types.js +8 -0
  138. package/lib/schema/versions.d.ts +27 -0
  139. package/lib/schema/versions.js +50 -0
  140. package/lib/security/encryption.d.ts +85 -0
  141. package/lib/security/encryption.js +279 -0
  142. package/lib/security/index.d.ts +16 -0
  143. package/lib/security/index.js +17 -0
  144. package/lib/security/integrity.d.ts +30 -0
  145. package/lib/security/integrity.js +78 -0
  146. package/lib/security/redaction.d.ts +26 -0
  147. package/lib/security/redaction.js +95 -0
  148. package/lib/security/secret-scanner.d.ts +86 -0
  149. package/lib/security/secret-scanner.js +404 -0
  150. package/lib/security/vault.d.ts +59 -0
  151. package/lib/security/vault.js +151 -0
  152. package/lib/security/zip-security.d.ts +27 -0
  153. package/lib/security/zip-security.js +154 -0
  154. package/lib/sync/autosync-config.d.ts +39 -0
  155. package/lib/sync/autosync-config.js +151 -0
  156. package/lib/sync/autosync-scheduler.d.ts +139 -0
  157. package/lib/sync/autosync-scheduler.js +564 -0
  158. package/lib/sync/fs.d.ts +18 -0
  159. package/lib/sync/fs.js +49 -0
  160. package/lib/sync/git/git-transport.d.ts +100 -0
  161. package/lib/sync/git/git-transport.js +437 -0
  162. package/lib/sync/github-auth.d.ts +131 -0
  163. package/lib/sync/github-auth.js +264 -0
  164. package/lib/sync/layout.d.ts +43 -0
  165. package/lib/sync/layout.js +203 -0
  166. package/lib/sync/snapshot-json.d.ts +9 -0
  167. package/lib/sync/snapshot-json.js +107 -0
  168. package/lib/sync/sync-config.d.ts +81 -0
  169. package/lib/sync/sync-config.js +281 -0
  170. package/lib/sync/sync-engine.d.ts +269 -0
  171. package/lib/sync/sync-engine.js +591 -0
  172. package/lib/sync/sync-history.d.ts +42 -0
  173. package/lib/sync/sync-history.js +78 -0
  174. package/lib/sync/sync-selection.d.ts +29 -0
  175. package/lib/sync/sync-selection.js +127 -0
  176. package/lib/sync/sync-session.d.ts +40 -0
  177. package/lib/sync/sync-session.js +54 -0
  178. package/lib/sync/sync-state.d.ts +37 -0
  179. package/lib/sync/sync-state.js +108 -0
  180. package/lib/sync/transport.d.ts +75 -0
  181. package/lib/sync/transport.js +55 -0
  182. package/lib/sync/ui-prefs.d.ts +34 -0
  183. package/lib/sync/ui-prefs.js +109 -0
  184. package/lib/sync/webdav/webdav-transport.d.ts +77 -0
  185. package/lib/sync/webdav/webdav-transport.js +451 -0
  186. package/lib/ui/errors.d.ts +19 -0
  187. package/lib/ui/errors.js +81 -0
  188. package/lib/ui/export-flow.d.ts +82 -0
  189. package/lib/ui/export-flow.js +98 -0
  190. package/lib/ui/i18n.d.ts +289 -0
  191. package/lib/ui/i18n.js +581 -0
  192. package/lib/ui/migration-consult-view.d.ts +91 -0
  193. package/lib/ui/migration-consult-view.js +115 -0
  194. package/lib/ui/progress.d.ts +38 -0
  195. package/lib/ui/progress.js +87 -0
  196. package/lib/ui/report.d.ts +23 -0
  197. package/lib/ui/report.js +153 -0
  198. package/lib/ui/test-helpers.d.ts +69 -0
  199. package/lib/ui/test-helpers.js +154 -0
  200. package/lib/ui/types.d.ts +257 -0
  201. package/lib/ui/types.js +21 -0
  202. package/lib/utils/atomic-write.d.ts +88 -0
  203. package/lib/utils/atomic-write.js +399 -0
  204. package/lib/utils/env-lock.d.ts +393 -0
  205. package/lib/utils/env-lock.js +930 -0
  206. package/lib/utils/hashing.d.ts +17 -0
  207. package/lib/utils/hashing.js +41 -0
  208. package/lib/utils/json.d.ts +24 -0
  209. package/lib/utils/json.js +76 -0
  210. package/lib/utils/logger.d.ts +39 -0
  211. package/lib/utils/logger.js +82 -0
  212. package/lib/utils/paths.d.ts +42 -0
  213. package/lib/utils/paths.js +189 -0
  214. package/lib/utils/proxy.d.ts +69 -0
  215. package/lib/utils/proxy.js +455 -0
  216. package/lib/utils/recursive-walk.d.ts +24 -0
  217. package/lib/utils/recursive-walk.js +134 -0
  218. package/lib/utils/zip.d.ts +54 -0
  219. package/lib/utils/zip.js +285 -0
  220. package/package.json +188 -0
  221. package/src/adapters/agent-instructions.ts +48 -0
  222. package/src/adapters/agent-presets.ts +14 -0
  223. package/src/adapters/credentials.test.ts +90 -0
  224. package/src/adapters/credentials.ts +143 -0
  225. package/src/adapters/file-collection.ts +137 -0
  226. package/src/adapters/files.test.ts +353 -0
  227. package/src/adapters/index.ts +94 -0
  228. package/src/adapters/link-report.test.ts +65 -0
  229. package/src/adapters/link-report.ts +61 -0
  230. package/src/adapters/mcp.test.ts +96 -0
  231. package/src/adapters/mcp.ts +185 -0
  232. package/src/adapters/plugin-files.ts +146 -0
  233. package/src/adapters/plugins.test.ts +326 -0
  234. package/src/adapters/plugins.ts +615 -0
  235. package/src/adapters/pnpm-workspace.test.ts +87 -0
  236. package/src/adapters/pnpm-workspace.ts +150 -0
  237. package/src/adapters/prompts.test.ts +110 -0
  238. package/src/adapters/prompts.ts +230 -0
  239. package/src/adapters/providers.test.ts +126 -0
  240. package/src/adapters/providers.ts +173 -0
  241. package/src/adapters/roundtrip.test.ts +190 -0
  242. package/src/adapters/self.test.ts +92 -0
  243. package/src/adapters/self.ts +75 -0
  244. package/src/adapters/sessions.ts +15 -0
  245. package/src/adapters/settings.test.ts +100 -0
  246. package/src/adapters/settings.ts +187 -0
  247. package/src/adapters/skills.ts +14 -0
  248. package/src/adapters/test-helpers.ts +248 -0
  249. package/src/adapters/ui.test.ts +57 -0
  250. package/src/adapters/ui.ts +108 -0
  251. package/src/adapters/workspaces.test.ts +106 -0
  252. package/src/adapters/workspaces.ts +109 -0
  253. package/src/client/ConfigManagerSection.tsx +90 -0
  254. package/src/client/api.ts +66 -0
  255. package/src/client/client-types.ts +18 -0
  256. package/src/client/common/ErrorBanner.tsx +73 -0
  257. package/src/client/common/Icon.tsx +129 -0
  258. package/src/client/common/Modal.tsx +173 -0
  259. package/src/client/common/ToastViewport.tsx +73 -0
  260. package/src/client/common/toast-store.test.ts +192 -0
  261. package/src/client/common/toast-store.ts +181 -0
  262. package/src/client/common/toast-types.ts +7 -0
  263. package/src/client/common/ui.tsx +342 -0
  264. package/src/client/config-manager.module.css +2971 -0
  265. package/src/client/css-modules.d.ts +8 -0
  266. package/src/client/index.ts +65 -0
  267. package/src/client/locales.ts +56 -0
  268. package/src/client/lucide-icons.d.ts +38 -0
  269. package/src/client/run-store.test.ts +223 -0
  270. package/src/client/run-store.ts +372 -0
  271. package/src/client/sync/SyncConfirmView.tsx +379 -0
  272. package/src/client/sync/SyncHistoryView.test.ts +40 -0
  273. package/src/client/sync/SyncHistoryView.tsx +220 -0
  274. package/src/client/sync/SyncSettingsView.tsx +1535 -0
  275. package/src/client/sync/history-model.test.ts +194 -0
  276. package/src/client/sync/history-model.ts +195 -0
  277. package/src/client/sync/sync-api.test.ts +585 -0
  278. package/src/client/sync/sync-api.ts +543 -0
  279. package/src/client/sync/sync-locales.ts +416 -0
  280. package/src/client/sync/sync-push-preview.test.ts +52 -0
  281. package/src/client/sync/sync-view-v2.test.ts +213 -0
  282. package/src/client/sync/sync-view.test.ts +487 -0
  283. package/src/client/sync/sync-view.ts +759 -0
  284. package/src/core/analyzer.ts +1068 -0
  285. package/src/core/backup.ts +605 -0
  286. package/src/core/boot-rescue.test.ts +847 -0
  287. package/src/core/boot-rescue.ts +737 -0
  288. package/src/core/cache-cleaner.test.ts +169 -0
  289. package/src/core/cache-cleaner.ts +121 -0
  290. package/src/core/config-lifecycle.test.ts +727 -0
  291. package/src/core/config-lifecycle.ts +573 -0
  292. package/src/core/config-snapshot.test.ts +369 -0
  293. package/src/core/config-snapshot.ts +522 -0
  294. package/src/core/config-state.test.ts +381 -0
  295. package/src/core/config-state.ts +207 -0
  296. package/src/core/consult-source.test.ts +199 -0
  297. package/src/core/consult-source.ts +293 -0
  298. package/src/core/crash-report.test.ts +380 -0
  299. package/src/core/crash-report.ts +369 -0
  300. package/src/core/exporter.ts +417 -0
  301. package/src/core/ghost-sweep.test.ts +94 -0
  302. package/src/core/ghost-sweep.ts +67 -0
  303. package/src/core/importer.ts +83 -0
  304. package/src/core/index.ts +56 -0
  305. package/src/core/journal.test.ts +257 -0
  306. package/src/core/journal.ts +518 -0
  307. package/src/core/local-plugin-host.ts +152 -0
  308. package/src/core/local-plugin-pack.test.ts +359 -0
  309. package/src/core/local-plugin-pack.ts +363 -0
  310. package/src/core/messages.test.ts +54 -0
  311. package/src/core/messages.ts +664 -0
  312. package/src/core/migration-consult.test.ts +315 -0
  313. package/src/core/migration-consult.ts +494 -0
  314. package/src/core/migration-history.test.ts +319 -0
  315. package/src/core/migration-history.ts +484 -0
  316. package/src/core/model-tools.test.ts +190 -0
  317. package/src/core/model-tools.ts +208 -0
  318. package/src/core/msg-types.ts +10 -0
  319. package/src/core/phase3-child-crash.ts +95 -0
  320. package/src/core/phase3-host.ts +411 -0
  321. package/src/core/phase3-p1.test.ts +212 -0
  322. package/src/core/phase3-p2b-fingerprint.test.ts +189 -0
  323. package/src/core/phase3-prod-child.ts +36 -0
  324. package/src/core/phase3-production-integration.test.ts +286 -0
  325. package/src/core/phase4-crash-child.ts +101 -0
  326. package/src/core/phase4-crash-injection.test.ts +166 -0
  327. package/src/core/plugin-cli.fs.test.ts +184 -0
  328. package/src/core/plugin-cli.test.ts +137 -0
  329. package/src/core/plugin-cli.ts +563 -0
  330. package/src/core/reconcile.test.ts +392 -0
  331. package/src/core/reconcile.ts +498 -0
  332. package/src/core/recovery-orchestrator.test.ts +145 -0
  333. package/src/core/recovery-orchestrator.ts +366 -0
  334. package/src/core/restore-manage.test.ts +94 -0
  335. package/src/core/restore.ts +849 -0
  336. package/src/core/rollback.ts +151 -0
  337. package/src/core/run-progress.test.ts +237 -0
  338. package/src/core/run-registry.test.ts +232 -0
  339. package/src/core/run-registry.ts +209 -0
  340. package/src/core/smoke.test.ts +842 -0
  341. package/src/core/startup-barrier.ts +99 -0
  342. package/src/core/types.ts +542 -0
  343. package/src/core/undo.ts +116 -0
  344. package/src/core/validator.ts +84 -0
  345. package/src/core/verify-recovery.ts +265 -0
  346. package/src/core/watcher.test.ts +326 -0
  347. package/src/core/watcher.ts +263 -0
  348. package/src/index.facade.test.ts +265 -0
  349. package/src/index.sync.test.ts +217 -0
  350. package/src/index.ts +2975 -0
  351. package/src/migrations/index.ts +71 -0
  352. package/src/migrations/v1-to-v2.ts +17 -0
  353. package/src/profiles/index.ts +8 -0
  354. package/src/profiles/profile-manager.test.ts +282 -0
  355. package/src/profiles/profile-manager.ts +630 -0
  356. package/src/schema/config.ts +165 -0
  357. package/src/schema/index.ts +71 -0
  358. package/src/schema/manifest-schema.test.ts +519 -0
  359. package/src/schema/manifest.ts +124 -0
  360. package/src/schema/tombstones.test.ts +131 -0
  361. package/src/schema/tombstones.ts +89 -0
  362. package/src/schema/types.ts +245 -0
  363. package/src/schema/versions.ts +55 -0
  364. package/src/security/encryption.ts +335 -0
  365. package/src/security/index.ts +16 -0
  366. package/src/security/integrity.ts +102 -0
  367. package/src/security/redaction.ts +101 -0
  368. package/src/security/secret-scanner.ts +445 -0
  369. package/src/security/security.test.ts +1215 -0
  370. package/src/security/vault.test.ts +164 -0
  371. package/src/security/vault.ts +198 -0
  372. package/src/security/zip-security.ts +180 -0
  373. package/src/sync/autosync-config.test.ts +147 -0
  374. package/src/sync/autosync-config.ts +179 -0
  375. package/src/sync/autosync-scheduler.test.ts +490 -0
  376. package/src/sync/autosync-scheduler.ts +646 -0
  377. package/src/sync/fs.ts +48 -0
  378. package/src/sync/git/git-transport.test.ts +673 -0
  379. package/src/sync/git/git-transport.ts +491 -0
  380. package/src/sync/github-auth.test.ts +280 -0
  381. package/src/sync/github-auth.ts +356 -0
  382. package/src/sync/layout.test.ts +270 -0
  383. package/src/sync/layout.ts +222 -0
  384. package/src/sync/snapshot-json.test.ts +119 -0
  385. package/src/sync/snapshot-json.ts +128 -0
  386. package/src/sync/sync-config.test.ts +310 -0
  387. package/src/sync/sync-config.ts +299 -0
  388. package/src/sync/sync-engine.test.ts +1074 -0
  389. package/src/sync/sync-engine.ts +754 -0
  390. package/src/sync/sync-history.test.ts +116 -0
  391. package/src/sync/sync-history.ts +120 -0
  392. package/src/sync/sync-selection.test.ts +186 -0
  393. package/src/sync/sync-selection.ts +143 -0
  394. package/src/sync/sync-session.test.ts +143 -0
  395. package/src/sync/sync-session.ts +77 -0
  396. package/src/sync/sync-state.test.ts +164 -0
  397. package/src/sync/sync-state.ts +131 -0
  398. package/src/sync/transport.test.ts +111 -0
  399. package/src/sync/transport.ts +118 -0
  400. package/src/sync/ui-prefs.test.ts +152 -0
  401. package/src/sync/ui-prefs.ts +135 -0
  402. package/src/sync/webdav/webdav-transport.test.ts +871 -0
  403. package/src/sync/webdav/webdav-transport.ts +535 -0
  404. package/src/ui/errors.test.ts +47 -0
  405. package/src/ui/errors.ts +100 -0
  406. package/src/ui/export-flow.test.ts +87 -0
  407. package/src/ui/export-flow.ts +156 -0
  408. package/src/ui/i18n.ts +590 -0
  409. package/src/ui/migration-consult-view.test.ts +151 -0
  410. package/src/ui/migration-consult-view.ts +178 -0
  411. package/src/ui/progress.test.ts +47 -0
  412. package/src/ui/progress.ts +102 -0
  413. package/src/ui/report.test.ts +101 -0
  414. package/src/ui/report.ts +146 -0
  415. package/src/ui/test-helpers.ts +184 -0
  416. package/src/ui/types.ts +325 -0
  417. package/src/utils/atomic-write.test.ts +312 -0
  418. package/src/utils/atomic-write.ts +422 -0
  419. package/src/utils/env-lock.test.ts +999 -0
  420. package/src/utils/env-lock.ts +1086 -0
  421. package/src/utils/hashing.ts +53 -0
  422. package/src/utils/json.ts +77 -0
  423. package/src/utils/logger.ts +114 -0
  424. package/src/utils/paths.ts +176 -0
  425. package/src/utils/proxy.test.ts +465 -0
  426. package/src/utils/proxy.ts +493 -0
  427. package/src/utils/recursive-walk.test.ts +148 -0
  428. package/src/utils/recursive-walk.ts +145 -0
  429. package/src/utils/zip.ts +332 -0
@@ -0,0 +1,1086 @@
1
+ /**
2
+ * 跨进程环境锁 primitive(Phase 2:Cross-process Lock)。
3
+ *
4
+ * 目标:防止多个 DSH 实例 / Web Host / CLI / AutoSync / Backup Scheduler / Model Tools
5
+ * 同时执行 destructive mutation。提供 **GLOBAL EXCLUSIVE MUTATION LOCK**。
6
+ *
7
+ * 设计基线:CROSS_PROCESS_LOCK_DESIGN.md Rev 3(BLOCKER 1–4 全部 CLOSED)。
8
+ * 关键不变量(NON-NEGOTIABLE,违反即破坏正确性):
9
+ * - 所有权获取 **必须** `open(lockPath, 'wx')` 独占创建语义;**禁止** exists→write、rm→recreate、
10
+ * 用 Phase 1 `atomicWriteFile(environment.lock)` 获取所有权。
11
+ * - `environment.lock` 是 **immutable ownership record**:创建后直到 release 不被 rename/replace。
12
+ * - heartbeat 走 **独立 sidecar** `environment.heartbeat.<instanceId>`,可安全用 Phase 1 `atomicWriteFile` 更新。
13
+ * - **operation-scoped** `MutationLockToken`:禁止 process-level reentrant(instanceId/handle/reenterCount 判嵌套)。
14
+ * - stale 检测只分类(LOCKED / STALE_LOCK_DETECTED / UNKNOWN_STATE),**绝不自动 unlink/takeover**。
15
+ * - recover 是独立显式动作(CLI `--recover-stale-lock`),用原子 rename 捕获 + 二次验证,二次验证失败不覆盖 successor。
16
+ * - destructive mutation 无 `--force`:必须成功 acquire,否则不得执行。
17
+ * - release 前校验 owner.instanceId === token.instanceId,不匹配不 unlink(ownership-lost)。
18
+ *
19
+ * 零 DSH 依赖(仅 node:fs / node:path / node:os / node:crypto + 复用 atomic-write.ts 的 atomicWriteFile)。
20
+ * CLI 离线引擎可复用。io / 时钟 / 进程身份探测可注入(对齐 Phase 1 AtomicIo 模式)。
21
+ */
22
+ import fs from 'node:fs/promises'
23
+ import fssync from 'node:fs'
24
+ import path from 'node:path'
25
+ import os from 'node:os'
26
+ import crypto from 'node:crypto'
27
+ import { atomicWriteFile } from './atomic-write.ts'
28
+
29
+ // ---------- 常量 ----------
30
+
31
+ const WINDOWS = process.platform === 'win32'
32
+ /** 锁目录相对 dataDir */
33
+ export const LOCKS_DIR = 'locks'
34
+ /** 所有权记录文件名(immutable) */
35
+ export const OWNERSHIP_FILE = 'environment.lock'
36
+ /** heartbeat sidecar 前缀 + 文件名模板:environment.heartbeat.<instanceId> */
37
+ export const HEARTBEAT_PREFIX = 'environment.heartbeat.'
38
+ /** recovery 捕获文件名临时前缀 + 模板:environment.recovering.<recoveryInstanceId> */
39
+ export const RECOVERING_PREFIX = 'environment.recovering.'
40
+ /** lock schema 版本 */
41
+ export const LOCK_SCHEMA_VERSION = 1
42
+ /** 默认心跳间隔 ms */
43
+ export const DEFAULT_HEARTBEAT_INTERVAL_MS = 1000
44
+ /** 默认 stale 阈值 ms(≥ 10 × heartbeatInterval) */
45
+ export const DEFAULT_STALE_AFTER_MS = 10_000
46
+ /** 默认 acquire 等待超时(等待活跃锁释放)ms;0 = 不等待直接返回 */
47
+ export const DEFAULT_ACQUIRE_TIMEOUT_MS = 0
48
+ /** 「心跳长过期」下限 ms(issue #36)。实际阈值 = max(30 × staleAfterMs, 本值)。
49
+ * 用途:Windows/macOS 默认拿不到 OS process identity,Windows 又会复用 PID,于是
50
+ * 「心跳过期 + pid 存活」永远停在 UNKNOWN_STATE,用户只能手工删锁文件(issue #36 实测)。
51
+ * 心跳长过期说明写方早已停摆 → 允许**显式**回收判为残留锁;acquire 侧仍绝不自动摘锁。 */
52
+ export const DEFAULT_LONG_EXPIRED_AFTER_MS = 30 * 60_000
53
+
54
+ // ---------- 类型 ----------
55
+
56
+ /** 进程身份(可注入探测;OS 可验证的 process creation identity 才用于 PID reuse) */
57
+ export interface ProcessIdentity {
58
+ /** OS 可验证的进程创建身份串(Linux /proc/<pid>/stat starttime、macOS ps、Windows Get-Process);null = 无法可靠取得 */
59
+ osProcessStartIdentity: string | null
60
+ /** 进程是否存活(best-effort) */
61
+ alive: boolean
62
+ }
63
+
64
+ /** 可注入的进程身份探测门面(默认实现跨平台;测试可注入)。
65
+ * 契约:`alive:false` 只表示 **确证不存在**(ESRCH);探测失败/不确定必须 **抛错**,
66
+ * 由上层归为 UNKNOWN_STATE —— 绝不把失败误报为「确证死亡」(否则会成为误删许可)。 */
67
+ export interface ProcessIdentityProbe {
68
+ /** 探测某 pid 的存活与 OS 身份;确证不存在返回 alive:false;不确定/失败抛错 */
69
+ probe(pid: number): Promise<ProcessIdentity>
70
+ /** 是否能够可靠取得 OS process creation identity(该平台上实现能力) */
71
+ canGetOsIdentity(): boolean
72
+ }
73
+
74
+ /** 可注入 IO 门面(对齐 Phase 1 AtomicIo;默认包 node:fs/promises) */
75
+ export interface EnvLockIo {
76
+ mkdir(dir: string, opts: { recursive: boolean }): Promise<void>
77
+ /** open;flag 含 'x'(wx/wx+)用于独占创建 */
78
+ open(p: string, flag: string, mode?: number): Promise<EnvLockHandle>
79
+ rename(src: string, dst: string): Promise<void>
80
+ unlink(p: string): Promise<void>
81
+ stat(p: string): Promise<{ isFile(): boolean } | null>
82
+ readFileText(p: string): Promise<string>
83
+ lstat?(p: string): Promise<{ isSymbolicLink(): boolean } | null>
84
+ /** 列出目录条目名(诊断;不存在 → 空) */
85
+ listLocksDir?(dir: string): Promise<string[]>
86
+ }
87
+
88
+ export interface EnvLockHandle {
89
+ writeFile(data: Uint8Array): Promise<void>
90
+ sync(): Promise<void>
91
+ close(): Promise<void>
92
+ }
93
+
94
+ export type LockState =
95
+ /** 成功获得所有权 */
96
+ | 'ACQUIRED'
97
+ /** 锁被活跃 owner 持有 */
98
+ | 'LOCKED'
99
+ /** 检测到确定 stale(heartbeat 超时 + 进程确证死亡 或 PID reuse)—— 只报告,不自动删除 */
100
+ | 'STALE_LOCK_DETECTED'
101
+ /** 无法可靠判定(探测失败 / OS identity 缺失)—— 不删除、不 recover、destructive 不执行 */
102
+ | 'UNKNOWN_STATE'
103
+ /** lock 目录 / 文件 IO 错误(非占用) */
104
+ | 'LOCK_IO_ERROR'
105
+ /** 权限错误(EPERM/EACCES 且无法确认 lock 存在) */
106
+ | 'PERMISSION_ERROR'
107
+
108
+ /** 所有权记录(immutable):写入后直到 release 不再改名/替换 */
109
+ export interface LockOwnershipRecord {
110
+ schemaVersion: number
111
+ owner: {
112
+ instanceId: string
113
+ /** 本实例启动时刻(Date.now(),应用层时间戳;PID reuse 判断不使用它) */
114
+ instanceStartedAt: number
115
+ pid: number
116
+ hostname: string
117
+ /** OS 可验证的进程创建身份(写入方 acquire 时探测自身);可能为 null */
118
+ osProcessStartIdentity: string | null
119
+ }
120
+ op: string
121
+ target: string
122
+ acquiredAt: number
123
+ lockVersion: string
124
+ /** 预留 Phase 3 journal id;本阶段恒 null,不实现 */
125
+ journalId: string | null
126
+ }
127
+
128
+ /** heartbeat sidecar 内容(atomicWriteFile 更新;文件名含 instanceId) */
129
+ export interface HeartbeatRecord {
130
+ ownerInstanceId: string
131
+ heartbeatAt: number
132
+ seq: number
133
+ }
134
+
135
+ /** 一次 acquire 的返回 */
136
+ export interface AcquireResult {
137
+ state: LockState
138
+ /** state === 'ACQUIRED' 时非 null */
139
+ token: MutationLockToken | null
140
+ /** 诊断信息(非敏感):占用方 op/hostname、或错误描述 */
141
+ detail?: string
142
+ }
143
+
144
+ /** operation-scoped lock token:accepted 后只属于当前 mutation 调用链 */
145
+ export interface MutationLockToken {
146
+ /** 本 token 唯一的不可伪造 id */
147
+ readonly tokenId: string
148
+ /** 所属 manager 身份(防止 foreign token) */
149
+ readonly managerId: string
150
+ /** owner instanceId(release 校验用) */
151
+ readonly instanceId: string
152
+ /** acquire 时刻 */
153
+ readonly acquiredAt: number
154
+ }
155
+
156
+ /** nested operation 显式传递的 lock context */
157
+ export interface MutationLockContext {
158
+ readonly token: MutationLockToken
159
+ }
160
+
161
+ /**
162
+ * core/CLI/ModelTools 依赖的最小锁契约(与具体 EnvironmentLockManager 解耦,便于 mock 测试)。
163
+ * 满足:acquire → 无 token 时获取全局锁;reuseLock → nested 复用(不 reacquire)。
164
+ */
165
+ export interface MutationLockPort {
166
+ /**
167
+ * 尝试获取 GLOBAL mutation lock。
168
+ * @param parentContext 若提供且有效(validate=true),表示调用链已持锁 → 复用,不 reacquire,恒返回 {state:'ACQUIRED', token: parentContext.token}
169
+ * @returns 未持锁时走完整 acquire;被占 → LOCKED;stale/unknown → 对应状态(不自动删)
170
+ */
171
+ acquire(opts: {
172
+ op: string
173
+ target?: string
174
+ /** nested:若有效父 token → 复用(不 reacquire),否则正常 acquire */
175
+ parentContext?: MutationLockContext
176
+ }): Promise<AcquireResult>
177
+
178
+ /** 校验 token 是否有效且属于当前持有(nested reuse 判断用) */
179
+ validate(token: unknown): token is MutationLockToken
180
+
181
+ /** release(release 前校验 instanceId;mismatch 抛 EnvironmentLockOwnedByAnotherError) */
182
+ release(token: MutationLockToken): Promise<void>
183
+ }
184
+
185
+ /**
186
+ * 便捷包装:核心引擎「无父 token → acquire;有有效父 token → reuse(不 reacquire)」。
187
+ * 返回 { token, context, release }:调用方在 finally 中调用 release(仅当本次真正 acquire 才 release)。
188
+ * 若未提供 port(测试/无锁环境)→ 恒成功、不锁定(token=null、release = no-op)。
189
+ *
190
+ * @example
191
+ * const { context, release } = await withMutationLock(ctx.mutationLock, { op: 'import', target })
192
+ * if (!context) throw new Error('环境锁被占用') // token===null 且真正需要锁 → 被挡
193
+ * try { ...mutation...; await rollback(..., { lockContext: context }) } finally { await release() }
194
+ */
195
+ /**
196
+ * 便捷包装:核心/宿主「无父 token → acquire;有有效父 token → reuse(不 reacquire)」。
197
+ * 返回 { context, release }:
198
+ * - context 非 null = 有锁(或已传入有效父 token 复用);release 仅当**本次真正 acquire**才实际释放;
199
+ * - context null = 锁不可得(被挡)或未配置锁环境。
200
+ * 调用方必须区分:port 未配置(无锁环境)→ context null 且可放行;port 已配置但 context null → 被挡必须拒绝。
201
+ */
202
+ export async function withMutationLock(
203
+ port: MutationLockPort | undefined,
204
+ opts: { op: string; target?: string; parentContext?: MutationLockContext; isBlocked?: () => boolean },
205
+ ): Promise<{ context: MutationLockContext | null; release(): Promise<void>; reason?: LockBlockReason; detail?: string }> {
206
+ // Phase 3 SAFE MODE:注入谓词被挡 → 拒绝(generic blocked?,不识 policy/why)
207
+ if (opts.isBlocked?.() === true) {
208
+ return { context: null, release: async () => {}, reason: 'blocked' }
209
+ }
210
+ if (port === undefined) {
211
+ // 无锁环境(mock/测试):不锁定,调用方自行保证(返回 null + no-op release)
212
+ return { context: null, release: async () => {} }
213
+ }
214
+ // —— 若已持有有效父 token:复用,不 reacquire,且 release = no-op(父负责最终释放)——
215
+ if (opts.parentContext !== undefined && port.validate(opts.parentContext.token)) {
216
+ return { context: opts.parentContext, release: async () => {} }
217
+ }
218
+ const res = await port.acquire({ op: opts.op, target: opts.target })
219
+ if (res.state !== 'ACQUIRED' || res.token === null) {
220
+ // 锁不可得:不放行。区分「被活跃任务占用」(LOCKED)与「锁不可用」(STALE/UNKNOWN/IO/PERM)
221
+ // —— 只在 LOCKED 时向用户说「另一个任务在运行」,其余诚实说「暂无法执行」(不谎称在运行)。
222
+ // stale 单独成类(issue #27):残留锁「重试/重启都不会好」,必须显式回收——
223
+ // 若与别的不可用原因共用「请稍后重试」文案,用户会一直等到放弃(实测反馈即如此)。
224
+ const reason: LockBlockReason = res.state === 'LOCKED'
225
+ ? 'locked'
226
+ : res.state === 'STALE_LOCK_DETECTED'
227
+ ? 'stale'
228
+ : 'unavailable'
229
+ return { context: null, release: async () => {}, reason, detail: res.detail }
230
+ }
231
+ const context: MutationLockContext = { token: res.token }
232
+ let released = false
233
+ return {
234
+ context,
235
+ release: async () => {
236
+ if (released) return
237
+ released = true
238
+ await port.release(res.token!).catch(() => {})
239
+ },
240
+ }
241
+ }
242
+
243
+ /**
244
+ * 直接执行式的 mutation 守卫:acquire(或复用父 token)→ 执行 → 释放。
245
+ * - port 未配置(无锁环境/测试)→ 直接执行 fn(null),不锁定。
246
+ * - port 已配置但 acquire 失败 → **抛 EnvironmentLockUnavailableError**(destructive 不得执行)。
247
+ * - 复用父 token 时(parentContext 有效)→ 不 reacquire、不释放父 token,fn 收到父 context。
248
+ */
249
+ export async function runWithMutationLock<T>(
250
+ port: MutationLockPort | undefined,
251
+ opts: { op: string; target?: string; parentContext?: MutationLockContext; isBlocked?: () => boolean },
252
+ fn: (ctx: MutationLockContext | null) => Promise<T>,
253
+ ): Promise<T> {
254
+ if (port === undefined) return fn(null)
255
+ const { context, release, reason, detail } = await withMutationLock(port, opts)
256
+ if (context === null) {
257
+ throw new EnvironmentLockUnavailableError(opts.op, reason ?? 'locked', detail)
258
+ }
259
+ try {
260
+ return await fn(context)
261
+ } finally {
262
+ await release()
263
+ }
264
+ }
265
+
266
+ /** 判断某 state 是否意味着「未获得锁(被挡)」;ACQUIRED 才放行 destructive */
267
+ export function isAcquired(state: LockState): boolean {
268
+ return state === 'ACQUIRED'
269
+ }
270
+
271
+ /** 一次 recover 的返回 */
272
+ export interface RecoverResult {
273
+ ok: boolean
274
+ /** 是否实际移除了 stale inode(ok=true 时为 true) */
275
+ removed: boolean
276
+ /** 判定:STALE_LOCK_DETECTED / UNKNOWN_STATE(拒绝)/ LOCKED(拒绝,owner healthy)/ LOCK_IO_ERROR / PERMISSION_ERROR */
277
+ state: LockState
278
+ /** 诊断 */
279
+ detail?: string
280
+ }
281
+
282
+ export interface EnvLockManagerOptions {
283
+ /** dataDir(缺省 ~/.dsh/dsh-config-manager)—— 锁在 <dataDir>/locks */
284
+ dataDir?: string
285
+ /** 覆盖绝对 locks 目录(测试/CLI 注入) */
286
+ locksDir?: string
287
+ /** 可注入 io */
288
+ io?: EnvLockIo
289
+ /** 可注入进程探测 */
290
+ probe?: ProcessIdentityProbe
291
+ /** 时钟(测试注入) */
292
+ now?: () => number
293
+ /** 心跳间隔 ms(缺省 1000) */
294
+ heartbeatIntervalMs?: number
295
+ /** stale 阈值 ms(缺省 10000) */
296
+ staleAfterMs?: number
297
+ /** acquire 等待活跃锁释放的超时 ms(缺省 0=不等待) */
298
+ acquireTimeoutMs?: number
299
+ /** 「心跳长过期」阈值 ms(issue #36;缺省 max(30 × staleAfterMs, 30 分钟))。
300
+ * 只影响「pid 存活但进程身份不可验证」这一类锁的**显式**回收与状态分类,不放松自动侧。 */
301
+ longExpiredAfterMs?: number
302
+ /** 诊断用的当前 operation 描述(写入 ownership.op) */
303
+ op?: string
304
+ /** 诊断用的 target 描述 */
305
+ target?: string
306
+ /** 插件版本(写入 ownership.lockVersion) */
307
+ lockVersion?: string
308
+ /** heartbeat 续期写失败回调(留痕;不中断 mutation) */
309
+ onHeartbeatWriteFailure?: (err: unknown) => void
310
+ }
311
+
312
+ /** 默认 io 实现 */
313
+ function defaultIo(): EnvLockIo {
314
+ return {
315
+ async mkdir(d, o) { await fs.mkdir(d, o) },
316
+ async open(p, flag, mode) { return fs.open(p, flag as Parameters<typeof fs.open>[1], mode) as Promise<EnvLockHandle> },
317
+ async rename(a, b) { return fs.rename(a, b) },
318
+ async unlink(p) { return fs.unlink(p) },
319
+ async stat(p) { try { return await fs.stat(p) } catch (e) { if (isENOENT(e)) return null; throw e } },
320
+ async readFileText(p) { return (await fs.readFile(p, 'utf8')).toString() },
321
+ async lstat(p) { try { return await fs.lstat(p) } catch (e) { if (isENOENT(e)) return null; throw e } },
322
+ }
323
+ }
324
+
325
+ /** 默认进程探测(跨平台 best-effort;OS identity 能力由平台决定) */
326
+ function defaultProbe(): ProcessIdentityProbe {
327
+ const selfOsIdentity = (() => {
328
+ try {
329
+ if (process.platform === 'linux') {
330
+ // /proc/<pid>/stat 第 22 字段 = starttime(tick 数)
331
+ const l = fssync.readFileSync(`/proc/${process.pid}/stat`, 'utf8').toString()
332
+ const afterComm = l.slice(l.lastIndexOf(')') + 1).trim().split(/\s+/)
333
+ // 格式: state ppid ... starttime:comm 后第一字段是 state,starttime 是第 22 个(index 21 起)
334
+ return `linux:${afterComm[21] ?? 'unknown'}`
335
+ }
336
+ if (process.platform === 'darwin') return `darwin:${process.pid}:${Date.now()}` // 不可靠 → 保守返回占位
337
+ if (process.platform === 'win32') {
338
+ // Windows 无简单 /proc;best-effort 用 process 自身属性(不真验 PID reuse,交给 probe 标记能力)
339
+ return null
340
+ }
341
+ return null
342
+ } catch { return null }
343
+ })()
344
+
345
+ const canGetOsIdentity = (): boolean => {
346
+ // Linux /proc 可靠;Windows/macOS 由 probe 运行时二次探测决定,这里保守:仅声明 Linux 能力
347
+ return process.platform === 'linux'
348
+ }
349
+
350
+ const probe = async (pid: number): Promise<ProcessIdentity> => {
351
+ let alive = false
352
+ let aliveConfirmed = false
353
+ try {
354
+ process.kill(pid, 0)
355
+ alive = true // 存在(或 EPERM 权限不足同样表示存在)
356
+ aliveConfirmed = true
357
+ } catch (e) {
358
+ const code = (e as { code?: string }).code
359
+ if (code === 'ESRCH') {
360
+ alive = false // 确证不存在
361
+ aliveConfirmed = true
362
+ } else if (code === 'EPERM') {
363
+ alive = true // 存在但无信号权限 → 视为 alive
364
+ aliveConfirmed = true
365
+ } else {
366
+ // 平台不支持 / 其它错误 → 探测失败(不确定),抛错由上层归为 UNKNOWN_STATE;
367
+ // **绝不要**伪装成「确证死亡」——否则会把失败的探测当成 stale 删除许可。
368
+ throw new Error(`进程探测失败 pid=${pid}: ${(e as Error)?.message ?? String(e)}`)
369
+ }
370
+ }
371
+ if (!aliveConfirmed) {
372
+ // 防御:理论上到不了这里
373
+ throw new Error(`进程探测未确定 pid=${pid}`)
374
+ }
375
+ let osIdentity: string | null = null
376
+ if (alive) {
377
+ if (process.platform === 'linux') {
378
+ try {
379
+ const l = fssync.readFileSync(`/proc/${pid}/stat`, 'utf8').toString()
380
+ const afterComm = l.slice(l.lastIndexOf(')') + 1).trim().split(/\s+/)
381
+ osIdentity = `linux:${afterComm[21] ?? 'unknown'}`
382
+ } catch { osIdentity = null }
383
+ } else if (process.platform === 'win32') {
384
+ // best-effort:Node 无法直接读其它进程 creation time;留给注入实现。这里返回 null = 无法验证。
385
+ osIdentity = null
386
+ } else if (process.platform === 'darwin') {
387
+ osIdentity = null // 依赖 ps 的实现应由宿主注入;默认保守
388
+ } else {
389
+ osIdentity = null
390
+ }
391
+ }
392
+ return { alive, osProcessStartIdentity: osIdentity }
393
+ }
394
+
395
+ return { probe, canGetOsIdentity }
396
+ }
397
+
398
+ function isENOENT(e: unknown): boolean {
399
+ return typeof e === 'object' && e !== null && (e as { code?: unknown }).code === 'ENOENT'
400
+ }
401
+
402
+ /** 解析 lock 文件 JSON,损坏/非法 → null */
403
+ function parseJsonSafe(text: string): unknown {
404
+ try { return JSON.parse(text) as unknown } catch { return null }
405
+ }
406
+
407
+ function randomHex(nBytes: number): string {
408
+ return crypto.randomBytes(nBytes).toString('hex')
409
+ }
410
+
411
+ /**
412
+ * EnvironmentLockManager:跨进程环境锁管理器。
413
+ *
414
+ * 用法:
415
+ * const mgr = new EnvironmentLockManager({ locksDir })
416
+ * const { state, token } = await mgr.acquire({ op: 'import' })
417
+ * if (state !== 'ACQUIRED') throw ... // destructive 必须成功 acquire
418
+ * try { ...mutation... } finally { await mgr.release(token) }
419
+ * // nested rollback:mutation 内 rollback(..., { lockContext: { token } }) → reuse,不 reacquire
420
+ */
421
+ export class EnvironmentLockManager {
422
+ private readonly locksDir: string
423
+ private readonly io: EnvLockIo
424
+ private readonly probe: ProcessIdentityProbe
425
+ private readonly now: () => number
426
+ private readonly heartbeatIntervalMs: number
427
+ private readonly staleAfterMs: number
428
+ private readonly longExpiredAfterMs: number
429
+ private readonly acquireTimeoutMs: number
430
+ private readonly lockVersion: string
431
+ private readonly onHeartbeatWriteFailure: (err: unknown) => void
432
+ /** 诊断用(acquire 时写 ownership.op/target) */
433
+ private readonly defaultOp: string
434
+ private readonly defaultTarget: string
435
+ /** 本 manager 唯一 id(forever token 校验用) */
436
+ private readonly managerId: string
437
+ /** 本 manager 持有的当前活跃 token(单锁单持有者) */
438
+ private activeToken: MutationLockToken | null = null
439
+ private heartbeatTimer: ReturnType<typeof setInterval> | null = null
440
+ private heartbeatSeq = 0
441
+ private readonly activeInstanceId: string
442
+ private heartbeatDegraded = false
443
+ /** 串行化的 heartbeat 写链(**最后一次写**的 promise);release 前用于 drain,见 drainHeartbeat */
444
+ private pendingHeartbeat: Promise<void> = Promise.resolve()
445
+ /** 瞬时错误(EBUSY 等)有界重试计数 */
446
+ private transientRetries = 0
447
+ private readonly maxTransientRetries = 5
448
+
449
+ /** 有界瞬时重试:EBUSY(Windows sharing violation / 杀软)后小退避重试;超限则返回 false(交由 classify) */
450
+ private async tryTransientRetry(kind: 'open' | 'unlink' | 'rename'): Promise<boolean> {
451
+ if (this.transientRetries >= this.maxTransientRetries) {
452
+ this.transientRetries = 0
453
+ return false
454
+ }
455
+ this.transientRetries += 1
456
+ await sleep(Math.max(this.heartbeatIntervalMs / 4, 20))
457
+ return true
458
+ }
459
+
460
+ /** 重置瞬时重试计数(每次 acquire 成功/失败收敛时调用) */
461
+ private resetTransientRetries(): void { this.transientRetries = 0 }
462
+
463
+ constructor(opts: EnvLockManagerOptions = {}) {
464
+ this.locksDir = opts.locksDir ?? path.join(opts.dataDir ?? path.join(os.homedir(), '.dsh', 'dsh-config-manager'), LOCKS_DIR)
465
+ this.io = opts.io ?? defaultIo()
466
+ this.probe = opts.probe ?? defaultProbe()
467
+ this.now = opts.now ?? (() => Date.now())
468
+ this.heartbeatIntervalMs = opts.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS
469
+ this.staleAfterMs = opts.staleAfterMs ?? DEFAULT_STALE_AFTER_MS
470
+ // issue #36:阈值取「30 倍 stale 窗口」与「30 分钟」的较大者——活着的 owner 连续 30 分钟
471
+ // 一次心跳都写不进去只可能是 ACL/磁盘级别的异常;而残留锁的用户等 9 天都等不到自愈。
472
+ this.longExpiredAfterMs = opts.longExpiredAfterMs ?? Math.max(this.staleAfterMs * 30, DEFAULT_LONG_EXPIRED_AFTER_MS)
473
+ this.acquireTimeoutMs = opts.acquireTimeoutMs ?? DEFAULT_ACQUIRE_TIMEOUT_MS
474
+ this.lockVersion = opts.lockVersion ?? '0.1.0'
475
+ this.onHeartbeatWriteFailure = opts.onHeartbeatWriteFailure ?? (() => {})
476
+ this.defaultOp = opts.op ?? 'mutation'
477
+ this.defaultTarget = opts.target ?? 'unknown'
478
+ this.managerId = randomHex(16)
479
+ this.activeInstanceId = randomHex(16)
480
+ }
481
+
482
+ get ownershipPath(): string { return path.join(this.locksDir, OWNERSHIP_FILE) }
483
+ get locksDirectory(): string { return this.locksDir }
484
+
485
+ /** 本 manager 是否当前持有锁(诊断用) */
486
+ get isHolding(): boolean { return this.activeToken !== null }
487
+
488
+ /* ------------------------------------------------------------ acquire */
489
+
490
+ /**
491
+ * 尝试获取 GLOBAL mutation lock。
492
+ * - 成功 → state=ACQUIRED,返回 token(token 只属于当前调用链)。
493
+ * - 被活跃 owner 持有 → LOCKED(可选等待 acquireTimeoutMs 后仍失败)。
494
+ * - 确定 stale → STALE_LOCK_DETECTED(**不自动删除**;destructive 不得执行)。
495
+ * - 其它 → UNKNOWN_STATE / LOCK_IO_ERROR / PERMISSION_ERROR。
496
+ *
497
+ * 所有权用 `open(ownershipPath, 'wx')` 独占创建;**绝不用 atomicWriteFile 获取所有权**。
498
+ */
499
+ async acquire(opts: { op?: string; target?: string } = {}): Promise<AcquireResult> {
500
+ const op = opts.op ?? this.defaultOp
501
+ const target = opts.target ?? this.defaultTarget
502
+ const started = this.now()
503
+ // 预取自身 OS identity(F3 修复):在 open('wx') 之前完成异步探测,避免 open→writeFile 间隙内 await
504
+ // (否则该间隙被杀会留下 0 字节 environment.lock → 永久 LOCKED)。探测失败降级为 null(不废掉合法 acquire)。
505
+ const selfOsIdentity = this.probe.canGetOsIdentity()
506
+ ? await this.probe.probe(process.pid).then((p) => p.osProcessStartIdentity).catch(() => null)
507
+ : null
508
+ for (;;) {
509
+ // —— 先确保 locks 目录存在(mkdir 递归;EPERM 在下面归类为 PERMISSION_ERROR)——
510
+ try {
511
+ await this.io.mkdir(this.locksDir, { recursive: true })
512
+ } catch (e) {
513
+ return classifyIoError(e, `创建锁目录 ${this.locksDir}`, this.io)
514
+ }
515
+
516
+ // —— 独占创建所有权文件(唯一所有权获取原语)——
517
+ let handle: EnvLockHandle | null = null
518
+ try {
519
+ handle = await this.io.open(this.ownershipPath, 'wx', 0o600)
520
+ } catch (e) {
521
+ const code = (e as { code?: string }).code
522
+ // EBUSY:Windows sharing violation / 杀软 / 索引器瞬时占用 → 有界重试(Windows P2-3)
523
+ if (code === 'EBUSY') {
524
+ const transient = await this.tryTransientRetry('open')
525
+ if (transient) continue
526
+ return classifyIoError(e, `open('wx') ${this.ownershipPath}`, this.io)
527
+ }
528
+ if (code === 'EEXIST') {
529
+ // 已存在 → inspect owner
530
+ const inspect = await this.inspectLockState()
531
+ if (inspect.state === 'LOCKED') {
532
+ // 等 acquireTimeoutMs 后重试;超时则返回 LOCKED
533
+ if (this.acquireTimeoutMs > 0 && this.now() - started < this.acquireTimeoutMs) {
534
+ await sleep(this.heartbeatIntervalMs / 2)
535
+ continue
536
+ }
537
+ return { state: 'LOCKED', token: null, detail: inspect.detail }
538
+ }
539
+ // STALE/UNKNOWN/IO 直接返回(不自动删除;destructive 不执行)
540
+ return { state: inspect.state, token: null, detail: inspect.detail }
541
+ }
542
+ // EPERM/EACCES 等(Windows open('wx') 对存在文件常抛 EPERM 而非 EEXIST):
543
+ // 按 §8.1 分类 —— 先检查 environment.lock 是否确实存在且可读取;
544
+ // 存在 → 按 existing lock inspect(可能正是活跃锁被 Windows 以 EPERM 拒绝);
545
+ // 不存在/无法确认 → LOCK_IO_ERROR / PERMISSION_ERROR(绝不误报「Locked/另一任务在运行」)。
546
+ if (code === 'EPERM' || code === 'EACCES') {
547
+ const exists = await this.statLockExists()
548
+ if (exists === true) {
549
+ const inspect = await this.inspectLockState()
550
+ if (inspect.state === 'LOCKED' && this.acquireTimeoutMs > 0 && this.now() - started < this.acquireTimeoutMs) {
551
+ await sleep(this.heartbeatIntervalMs / 2)
552
+ continue
553
+ }
554
+ return { state: inspect.state, token: null, detail: inspect.detail }
555
+ }
556
+ // 无既有锁 → 权限/ACL/文件系统错误(可能目录不可写 / ACL 拒绝),非锁占用
557
+ return exists === false
558
+ ? { state: 'PERMISSION_ERROR', token: null, detail: `open('wx') ${this.ownershipPath}: 权限/ACL 错误 (${code}),且未发现既有锁文件` }
559
+ : classifyIoError(e, `open('wx') ${this.ownershipPath}`, this.io)
560
+ }
561
+ return classifyIoError(e, `open('wx') ${this.ownershipPath}`, this.io)
562
+ }
563
+
564
+ // —— 独占创建成功:写入 immutable owner(一次性,之后不再替换)——
565
+ try {
566
+ const ownerRecord: LockOwnershipRecord = {
567
+ schemaVersion: LOCK_SCHEMA_VERSION,
568
+ owner: {
569
+ instanceId: this.activeInstanceId,
570
+ instanceStartedAt: Date.now(),
571
+ pid: process.pid,
572
+ hostname: os.hostname(),
573
+ osProcessStartIdentity: selfOsIdentity,
574
+ },
575
+ op,
576
+ target,
577
+ acquiredAt: this.now(),
578
+ lockVersion: this.lockVersion,
579
+ journalId: null,
580
+ }
581
+ await handle.writeFile(encode(ownerRecord))
582
+ await handle.sync()
583
+ await handle.close()
584
+ handle = null
585
+ } catch (e) {
586
+ // 写入 owner 失败:必须回滚 —— 关闭句柄 + 尽力删除刚创建的 lock(此刻所有权尚未确立,删除自己是安全的)
587
+ if (handle) try { await handle.close() } catch { /* ignore */ }
588
+ this.activeToken = null
589
+ try { await this.io.unlink(this.ownershipPath) } catch { /* 清理失败留痕由上层 */ }
590
+ return classifyIoError(e, `写入 owner ${this.ownershipPath}`, this.io)
591
+ }
592
+
593
+ // —— 确立 token + 启动 heartbeat ——
594
+ const token: MutationLockToken = {
595
+ tokenId: randomHex(16),
596
+ managerId: this.managerId,
597
+ instanceId: this.activeInstanceId,
598
+ acquiredAt: this.now(),
599
+ }
600
+ this.activeToken = token
601
+ this.heartbeatSeq = 0
602
+ this.heartbeatDegraded = false
603
+ this.startHeartbeat()
604
+ return { state: 'ACQUIRED', token, detail: `op=${op}` }
605
+ }
606
+ }
607
+
608
+ /* ------------------------------------------------------------ validate / release */
609
+
610
+ /**
611
+ * 校验 token 是否有效且属于当前持有(供 nested operation 判断能否 reuse)。
612
+ * 返回 true = 该 token 授权本调用链 reuse 当前持有。
613
+ */
614
+ validate(token: unknown): token is MutationLockToken {
615
+ const t = token as MutationLockToken | null | undefined
616
+ if (t === null || t === undefined) return false
617
+ if (this.activeToken === null) return false // 未持有
618
+ if (t.managerId !== this.managerId) return false // foreign token
619
+ if (t.tokenId !== this.activeToken.tokenId) return false // 非当前 token / 已 release
620
+ // 显式 instanceId 三重匹配(P2-1 纵深防御:tokenId 唯一绑定 instanceId,此处显式断言防未来拆解)
621
+ if (t.instanceId !== this.activeInstanceId) return false
622
+ return true
623
+ }
624
+
625
+ /**
626
+ * 释放锁:release 前**校验 ownership record 的 instanceId === token.instanceId**(以及 manager/tokenId)。
627
+ * 匹配 → 清理 heartbeat + unlink ownership;不匹配 → **不 unlink**,记录 ownership-lost violation。
628
+ *
629
+ * 错误语义(F2 修复):仅当磁盘 ownership 被确认成功删除后才清空 activeToken;unlink 失败/异常时
630
+ * **保留 activeToken**(本调用链仍持有该 inode),允许调用方重试 release,绝不留下"令牌已失效但锁在磁盘上"的卡死态。
631
+ */
632
+ async release(token: MutationLockToken): Promise<void> {
633
+ if (!this.validate(token)) {
634
+ // 可能已 release 或 foreign:幂等返回(已 release 再次 release 无害)
635
+ if (this.activeToken === null) return
636
+ throw new EnvironmentLockOwnedByAnotherError('release: token 不匹配当前持有(foreign/已被接管),拒绝 unlink')
637
+ }
638
+ const instanceId = this.activeInstanceId
639
+ this.stopHeartbeat()
640
+ // —— release 前校验磁盘 ownership record 仍属于自己(防异常恢复/人工修改)——
641
+ const st = await this.readOwnershipState()
642
+ if (st.kind === 'missing') {
643
+ // ownership 文件不存在:已被清除/尚未落盘 → 视为已释放;清 token + 尽力清 heartbeat
644
+ // 先清 token(writeHeartbeat 的「不再写」闸门)再 drain,最后清理 sidecar(同成功路径的顺序理由)
645
+ this.heartbeatDegraded = false
646
+ this.activeToken = null
647
+ await this.drainHeartbeat()
648
+ await this.cleanupHeartbeat(instanceId).catch(() => {})
649
+ return
650
+ }
651
+ if (st.kind === 'corrupt') {
652
+ // ownership 存在但损坏/不可读:无法确证属于自己 → ownership-lost,不 unlink(防误删他人/异常文件)
653
+ this.heartbeatDegraded = false
654
+ this.activeToken = null
655
+ throw new EnvironmentLockOwnedByAnotherError(
656
+ `release: 磁盘 ownership 无法读取/损坏(可能被异常恢复或人工修改),拒绝 unlink(ownership-lost)`,
657
+ )
658
+ }
659
+ if (st.rec.owner.instanceId !== instanceId) {
660
+ throw new EnvironmentLockOwnedByAnotherError(
661
+ `release: 磁盘 ownership.instanceId=${st.rec.owner.instanceId} !== 本 token ${instanceId}(ownership-lost)`,
662
+ )
663
+ }
664
+ // —— matching:unlink ——(acquire 成功后句柄已 close,此处只有 unlink)
665
+ try {
666
+ await this.io.unlink(this.ownershipPath)
667
+ } catch (e) {
668
+ // unlink 失败:**保留 activeToken**(仍持有该磁盘 inode),调用方可重试 release;
669
+ // 绝不在此清空 token(否则锁卡死在磁盘而令牌失效)。
670
+ throw new EnvironmentLockIOError(`release: unlink ${this.ownershipPath} 失败: ${e instanceof Error ? e.message : String(e)}`, e)
671
+ }
672
+ // unlink 成功 → 释放完成:**先清 token 再 drain**,最后清自己的 heartbeat sidecar。
673
+ // 顺序关键:activeToken=null 必须早于 drain —— 否则 interval 可能在 drain 返回之后、cleanup 之前
674
+ // 再排入一次写并真正落盘,把刚删掉的 sidecar 复活(writeHeartbeat 以 activeToken===null 作为「不再写」的闸门)。
675
+ // 本仓库实测:顺序颠倒时 L3 回归用例可稳定复现 sidecar 复活(该用例正是捕获了这一点)。
676
+ this.heartbeatDegraded = false
677
+ this.activeToken = null
678
+ await this.drainHeartbeat()
679
+ await this.cleanupHeartbeat(instanceId).catch(() => {})
680
+ }
681
+
682
+ /* ------------------------------------------------------------ heartbeat */
683
+
684
+ private startHeartbeat(): void {
685
+ if (this.heartbeatTimer !== null) return
686
+ this.heartbeatTimer = setInterval(() => { void this.trackHeartbeat() }, Math.max(this.heartbeatIntervalMs, 50))
687
+ if (this.heartbeatTimer.unref) this.heartbeatTimer.unref()
688
+ // 立即写一次,确立初始 heartbeat(stale 窗口从此刻起)——走 trackHeartbeat 纳入可 drain 的串行链:
689
+ // 否则这个 fire-and-forget 的写可能在 release 清理 sidecar 之后才落盘,把 sidecar 重新创建(或残留 .dshcm.*.tmp),
690
+ // 在 Windows 上即表现为目录清理竞态(after-hook rmSync ENOTEMPTY)。
691
+ this.trackHeartbeat()
692
+ }
693
+
694
+ private stopHeartbeat(): void {
695
+ if (this.heartbeatTimer !== null) {
696
+ clearInterval(this.heartbeatTimer)
697
+ this.heartbeatTimer = null
698
+ }
699
+ }
700
+
701
+ /**
702
+ * 等待**当前在途**的 heartbeat 写完成(release 清理 sidecar 前必须调用)。
703
+ * 为什么必要:writeHeartbeat 走 atomicWriteFile(tmp 写入 → rename),是异步多步操作。
704
+ * 若 release 只 stopHeartbeat + unlink sidecar 而不等待,一个已启动的写会在 unlink 之后才 rename,
705
+ * 于是把刚删掉的 sidecar **重新创建**(或残留 .dshcm.*.tmp)——Windows 上即 after-hook rmSync ENOTEMPTY。
706
+ * 调用点保证:release 先同步 stopHeartbeat() 并置 activeToken=null,故此刻起不会有新的写开始;
707
+ * 因此 drain 之后 cleanupHeartbeat 删除的 sidecar 不会再被复活。
708
+ */
709
+ private async drainHeartbeat(): Promise<void> {
710
+ try { await this.pendingHeartbeat } catch { /* writeHeartbeat 自身已吞错;此处仅防御 */ }
711
+ }
712
+
713
+ /** 串行化并追踪一次 heartbeat 写:chain 保证不会有两个写并发 rename 同一个 sidecar */
714
+ private trackHeartbeat(): Promise<void> {
715
+ this.pendingHeartbeat = this.pendingHeartbeat.then(() => this.writeHeartbeat())
716
+ return this.pendingHeartbeat
717
+ }
718
+
719
+ /** 写 heartbeat sidecar(atomicWriteFile 更新 sidecar,不影响 ownership;失败 → degraded,不中断 mutation) */
720
+ private async writeHeartbeat(): Promise<void> {
721
+ if (this.activeToken === null) return
722
+ const rec: HeartbeatRecord = {
723
+ ownerInstanceId: this.activeInstanceId,
724
+ heartbeatAt: this.now(),
725
+ seq: ++this.heartbeatSeq,
726
+ }
727
+ const sbPath = path.join(this.locksDir, `${HEARTBEAT_PREFIX}${this.activeInstanceId}`)
728
+ try {
729
+ await atomicWriteFile(sbPath, encode(rec), { mode: 0o600 })
730
+ this.heartbeatDegraded = false
731
+ } catch (e) {
732
+ this.heartbeatDegraded = true
733
+ this.onHeartbeatWriteFailure(e)
734
+ }
735
+ }
736
+
737
+ /** 删除指定 instanceId 的 heartbeat sidecar(不受 activeToken 状态影响——F1 修复;调用方传自己的 instanceId)。 */
738
+ private async cleanupHeartbeat(instanceId: string): Promise<void> {
739
+ const sbPath = path.join(this.locksDir, `${HEARTBEAT_PREFIX}${instanceId}`)
740
+ try { await this.io.unlink(sbPath) } catch (e) { if (!isENOENT(e)) this.onHeartbeatWriteFailure(e) }
741
+ }
742
+
743
+ /* ------------------------------------------------------------ inspect / stale */
744
+
745
+ /** 检查 environment.lock 是否确实存在(§8.1 分类用;stat 失败/无法确认 → null) */
746
+ private async statLockExists(): Promise<boolean | null> {
747
+ try {
748
+ const st = await this.io.stat(this.ownershipPath)
749
+ return st !== null
750
+ } catch (e) {
751
+ if (isENOENT(e)) return false
752
+ return null // IO error 无法确认
753
+ }
754
+ }
755
+
756
+ /** ownership 三态读取:missing(缺失)/ corrupt(存在但空或非法)/ ok(有效 owner)。
757
+ * 用于区分「无锁」与「崩溃残留的 0 字节/损坏锁」——后者可被显式 recovery 安全回收(无有效 owner 无从误删)。 */
758
+ private async readOwnershipState(): Promise<
759
+ { kind: 'missing' } | { kind: 'corrupt' } | { kind: 'ok'; rec: LockOwnershipRecord }
760
+ > {
761
+ let text: string
762
+ try {
763
+ text = await this.io.readFileText(this.ownershipPath)
764
+ } catch (e) {
765
+ if (isENOENT(e)) return { kind: 'missing' }
766
+ // ACL 不可读(EACCES 等)→ 视为 corrupt-unknown(无法确证 owner),安全侧
767
+ return { kind: 'corrupt' }
768
+ }
769
+ const parsed = parseJsonSafe(text) as LockOwnershipRecord | null
770
+ if (parsed === null || typeof parsed !== 'object' || parsed.schemaVersion !== LOCK_SCHEMA_VERSION
771
+ || typeof parsed.owner?.instanceId !== 'string') {
772
+ return { kind: 'corrupt' }
773
+ }
774
+ return { kind: 'ok', rec: parsed }
775
+ }
776
+
777
+ /** 读取 owner instanceId 的 heartbeat sidecar(无 → null) */
778
+ private async readHeartbeat(instanceId: string): Promise<HeartbeatRecord | null> {
779
+ const sbPath = path.join(this.locksDir, `${HEARTBEAT_PREFIX}${instanceId}`)
780
+ let text: string
781
+ try { text = await this.io.readFileText(sbPath) } catch (e) { if (isENOENT(e)) return null; throw e }
782
+ const parsed = parseJsonSafe(text) as HeartbeatRecord | null
783
+ if (parsed === null || typeof parsed !== 'object' || parsed.ownerInstanceId !== instanceId) return null
784
+ return parsed
785
+ }
786
+
787
+ /**
788
+ * 判定锁状态(只分类)。按 Design §6.3 正式状态表:
789
+ * heartbeat fresh → LOCKED
790
+ * heartbeat expired + PID dead → STALE_LOCK_DETECTED
791
+ * heartbeat expired + PID alive + identity 不同 → STALE_LOCK_DETECTED (PID reuse)
792
+ * heartbeat expired + PID alive + identity 相同 → LOCKED (owner alive / heartbeat degraded)
793
+ * probe 无法可靠确定 → UNKNOWN_STATE
794
+ */
795
+ async inspectLockState(): Promise<{ state: LockState; detail?: string }> {
796
+ const st = await this.readOwnershipState()
797
+ if (st.kind === 'missing') {
798
+ // 无所有权文件:可能在创建中,或已被清除 → 保守按"非空闲"处理
799
+ return { state: 'LOCKED', detail: 'ownership file 不存在(可能正被创建中)' }
800
+ }
801
+ if (st.kind === 'corrupt') {
802
+ // 存在但空/损坏/ACL 不可读:崩溃窗口残留(open('wx') 后未写完 owner 即死),无有效 owner
803
+ // 无从误删;acquire 侧不执行(UNKNOWN_STATE),但显式 recovery 可安全回收(见 recoverStaleLock)
804
+ return { state: 'UNKNOWN_STATE', detail: 'ownership 存在但无有效 owner(可能崩溃残留空/损坏文件),不删除、不执行;可用 --recover-stale-lock 回收' }
805
+ }
806
+ const rec = st.rec
807
+ // heartbeat 读取失败(EACCES)→ 无法确认 fresh → 保守不判 stale(避免误删)
808
+ let heartbeat: HeartbeatRecord | null = null
809
+ try {
810
+ heartbeat = await this.readHeartbeat(rec.owner.instanceId)
811
+ } catch (e) {
812
+ const code = (e as { code?: string }).code
813
+ return code === 'EACCES' || code === 'EPERM'
814
+ ? { state: 'UNKNOWN_STATE', detail: '无法读取 heartbeat(权限/ACL),保守拒绝' }
815
+ : { state: 'UNKNOWN_STATE', detail: 'heartbeat 读取失败,保守拒绝' }
816
+ }
817
+ const now = this.now()
818
+ const heartbeatFresh = heartbeat !== null && (now - heartbeat.heartbeatAt) <= this.staleAfterMs
819
+
820
+ if (heartbeatFresh) {
821
+ return { state: 'LOCKED', detail: `owner op=${rec.op} pid=${rec.owner.pid} (heartbeat fresh)` }
822
+ }
823
+
824
+ // heartbeat 已过期:先探测 PID liveness(kill(pid,0) 跨平台可靠;ESRCH = 确证不存在)
825
+ let ident: ProcessIdentity
826
+ try {
827
+ ident = await this.probe.probe(rec.owner.pid)
828
+ } catch {
829
+ return { state: 'UNKNOWN_STATE', detail: `进程探测失败 pid=${rec.owner.pid}` }
830
+ }
831
+ // 确证死亡(ESRCH)→ STALE(不依赖 OS identity 能力)
832
+ if (!ident.alive) {
833
+ return { state: 'STALE_LOCK_DETECTED', detail: `owner pid=${rec.owner.pid} 确证不存在 (heartbeat expired)` }
834
+ }
835
+ // PID 存活:需 OS identity 区分「reuse」与「同一进程 alive(heartbeat degraded)」
836
+ // —— capability/值缺失 → 无法可靠确定 → UNKNOWN_STATE(保守拒删)
837
+ if (!this.probe.canGetOsIdentity() || rec.owner.osProcessStartIdentity === null || ident.osProcessStartIdentity === null) {
838
+ // issue #36:Windows 默认无 OS identity 能力 + PID 会被复用 → 该分支此前永远停在
839
+ // UNKNOWN_STATE(连官方 recover-stale-lock 都拒绝),用户只能手工删锁文件。
840
+ // 心跳**长过期**(远超窗口,见 longExpiredAfterMs)说明写方早已停摆,判定为残留锁:
841
+ // 只影响显式回收与状态分类,acquire 侧依旧不自动摘锁。
842
+ const longExpired = this.longExpiredReason(heartbeat, now)
843
+ if (longExpired !== null) {
844
+ return {
845
+ state: 'STALE_LOCK_DETECTED',
846
+ detail: `owner pid=${rec.owner.pid} 存活但无法验证进程身份(可能是 PID 复用),且 heartbeat ${longExpired} → 判定为残留锁,可显式回收`,
847
+ }
848
+ }
849
+ return {
850
+ state: 'UNKNOWN_STATE',
851
+ detail: `heartbeat 过期且 pid=${rec.owner.pid} 存活,但无法可靠取得 OS process identity,保守拒绝删除`,
852
+ }
853
+ }
854
+ if (rec.owner.osProcessStartIdentity !== ident.osProcessStartIdentity) {
855
+ return { state: 'STALE_LOCK_DETECTED', detail: `pid reuse:pid=${rec.owner.pid} identity 与 recorded 不同` }
856
+ }
857
+ // alive 且 identity 相同 → owner alive(heartbeat degraded 保护)
858
+ return { state: 'LOCKED', detail: 'owner 进程存活(heartbeat 可能 degraded),非 stale' }
859
+ }
860
+
861
+ /* ------------------------------------------------------------ recover (explicit) */
862
+
863
+ /**
864
+ * 显式 stale recovery(独立动作;对应 CLI `--recover-stale-lock`)。
865
+ * 只执行:inspect → prove definitely stale → 原子 rename 捕获 → 二次验证 → unlink。
866
+ * **不自动触发**;仅当检测为 STALE_LOCK_DETECTED 才允许 capture。
867
+ * 二次验证失败 → quarantine(保留 recovering 文件,不 rename 回环境锁,不覆盖 successor)。
868
+ */
869
+ async recoverStaleLock(): Promise<RecoverResult> {
870
+ // 1. inspect:必须 definitely stale 或 corrupt(崩溃残留无有效 owner)
871
+ const insp = await this.inspectLockState()
872
+ const isCorruptReclaim = insp.state === 'UNKNOWN_STATE' && insp.detail?.includes('无有效 owner')
873
+ if (insp.state !== 'STALE_LOCK_DETECTED' && !isCorruptReclaim) {
874
+ return { ok: false, removed: false, state: insp.state, detail: insp.detail ?? '非 stale,拒绝 recovery' }
875
+ }
876
+ const st = await this.readOwnershipState()
877
+ const rec = st.kind === 'ok' ? st.rec : null
878
+ if (st.kind === 'missing') {
879
+ return { ok: false, removed: false, state: 'LOCKED', detail: 'ownership 已消失(被他人接管/清除),停止 recovery' }
880
+ }
881
+ // 2. 原子 rename 捕获当前 inode
882
+ const recoveringPath = path.join(this.locksDir, `${RECOVERING_PREFIX}${randomHex(8)}`)
883
+ try {
884
+ await this.io.rename(this.ownershipPath, recoveringPath)
885
+ } catch (e) {
886
+ return { ok: false, removed: false, state: classifyIoError(e, `rename ${this.ownershipPath} → ${recoveringPath}`, this.io).state, detail: '捕获 rename 失败(可能被他人接管),停止' }
887
+ }
888
+ // 3. 二次验证:captured 内容仍是被判 stale 的那个 owner
889
+ let capturedText: string
890
+ try {
891
+ capturedText = await this.io.readFileText(recoveringPath)
892
+ } catch (e) {
893
+ // 读不到 captured(异常)→ quarantine,不 rename 回
894
+ return { ok: false, removed: false, state: 'LOCK_IO_ERROR', detail: '捕获文件读取失败,保留 recovering 供诊断' }
895
+ }
896
+ const captured = parseJsonSafe(capturedText) as LockOwnershipRecord | null
897
+ if (isCorruptReclaim) {
898
+ // corrupt 回收:captured 无有效 owner(无 instanceId 可校验)→ 只确认它仍是非缺省非法内容 → unlink
899
+ // (captured 若已变成有效 owner,即 successor 在 rename 后才出现于 ownershipPath,与此 recovering 无涉)
900
+ if (captured !== null && typeof captured === 'object' && typeof captured.owner?.instanceId === 'string') {
901
+ // captured 突然变得有效(异常:rename 后被人写入)→ 保守 quarantine,不删
902
+ return {
903
+ ok: false, removed: false, state: 'UNKNOWN_STATE',
904
+ detail: `corrupt 回收二次验证异常:captured 具有效 owner,保留 ${path.basename(recoveringPath)} quarantine;不覆盖当前 environment.lock`,
905
+ }
906
+ }
907
+ } else {
908
+ // 有效 owner 的 stale 回收:校验 captured.instanceId 与 rec.instanceId 一致 + 二次 prove dead
909
+ const reProbeDead = await this.reProveStale(rec as LockOwnershipRecord)
910
+ if (captured === null || typeof captured !== 'object'
911
+ || captured.owner?.instanceId !== (rec as LockOwnershipRecord).owner.instanceId || !reProbeDead) {
912
+ // 二次验证失败 → quarantine:不 rename 回 environment.lock(防覆盖 successor),保留 recovering 文件
913
+ return {
914
+ ok: false, removed: false, state: 'UNKNOWN_STATE',
915
+ detail: `二次验证失败:保留 ${path.basename(recoveringPath)} quarantine;不覆盖当前 environment.lock(若有 successor)`,
916
+ }
917
+ }
918
+ }
919
+ // 4. 验证通过 → unlink captured
920
+ try {
921
+ await this.io.unlink(recoveringPath)
922
+ } catch (e) {
923
+ return { ok: false, removed: false, state: classifyIoError(e, `unlink ${recoveringPath}`, this.io).state, detail: '删除 captured 失败' }
924
+ }
925
+ // 5. 清理该 owner 的 heartbeat sidecar(按 stale instanceId 匹配;corrupt 时 rec=null 跳过)
926
+ if (rec !== null) {
927
+ try {
928
+ const sbPath = path.join(this.locksDir, `${HEARTBEAT_PREFIX}${(rec as LockOwnershipRecord).owner.instanceId}`)
929
+ await this.io.unlink(sbPath).catch(() => {})
930
+ } catch { /* 尽力 */ }
931
+ }
932
+ return {
933
+ ok: true, removed: true, state: 'STALE_LOCK_DETECTED',
934
+ detail: rec !== null ? `已移除 stale ownership (op=${(rec as LockOwnershipRecord).op}, pid=${(rec as LockOwnershipRecord).owner.pid})` : '已移除 corrupt/崩溃残留所有权文件',
935
+ }
936
+ }
937
+
938
+ /** recovery 二次验证:重新探测 recorded pid 是否确证死亡(保守——任何不确定性 → 失败)。
939
+ * 与 inspectLockState 同一套语义:alive:false 仅确证死亡 → stale 成立;
940
+ * alive 则需 OS identity 判断是否 reuse;无法确定 → 保守失败(不删除)。 */
941
+ private async reProveStale(rec: LockOwnershipRecord): Promise<boolean> {
942
+ let ident: ProcessIdentity
943
+ try { ident = await this.probe.probe(rec.owner.pid) } catch { return false }
944
+ // 确证死亡(ESRCH,alive:false 仅此语义)→ stale 成立
945
+ if (!ident.alive) return true
946
+ // 存活:需 identity 判断 reuse;无法可靠取得 → 保守失败
947
+ if (!this.probe.canGetOsIdentity() || rec.owner.osProcessStartIdentity === null || ident.osProcessStartIdentity === null) {
948
+ // issue #36:必须与 inspectLockState 用**同一**判据。否则首次判定「可回收」、二次验证却
949
+ // 判「非 stale」→ quarantine,用户拿到的仍是「二次验证失败」,等于没修。
950
+ const hb = await this.readHeartbeat(rec.owner.instanceId).catch(() => null)
951
+ return this.longExpiredReason(hb, this.now()) !== null
952
+ }
953
+ // recorded identity 与探测不同 → PID reuse → 原 owner 已死 → stale 成立
954
+ if (rec.owner.osProcessStartIdentity !== ident.osProcessStartIdentity) return true
955
+ // 同一进程仍存活 → 非 stale
956
+ return false
957
+ }
958
+
959
+ /** 心跳「长过期」判据(issue #36):达到阈值 → 返回可读原因,否则 null。
960
+ * heartbeat sidecar 缺失时返回 null(无从判断过期时长)——只对「确实读过 heartbeat
961
+ * 且它早已停更」的锁放宽,避免把「sidecar 被清掉」误判成残留。 */
962
+ private longExpiredReason(heartbeat: HeartbeatRecord | null, now: number): string | null {
963
+ if (heartbeat === null) return null
964
+ const age = now - heartbeat.heartbeatAt
965
+ if (!(age >= this.longExpiredAfterMs)) return null // NaN / 负值(时钟回拨)→ 保守
966
+ return `已过期 ${formatDuration(age)}(阈值 ${formatDuration(this.longExpiredAfterMs)})`
967
+ }
968
+
969
+ /* ------------------------------------------------------------ diag */
970
+
971
+ /** 列出 locks 目录内容(诊断;不存在 → 空) */
972
+ async listLockFiles(): Promise<string[]> {
973
+ try {
974
+ const dirs = await (this.io.listLocksDir ? this.io.listLocksDir(this.locksDir) : Promise.resolve([]))
975
+ return dirs
976
+ } catch { return [] }
977
+ }
978
+ }
979
+
980
+ // listLocksDir 可选增强:listLockFiles 使用 io.listLocksDir(若未提供 → 空数组)
981
+
982
+ function encode(v: unknown): Uint8Array {
983
+ return new TextEncoder().encode(JSON.stringify(v))
984
+ }
985
+
986
+ /** 毫秒 → 可读时长(诊断文案用;不追求精确,只求用户能一眼判断「多久没心跳了」) */
987
+ function formatDuration(ms: number): string {
988
+ if (ms >= 86_400_000) return `${(ms / 86_400_000).toFixed(1)} 天`
989
+ if (ms >= 3_600_000) return `${(ms / 3_600_000).toFixed(1)} 小时`
990
+ if (ms >= 60_000) return `${Math.round(ms / 60_000)} 分钟`
991
+ return `${Math.round(ms / 1000)} 秒`
992
+ }
993
+
994
+ function sleep(ms: number): Promise<void> {
995
+ return new Promise((r) => setTimeout(r, ms))
996
+ }
997
+
998
+ /** 按 §8.1 把 open/io 错误分类为 LockState(EEXIST 已在 acquire 单独处理) */
999
+ function classifyIoError(e: unknown, what: string, io: EnvLockIo): { state: LockState; token: null; detail: string } {
1000
+ const code = (e as { code?: string }).code
1001
+ if (code === 'EACCES' || code === 'EPERM') {
1002
+ return { state: 'PERMISSION_ERROR', token: null, detail: `${what}: 权限/ACL 错误 (${code}),非锁占用` }
1003
+ }
1004
+ return { state: 'LOCK_IO_ERROR', token: null, detail: `${what}: ${e instanceof Error ? e.message : String(e)}` }
1005
+ }
1006
+
1007
+ /** 当 release/recover 发现 ownership 已不属于本 token(异常恢复/人工修改)时抛出 */
1008
+ export class EnvironmentLockOwnedByAnotherError extends Error {
1009
+ constructor(msg: string) {
1010
+ super(msg)
1011
+ this.name = 'EnvironmentLockOwnedByAnotherError'
1012
+ }
1013
+ }
1014
+
1015
+ /** release 时 IO 失败(unlink/读 ownership)抛出;保留 activeToken 以便调用方重试 release */
1016
+ export class EnvironmentLockIOError extends Error {
1017
+ readonly underlyingCause: unknown
1018
+ constructor(msg: string, underlying?: unknown) {
1019
+ super(msg)
1020
+ this.name = 'EnvironmentLockIOError'
1021
+ this.underlyingCause = underlying
1022
+ }
1023
+ }
1024
+
1025
+ /** 锁被挡时向用户呈现的分类(用户可读文案据此选择,绝不暴露环境锁/op/路径等内部细节)。 */
1026
+ export type LockBlockReason =
1027
+ /** 被另一进程/任务活跃持有(LOCKED → 「另一个任务正在运行,请稍后重试」) */
1028
+ | 'locked'
1029
+ /** Phase 3 SAFE MODE 注入谓词阻断(isBlocked → 「配置修改已被保护,请先处理恢复事项」) */
1030
+ | 'blocked'
1031
+ /**
1032
+ * 检测到 stale 残留锁(上次进程异常退出,持有者已确证死亡)——**重试不会自愈**,
1033
+ * 必须显式回收(GUI「事故恢复」/ CLI `recover-stale-lock`)。见 issue #27。
1034
+ */
1035
+ | 'stale'
1036
+ /** 锁不可用/IO/权限/UNKNOWN(→ 「操作暂时无法执行,请稍后重试」) */
1037
+ | 'unavailable'
1038
+
1039
+ /** 按分类生成用户可读的友好文案(内部诊断不进入此文案;op/reason 作为字段供日志使用)。
1040
+ * 导出:后台调度器(自动同步/定时备份)被挡时用同一份文案写日志,避免两处文案漂移。 */
1041
+ export const LOCK_BLOCK_MESSAGE: Record<LockBlockReason, string> = {
1042
+ locked: '另一个任务正在运行,请稍后重试。',
1043
+ blocked: '配置修改已被保护,请先处理恢复事项后再继续。',
1044
+ unavailable: '操作暂时无法执行,请稍后重试;若持续失败请查看日志。',
1045
+ // 必须说清「重试/重启都不会好」并给出可操作路径:否则用户只会一遍遍重试(issue #27 实测如此)。
1046
+ // issue #31:文案承诺的「事故恢复」入口必须真的能回收残留锁——GUI 已接线
1047
+ // (GET /recovery/status 的 lock 字段 + POST /recovery/lock/recover),两处入口都真实可达。
1048
+ stale: '检测到上次异常退出残留的配置锁(其持有进程已不存在),操作已被阻止。'
1049
+ + '该锁不会自动清除,重试或重启 DSH 均无效:请在「事故恢复」中点击「回收残留锁」,'
1050
+ + '或运行 dsh-config-manager recover-stale-lock 回收后再重试。',
1051
+ }
1052
+
1053
+ /** 分类的**简短**文案(单行、可用于表格单元格/迁移历史摘要/日志前缀)。
1054
+ * 与 LOCK_BLOCK_MESSAGE 同源同分类:长文案给「需要完整指引」的场景(423 响应/告警日志),
1055
+ * 短文案给「一行放不下长句」的场景。分开定义避免任一处再自造文案(issue #31 的漂移根因)。 */
1056
+ export const LOCK_BLOCK_BRIEF: Record<LockBlockReason, string> = {
1057
+ locked: '环境锁被另一项任务占用',
1058
+ blocked: '配置修改已被保护,需先处理恢复事项',
1059
+ unavailable: '环境锁暂时不可用',
1060
+ // stale 必须点出「残留」——否则用户会像 issue #31 那样等 9 天(57 次静默跳过)。
1061
+ stale: '残留配置锁(持有进程已不存在),需先回收',
1062
+ }
1063
+
1064
+ /** destructive 必须成功获取 Environment Lock;否则抛此错(被另一进程/操作持有,或锁不可用)。
1065
+ * 携带 op 与 reason 供内部日志诊断;.message 恒为用户可读的友好文案(不暴露锁/op/路径)。 */
1066
+ export class EnvironmentLockUnavailableError extends Error {
1067
+ readonly reason: LockBlockReason
1068
+ readonly op: string
1069
+ /**
1070
+ * 内部诊断(非敏感):占用方 op/hostname 或 stale 判定依据。**只进日志**,
1071
+ * 绝不进入 .message(用户文案里不含 op/路径/主机名)。
1072
+ */
1073
+ readonly detail: string | undefined
1074
+ constructor(op: string, reason: LockBlockReason = 'locked', detail?: string) {
1075
+ super(LOCK_BLOCK_MESSAGE[reason])
1076
+ this.name = 'EnvironmentLockUnavailableError'
1077
+ this.reason = reason
1078
+ this.op = op
1079
+ this.detail = detail
1080
+ }
1081
+ }
1082
+
1083
+ /** 便捷:以 token 授权当前持有(供 nested operation 判断复用)—— 等价于 manager.validate 的纯函数形态 */
1084
+ export function isTokenValid(manager: EnvironmentLockManager, ctx: MutationLockContext | undefined): ctx is MutationLockContext {
1085
+ return ctx !== undefined && typeof ctx === 'object' && manager.validate(ctx.token)
1086
+ }