@harperfast/harper 5.3.0-alpha.1 → 5.3.0-beta.2

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 (347) hide show
  1. package/bin/cliOperations.ts +12 -0
  2. package/bin/restart.ts +66 -6
  3. package/components/Application.ts +1134 -112
  4. package/components/DESIGN.md +421 -0
  5. package/components/OptionsWatcher.ts +368 -102
  6. package/components/Scope.ts +20 -1
  7. package/components/componentLoader.ts +43 -4
  8. package/components/deploymentOperations.ts +4 -1
  9. package/components/deploymentRecorder.ts +9 -2
  10. package/components/mcp/DESIGN.md +109 -0
  11. package/components/mcp/audit.ts +21 -17
  12. package/components/operations.js +284 -52
  13. package/components/operationsValidation.js +49 -2
  14. package/components/packageComponent.ts +25 -1
  15. package/components/requestRestart.ts +11 -0
  16. package/config/DESIGN.md +306 -0
  17. package/config/RootConfigWatcher.ts +191 -37
  18. package/config/configReadRetry.ts +62 -0
  19. package/config/configUtils.ts +78 -26
  20. package/config/parseConfigFile.ts +34 -0
  21. package/config/readConfigFileSync.ts +44 -0
  22. package/config/watcherArming.ts +59 -0
  23. package/config-root.schema.json +4 -0
  24. package/dataLayer/DESIGN.md +179 -0
  25. package/dataLayer/harperBridge/ResourceBridge.ts +28 -2
  26. package/dataLayer/restoreMarker.ts +92 -25
  27. package/dist/bin/cliOperations.js +13 -0
  28. package/dist/bin/cliOperations.js.map +1 -1
  29. package/dist/bin/restart.js +42 -6
  30. package/dist/bin/restart.js.map +1 -1
  31. package/dist/components/Application.d.ts +104 -9
  32. package/dist/components/Application.js +954 -102
  33. package/dist/components/Application.js.map +1 -1
  34. package/dist/components/OptionsWatcher.d.ts +4 -1
  35. package/dist/components/OptionsWatcher.js +378 -104
  36. package/dist/components/OptionsWatcher.js.map +1 -1
  37. package/dist/components/Scope.js +15 -1
  38. package/dist/components/Scope.js.map +1 -1
  39. package/dist/components/componentLoader.js +35 -3
  40. package/dist/components/componentLoader.js.map +1 -1
  41. package/dist/components/deploymentOperations.js +4 -1
  42. package/dist/components/deploymentOperations.js.map +1 -1
  43. package/dist/components/deploymentRecorder.d.ts +4 -2
  44. package/dist/components/deploymentRecorder.js +1 -0
  45. package/dist/components/deploymentRecorder.js.map +1 -1
  46. package/dist/components/mcp/audit.d.ts +2 -1
  47. package/dist/components/mcp/audit.js +21 -17
  48. package/dist/components/mcp/audit.js.map +1 -1
  49. package/dist/components/operations.d.ts +28 -0
  50. package/dist/components/operations.js +263 -54
  51. package/dist/components/operations.js.map +1 -1
  52. package/dist/components/operationsValidation.js +48 -2
  53. package/dist/components/operationsValidation.js.map +1 -1
  54. package/dist/components/packageComponent.js +24 -0
  55. package/dist/components/packageComponent.js.map +1 -1
  56. package/dist/components/requestRestart.d.ts +1 -0
  57. package/dist/components/requestRestart.js +7 -0
  58. package/dist/components/requestRestart.js.map +1 -1
  59. package/dist/config/RootConfigWatcher.d.ts +2 -0
  60. package/dist/config/RootConfigWatcher.js +189 -35
  61. package/dist/config/RootConfigWatcher.js.map +1 -1
  62. package/dist/config/configReadRetry.d.ts +8 -0
  63. package/dist/config/configReadRetry.js +62 -0
  64. package/dist/config/configReadRetry.js.map +1 -0
  65. package/dist/config/configUtils.d.ts +10 -9
  66. package/dist/config/configUtils.js +63 -27
  67. package/dist/config/configUtils.js.map +1 -1
  68. package/dist/config/parseConfigFile.d.ts +4 -0
  69. package/dist/config/parseConfigFile.js +35 -0
  70. package/dist/config/parseConfigFile.js.map +1 -0
  71. package/dist/config/readConfigFileSync.d.ts +1 -0
  72. package/dist/config/readConfigFileSync.js +47 -0
  73. package/dist/config/readConfigFileSync.js.map +1 -0
  74. package/dist/config/watcherArming.d.ts +15 -0
  75. package/dist/config/watcherArming.js +59 -0
  76. package/dist/config/watcherArming.js.map +1 -0
  77. package/dist/dataLayer/harperBridge/ResourceBridge.js +21 -2
  78. package/dist/dataLayer/harperBridge/ResourceBridge.js.map +1 -1
  79. package/dist/dataLayer/restoreMarker.d.ts +21 -8
  80. package/dist/dataLayer/restoreMarker.js +94 -27
  81. package/dist/dataLayer/restoreMarker.js.map +1 -1
  82. package/dist/index.d.ts +2 -0
  83. package/dist/index.js +9 -1
  84. package/dist/index.js.map +1 -1
  85. package/dist/json/systemSchema.json +3 -0
  86. package/dist/resources/DatabaseTransaction.d.ts +25 -0
  87. package/dist/resources/DatabaseTransaction.js +224 -18
  88. package/dist/resources/DatabaseTransaction.js.map +1 -1
  89. package/dist/resources/LMDBTransaction.d.ts +2 -1
  90. package/dist/resources/LMDBTransaction.js +22 -3
  91. package/dist/resources/LMDBTransaction.js.map +1 -1
  92. package/dist/resources/PrimaryRocksDatabase.js +22 -6
  93. package/dist/resources/PrimaryRocksDatabase.js.map +1 -1
  94. package/dist/resources/RecordEncoder.d.ts +1 -1
  95. package/dist/resources/RecordEncoder.js +46 -10
  96. package/dist/resources/RecordEncoder.js.map +1 -1
  97. package/dist/resources/Resource.js +97 -13
  98. package/dist/resources/Resource.js.map +1 -1
  99. package/dist/resources/ResourceInterface.d.ts +8 -0
  100. package/dist/resources/RocksIndexStore.js +2 -1
  101. package/dist/resources/RocksIndexStore.js.map +1 -1
  102. package/dist/resources/RocksTransactionLogStore.d.ts +10 -0
  103. package/dist/resources/RocksTransactionLogStore.js +104 -33
  104. package/dist/resources/RocksTransactionLogStore.js.map +1 -1
  105. package/dist/resources/Table.d.ts +58 -7
  106. package/dist/resources/Table.js +1143 -348
  107. package/dist/resources/Table.js.map +1 -1
  108. package/dist/resources/analytics/write.d.ts +3 -0
  109. package/dist/resources/analytics/write.js +49 -16
  110. package/dist/resources/analytics/write.js.map +1 -1
  111. package/dist/resources/auditStore.d.ts +170 -0
  112. package/dist/resources/auditStore.js +457 -11
  113. package/dist/resources/auditStore.js.map +1 -1
  114. package/dist/resources/crdt.d.ts +10 -0
  115. package/dist/resources/crdt.js +22 -0
  116. package/dist/resources/crdt.js.map +1 -1
  117. package/dist/resources/dataLoader.js +3 -4
  118. package/dist/resources/dataLoader.js.map +1 -1
  119. package/dist/resources/databases.d.ts +16 -13
  120. package/dist/resources/databases.js +625 -177
  121. package/dist/resources/databases.js.map +1 -1
  122. package/dist/resources/derivedIndexRegistry.d.ts +5 -0
  123. package/dist/resources/derivedIndexRegistry.js +68 -0
  124. package/dist/resources/derivedIndexRegistry.js.map +1 -0
  125. package/dist/resources/derivedIndexRuntime.d.ts +215 -0
  126. package/dist/resources/derivedIndexRuntime.js +2027 -0
  127. package/dist/resources/derivedIndexRuntime.js.map +1 -0
  128. package/dist/resources/graphql.js +3 -2
  129. package/dist/resources/graphql.js.map +1 -1
  130. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.d.ts +102 -9
  131. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js +867 -39
  132. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js.map +1 -1
  133. package/dist/resources/indexes/fullTextDerivedIndex.d.ts +81 -0
  134. package/dist/resources/indexes/fullTextDerivedIndex.js +1004 -0
  135. package/dist/resources/indexes/fullTextDerivedIndex.js.map +1 -0
  136. package/dist/resources/indexes/fullTextNativeBinding.d.ts +78 -0
  137. package/dist/resources/indexes/fullTextNativeBinding.js +85 -0
  138. package/dist/resources/indexes/fullTextNativeBinding.js.map +1 -0
  139. package/dist/resources/indexes/hnswDerivedIndex.d.ts +67 -0
  140. package/dist/resources/indexes/hnswDerivedIndex.js +464 -0
  141. package/dist/resources/indexes/hnswDerivedIndex.js.map +1 -0
  142. package/dist/resources/indexes/hnswPlaneBinding.d.ts +65 -0
  143. package/dist/resources/indexes/hnswPlaneBinding.js +91 -0
  144. package/dist/resources/indexes/hnswPlaneBinding.js.map +1 -0
  145. package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.d.ts +24 -0
  146. package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.js +149 -0
  147. package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.js.map +1 -0
  148. package/dist/resources/nodeIdMapping.d.ts +5 -0
  149. package/dist/resources/nodeIdMapping.js +49 -0
  150. package/dist/resources/nodeIdMapping.js.map +1 -1
  151. package/dist/resources/recordLock.d.ts +47 -4
  152. package/dist/resources/recordLock.js +138 -7
  153. package/dist/resources/recordLock.js.map +1 -1
  154. package/dist/resources/recordLockCoordinator.d.ts +557 -0
  155. package/dist/resources/recordLockCoordinator.js +2597 -0
  156. package/dist/resources/recordLockCoordinator.js.map +1 -0
  157. package/dist/resources/replayLogs.js +5 -0
  158. package/dist/resources/replayLogs.js.map +1 -1
  159. package/dist/resources/replicatedApplyFailure.d.ts +16 -0
  160. package/dist/resources/replicatedApplyFailure.js +63 -0
  161. package/dist/resources/replicatedApplyFailure.js.map +1 -0
  162. package/dist/resources/scheduler/scheduler.js +3 -3
  163. package/dist/resources/scheduler/scheduler.js.map +1 -1
  164. package/dist/resources/search.d.ts +10 -4
  165. package/dist/resources/search.js +160 -40
  166. package/dist/resources/search.js.map +1 -1
  167. package/dist/resources/tracked.d.ts +5 -1
  168. package/dist/resources/tracked.js +74 -23
  169. package/dist/resources/tracked.js.map +1 -1
  170. package/dist/resources/transactionBroadcast.js +4 -6
  171. package/dist/resources/transactionBroadcast.js.map +1 -1
  172. package/dist/security/auth.js +59 -23
  173. package/dist/security/auth.js.map +1 -1
  174. package/dist/security/deferredAuthentication.d.ts +11 -0
  175. package/dist/security/deferredAuthentication.js +25 -3
  176. package/dist/security/deferredAuthentication.js.map +1 -1
  177. package/dist/security/jsLoader.js +9 -4
  178. package/dist/security/jsLoader.js.map +1 -1
  179. package/dist/server/REST.js +39 -5
  180. package/dist/server/REST.js.map +1 -1
  181. package/dist/server/http.d.ts +5 -1
  182. package/dist/server/http.js +34 -2
  183. package/dist/server/http.js.map +1 -1
  184. package/dist/server/mqtt.js +5 -1
  185. package/dist/server/mqtt.js.map +1 -1
  186. package/dist/server/serverHelpers/Headers.d.ts +2 -0
  187. package/dist/server/serverHelpers/Headers.js +6 -0
  188. package/dist/server/serverHelpers/Headers.js.map +1 -1
  189. package/dist/server/serverHelpers/NodeAdapterResponse.d.ts +48 -0
  190. package/dist/server/serverHelpers/NodeAdapterResponse.js +220 -0
  191. package/dist/server/serverHelpers/NodeAdapterResponse.js.map +1 -0
  192. package/dist/server/serverHelpers/Request.d.ts +5 -10
  193. package/dist/server/serverHelpers/Request.js +38 -136
  194. package/dist/server/serverHelpers/Request.js.map +1 -1
  195. package/dist/server/serverHelpers/contentTypes.d.ts +2 -0
  196. package/dist/server/serverHelpers/contentTypes.js +189 -15
  197. package/dist/server/serverHelpers/contentTypes.js.map +1 -1
  198. package/dist/server/serverHelpers/serverUtilities.d.ts +3 -3
  199. package/dist/server/serverHelpers/serverUtilities.js +110 -20
  200. package/dist/server/serverHelpers/serverUtilities.js.map +1 -1
  201. package/dist/server/serverHelpers/uwsServer.js +4 -1
  202. package/dist/server/serverHelpers/uwsServer.js.map +1 -1
  203. package/dist/server/serverHelpers/webSocketCloseReason.d.ts +2 -0
  204. package/dist/server/serverHelpers/webSocketCloseReason.js +29 -0
  205. package/dist/server/serverHelpers/webSocketCloseReason.js.map +1 -0
  206. package/dist/server/storageReclamation.js +1 -1
  207. package/dist/server/storageReclamation.js.map +1 -1
  208. package/dist/server/threads/isolatedApplications.d.ts +47 -0
  209. package/dist/server/threads/isolatedApplications.js +171 -0
  210. package/dist/server/threads/isolatedApplications.js.map +1 -0
  211. package/dist/server/threads/logRotationTransport.d.ts +1 -0
  212. package/dist/server/threads/logRotationTransport.js +33 -0
  213. package/dist/server/threads/logRotationTransport.js.map +1 -0
  214. package/dist/server/threads/manageThreads.d.ts +64 -6
  215. package/dist/server/threads/manageThreads.js +261 -12
  216. package/dist/server/threads/manageThreads.js.map +1 -1
  217. package/dist/server/threads/socketRouter.d.ts +1 -0
  218. package/dist/server/threads/socketRouter.js +196 -13
  219. package/dist/server/threads/socketRouter.js.map +1 -1
  220. package/dist/server/threads/threadServer.js +30 -7
  221. package/dist/server/threads/threadServer.js.map +1 -1
  222. package/dist/utility/errors/hdbError.d.ts +24 -0
  223. package/dist/utility/errors/hdbError.js +58 -1
  224. package/dist/utility/errors/hdbError.js.map +1 -1
  225. package/dist/utility/hdbTerms.d.ts +2 -0
  226. package/dist/utility/hdbTerms.js +2 -0
  227. package/dist/utility/hdbTerms.js.map +1 -1
  228. package/dist/utility/logging/harper_logger.js +218 -38
  229. package/dist/utility/logging/harper_logger.js.map +1 -1
  230. package/dist/utility/logging/logGenerationCoordinator.d.ts +35 -0
  231. package/dist/utility/logging/logGenerationCoordinator.js +184 -0
  232. package/dist/utility/logging/logGenerationCoordinator.js.map +1 -0
  233. package/dist/utility/logging/logRotation.d.ts +46 -0
  234. package/dist/utility/logging/logRotation.js +365 -0
  235. package/dist/utility/logging/logRotation.js.map +1 -0
  236. package/dist/utility/logging/logRotator.d.ts +1 -1
  237. package/dist/utility/logging/logRotator.js +172 -92
  238. package/dist/utility/logging/logRotator.js.map +1 -1
  239. package/dist/utility/npmUtilities.js +6 -4
  240. package/dist/utility/npmUtilities.js.map +1 -1
  241. package/dist/utility/watcherFallback.d.ts +0 -45
  242. package/dist/utility/watcherFallback.js +1 -125
  243. package/dist/utility/watcherFallback.js.map +1 -1
  244. package/dist/validation/configValidator.js +6 -3
  245. package/dist/validation/configValidator.js.map +1 -1
  246. package/index.ts +9 -0
  247. package/json/systemSchema.json +3 -0
  248. package/npm-shrinkwrap.json +177 -87
  249. package/package.json +13 -5
  250. package/resources/DESIGN.md +689 -19
  251. package/resources/DatabaseTransaction.ts +230 -17
  252. package/resources/LMDBTransaction.ts +21 -3
  253. package/resources/PrimaryRocksDatabase.ts +20 -7
  254. package/resources/RecordEncoder.ts +52 -10
  255. package/resources/Resource.ts +97 -13
  256. package/resources/ResourceInterface.ts +8 -0
  257. package/resources/RocksIndexStore.ts +2 -1
  258. package/resources/RocksTransactionLogStore.ts +111 -31
  259. package/resources/Table.ts +1273 -396
  260. package/resources/analytics/DESIGN.md +38 -0
  261. package/resources/analytics/write.ts +50 -17
  262. package/resources/auditStore.ts +460 -11
  263. package/resources/crdt.ts +22 -0
  264. package/resources/dataLoader.ts +3 -4
  265. package/resources/databases.ts +611 -147
  266. package/resources/derivedIndexRegistry.ts +56 -0
  267. package/resources/derivedIndexRuntime.ts +2292 -0
  268. package/resources/graphql.ts +3 -2
  269. package/resources/indexes/DESIGN.md +833 -0
  270. package/resources/indexes/HierarchicalNavigableSmallWorld.ts +924 -46
  271. package/resources/indexes/fullTextDerivedIndex.ts +1165 -0
  272. package/resources/indexes/fullTextNativeBinding.ts +146 -0
  273. package/resources/indexes/hnswDerivedIndex.ts +531 -0
  274. package/resources/indexes/hnswPlaneBinding.ts +174 -0
  275. package/resources/indexes/nativeFullTextDerivedIndexLifecycle.ts +181 -0
  276. package/resources/nodeIdMapping.ts +50 -0
  277. package/resources/record-locks.md +1407 -0
  278. package/resources/recordLock.ts +173 -7
  279. package/resources/recordLockCoordinator.ts +3082 -0
  280. package/resources/replayLogs.ts +5 -0
  281. package/resources/replicatedApplyFailure.ts +77 -0
  282. package/resources/scheduler/DESIGN.md +40 -0
  283. package/resources/scheduler/scheduler.ts +4 -4
  284. package/resources/search.ts +169 -49
  285. package/resources/tracked.ts +73 -22
  286. package/resources/transactionBroadcast.ts +4 -4
  287. package/security/DESIGN.md +175 -0
  288. package/security/auth.ts +53 -24
  289. package/security/deferredAuthentication.ts +24 -2
  290. package/security/jsLoader.ts +9 -4
  291. package/server/DESIGN.md +275 -0
  292. package/server/REST.ts +42 -6
  293. package/server/http.ts +34 -2
  294. package/server/mqtt.ts +6 -4
  295. package/server/serverHelpers/Headers.ts +5 -1
  296. package/server/serverHelpers/NodeAdapterResponse.ts +221 -0
  297. package/server/serverHelpers/Request.ts +33 -131
  298. package/server/serverHelpers/contentTypes.ts +188 -15
  299. package/server/serverHelpers/serverUtilities.ts +157 -27
  300. package/server/serverHelpers/uwsServer.ts +4 -1
  301. package/server/serverHelpers/webSocketCloseReason.ts +25 -0
  302. package/server/storageReclamation.ts +2 -2
  303. package/server/threads/isolatedApplications.ts +157 -0
  304. package/server/threads/logRotationTransport.ts +40 -0
  305. package/server/threads/manageThreads.js +254 -12
  306. package/server/threads/socketRouter.ts +217 -11
  307. package/server/threads/threadServer.js +30 -7
  308. package/studio/web/assets/{Chat-BnCBegQz.js → Chat-DADFFGe_.js} +1 -1
  309. package/studio/web/assets/{FloatingChat-CoDW1ySS.js → FloatingChat-D_mI-rZ7.js} +3 -3
  310. package/studio/web/assets/{apiToken-Bwk5BLXW.js → apiToken-c2NiSDHa.js} +1 -1
  311. package/studio/web/assets/{applications-DHxGi7JH.js → applications-DktUqh7G.js} +1 -1
  312. package/studio/web/assets/{cssMode-s0cWI-_M.js → cssMode-Cs_75Xhw.js} +1 -1
  313. package/studio/web/assets/{editor-DNcRHK54.js → editor-19b-Y1IN.js} +1 -1
  314. package/studio/web/assets/{html-Bdssedlg.js → html-DiYEQMpB.js} +1 -1
  315. package/studio/web/assets/{htmlMode-CoDlJ3fw.js → htmlMode-CmR0y7P_.js} +1 -1
  316. package/studio/web/assets/{index-D6sxmFLR.js → index-Dm0rfkJ7.js} +5 -5
  317. package/studio/web/assets/{index.lazy-tmU5BS8s.js → index.lazy-7vqt2CC3.js} +1 -1
  318. package/studio/web/assets/{javascript-B8meVSTH.js → javascript-BWtCFuOt.js} +1 -1
  319. package/studio/web/assets/{jsonMode-DpIPd35T.js → jsonMode-Buzzbv9y.js} +1 -1
  320. package/studio/web/assets/{languageServices-C_5FMJzQ.js → languageServices-SqsFWfTM.js} +1 -1
  321. package/studio/web/assets/{lspLanguageFeatures-BIzNBzPK.js → lspLanguageFeatures-EMV5cmjo.js} +1 -1
  322. package/studio/web/assets/{notifications-CvZivSbh.js → notifications-CAB-LZWT.js} +1 -1
  323. package/studio/web/assets/{notifications-CQf18QKb.js → notifications-DRzmSRxM.js} +1 -1
  324. package/studio/web/assets/{profile-DdOwtntb.js → profile-BNKAl79n.js} +1 -1
  325. package/studio/web/assets/{regions-n69fwagr.js → regions-CUow_Zw2.js} +1 -1
  326. package/studio/web/assets/{register-PfWTCXWB.js → register-Dkt3WUMp.js} +2 -2
  327. package/studio/web/assets/{setComponentFile-Bg6O7X0S.js → setComponentFile-BZRfMD0N.js} +1 -1
  328. package/studio/web/assets/{setup-CUx_aUDl.js → setup-D_yiEPO2.js} +2 -2
  329. package/studio/web/assets/{status-D7BVKqX9.js → status-DhHh1Ge-.js} +1 -1
  330. package/studio/web/assets/{toggleHighContrast-DBSyXzMr.js → toggleHighContrast-D7L1PDtV.js} +1 -1
  331. package/studio/web/assets/{tsMode-BByKCjBS.js → tsMode-CCwLk1YS.js} +1 -1
  332. package/studio/web/assets/{typescript-DDLnLpw9.js → typescript-BP1j1mjn.js} +1 -1
  333. package/studio/web/assets/{useEntityRestURL-31CHGaHk.js → useEntityRestURL-D7bnYxLw.js} +1 -1
  334. package/studio/web/assets/{workers-pR3jRY9D.js → workers-tOuCNT17.js} +1 -1
  335. package/studio/web/assets/{xml-2iRnMhQO.js → xml-BSG_3mQT.js} +1 -1
  336. package/studio/web/assets/{yaml-Bf92gJpd.js → yaml-DxiLprBB.js} +1 -1
  337. package/studio/web/index.html +1 -1
  338. package/utility/DESIGN.md +55 -0
  339. package/utility/errors/hdbError.ts +54 -0
  340. package/utility/hdbTerms.ts +2 -0
  341. package/utility/logging/harper_logger.ts +210 -30
  342. package/utility/logging/logGenerationCoordinator.ts +196 -0
  343. package/utility/logging/logRotation.ts +367 -0
  344. package/utility/logging/logRotator.ts +196 -91
  345. package/utility/npmUtilities.ts +6 -4
  346. package/utility/watcherFallback.ts +0 -122
  347. package/validation/configValidator.ts +6 -3
@@ -38,6 +38,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.Application = exports.GIT_CREDENTIAL_HELPER_PATH = exports.DEPLOY_STAGING_DIR = exports.ASIDE_STAGING_DIR = exports.InvalidCredentialEntryError = exports.InvalidBranchedDatabasesError = exports.InvalidCredentialsPropertyError = exports.InvalidInstallTimeoutError = exports.InvalidInstallCommandError = exports.InvalidInstallPropertyError = exports.InvalidPackageIdentifierError = void 0;
40
40
  exports.assertApplicationConfig = assertApplicationConfig;
41
+ exports.assertIsolationConfig = assertIsolationConfig;
41
42
  exports.assertBranchedDatabases = assertBranchedDatabases;
42
43
  exports.isSSHAuthFailure = isSSHAuthFailure;
43
44
  exports.parseGitReference = parseGitReference;
@@ -47,11 +48,15 @@ exports.readInstalledPackageMetadata = readInstalledPackageMetadata;
47
48
  exports.installedPackageMetadataEqual = installedPackageMetadataEqual;
48
49
  exports.installedRuntimeChanged = installedRuntimeChanged;
49
50
  exports.extractApplication = extractApplication;
51
+ exports.getStagingRetentionMaxCount = getStagingRetentionMaxCount;
52
+ exports.pruneDormantBuilds = pruneDormantBuilds;
50
53
  exports.candidateApplicationPath = candidateApplicationPath;
51
54
  exports.makeRollbackPlaceholderMovable = makeRollbackPlaceholderMovable;
55
+ exports.publishClaimOwnership = publishClaimOwnership;
52
56
  exports.splitAttributionOwners = splitAttributionOwners;
53
57
  exports.unsettleableComponentsFromDisk = unsettleableComponentsFromDisk;
54
58
  exports.recoverInterruptedActivations = recoverInterruptedActivations;
59
+ exports.reconcileDormantBuilds = reconcileDormantBuilds;
55
60
  exports.markCandidateComplete = markCandidateComplete;
56
61
  exports.activateCandidateApplication = activateCandidateApplication;
57
62
  exports.buildCandidateApplication = buildCandidateApplication;
@@ -76,6 +81,7 @@ exports.terminateProcessTree = terminateProcessTree;
76
81
  exports.getEnvBuiltInComponents = getEnvBuiltInComponents;
77
82
  const configUtils_ts_1 = require("../config/configUtils.js");
78
83
  const hdbTerms_ts_1 = require("../utility/hdbTerms.js");
84
+ const hdbError_ts_1 = require("../utility/errors/hdbError.js");
79
85
  const harper_logger_ts_1 = __importStar(require("../utility/logging/harper_logger.js"));
80
86
  const deployLifecycle_ts_1 = require("./deployLifecycle.js");
81
87
  const componentPreparationLock_ts_1 = require("./componentPreparationLock.js");
@@ -195,6 +201,12 @@ function assertApplicationConfig(applicationName, applicationConfig) {
195
201
  }
196
202
  }
197
203
  assertBranchedDatabases(applicationName, applicationConfig.branchedDatabases);
204
+ assertIsolationConfig(applicationName, applicationConfig.isolated);
205
+ }
206
+ function assertIsolationConfig(applicationName, isolated) {
207
+ if (isolated !== undefined && typeof isolated !== 'boolean') {
208
+ throw new TypeError(`Invalid 'isolated' for application ${applicationName}: expected a boolean, got ${typeof isolated}`);
209
+ }
198
210
  }
199
211
  /**
200
212
  * A branch that cannot be honoured fails the application's load: falling back would hand it the
@@ -909,7 +921,120 @@ const CANDIDATE_COMPONENT_FILE = '.component';
909
921
  // well-formed. Workers cannot infer that case: a well-formed journal is indistinguishable from one belonging
910
922
  // to a deploy in flight, so without a record they would treat an unsettled component as healthy and load it.
911
923
  const UNSETTLED_MARKER = '.unsettled';
924
+ // Everything the build decided that a later activation cannot re-derive: the root-config entry to publish,
925
+ // whether the installation is opaque to metadata comparison, and the isolation intent that was admitted.
926
+ // Written before `.complete`, so the marker vouches for it. An OPTIONAL record would not do: it could not
927
+ // distinguish a payload build, which owns no root config, from a package build whose record was lost.
928
+ const CANDIDATE_ARTIFACT_FILE = '.artifact.json';
912
929
  const ACTIVATION_JOURNAL_VERSION = 1;
930
+ const ARTIFACT_DESCRIPTOR_VERSION = 1;
931
+ const DEFAULT_STAGING_RETENTION_MAX_COUNT = 5;
932
+ /** `deployment_stagingRetention_maxCount`; 0 keeps none. Only a number or numeric string counts, so `true`/`[]`/blank cannot become "keep nothing". */
933
+ function getStagingRetentionMaxCount() {
934
+ const configured = (0, configUtils_ts_1.getConfigValue)(hdbTerms_ts_1.CONFIG_PARAMS.DEPLOYMENT_STAGINGRETENTION_MAXCOUNT);
935
+ if (typeof configured !== 'number' && typeof configured !== 'string')
936
+ return DEFAULT_STAGING_RETENTION_MAX_COUNT;
937
+ if (typeof configured === 'string' && configured.trim() === '')
938
+ return DEFAULT_STAGING_RETENTION_MAX_COUNT;
939
+ const parsed = Number(configured);
940
+ return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : DEFAULT_STAGING_RETENTION_MAX_COUNT;
941
+ }
942
+ async function presentOrAbsent(path) {
943
+ return (0, promises_1.lstat)(path).catch((error) => {
944
+ if (error?.code === 'ENOENT')
945
+ return undefined;
946
+ throw error;
947
+ });
948
+ }
949
+ /**
950
+ * A dormant build: complete, tree present, no journal. Activation writes `.complete` moments before its
951
+ * journal under the owner's preparation lock, so only a read under that lock is a verdict. A stale
952
+ * `.unsettled` makes it residue instead, since only removing the directory clears that marker for workers.
953
+ * Only ENOENT is absence; any other read error propagates so the caller preserves the entry.
954
+ */
955
+ async function dormantBuildAt(deploymentDirPath, owner) {
956
+ const complete = await presentOrAbsent((0, node_path_1.join)(deploymentDirPath, CANDIDATE_COMPLETE_MARKER));
957
+ if (!complete)
958
+ return undefined;
959
+ if (await presentOrAbsent((0, node_path_1.join)(deploymentDirPath, UNSETTLED_MARKER)))
960
+ return undefined;
961
+ const tree = await presentOrAbsent((0, node_path_1.join)(deploymentDirPath, owner));
962
+ if (!tree || !(tree.isDirectory() || tree.isSymbolicLink()))
963
+ return undefined;
964
+ return { deploymentDirPath, deploymentId: (0, node_path_1.basename)(deploymentDirPath), completedAt: complete.mtimeMs };
965
+ }
966
+ /**
967
+ * Remove the oldest dormant builds beyond `maxCount`. The caller must hold the component's preparation lock;
968
+ * every catalogued build is re-derived under it before the kept set is chosen, so a catalog read unlocked
969
+ * cannot hold or miss a slot. Never throws: a failure must neither fail a component closed nor replace a
970
+ * deploy's own error.
971
+ *
972
+ * `pinnedDeploymentId` is never evicted. A delayed activation runs this preamble under the same lock it is
973
+ * about to activate under, so without the pin retention would delete the artifact the request named —
974
+ * immediately, when the knob is `0`. The pin is applied after the kept set is chosen, so a pinned build in
975
+ * the eviction tail leaves `maxCount + 1` on disk for the life of the request; the next preamble that does
976
+ * not pin it brings the count back down.
977
+ */
978
+ async function pruneDormantBuilds(componentName, builds, maxCount, pinnedDeploymentId) {
979
+ const current = [];
980
+ for (const build of builds) {
981
+ try {
982
+ const fresh = await dormantBuildAt(build.deploymentDirPath, componentName);
983
+ if (fresh && !(await presentOrAbsent((0, node_path_1.join)(build.deploymentDirPath, ACTIVATION_JOURNAL))))
984
+ current.push(fresh);
985
+ }
986
+ catch (error) {
987
+ harper_logger_ts_1.default.warn(`Leaving deploy staging ${build.deploymentDirPath} out of retention; it could not be read:`, (0, harper_logger_ts_1.errorForLog)(error));
988
+ }
989
+ }
990
+ const evictions = current
991
+ .sort((left, right) => right.completedAt - left.completedAt ||
992
+ (left.deploymentId < right.deploymentId ? -1 : left.deploymentId > right.deploymentId ? 1 : 0))
993
+ .slice(Math.max(0, maxCount))
994
+ .filter((build) => build.deploymentId !== pinnedDeploymentId);
995
+ for (const build of evictions) {
996
+ try {
997
+ await (0, promises_1.rm)(build.deploymentDirPath, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
998
+ harper_logger_ts_1.default.debug?.(`Pruned dormant staged build ${build.deploymentId} of ${componentName} beyond deployment_stagingRetention_maxCount=${maxCount}`);
999
+ }
1000
+ catch (error) {
1001
+ harper_logger_ts_1.default.warn(`Could not prune dormant staged build ${build.deploymentDirPath} of ${componentName}; it remains beyond ` +
1002
+ `deployment_stagingRetention_maxCount=${maxCount}:`, (0, harper_logger_ts_1.errorForLog)(error));
1003
+ }
1004
+ }
1005
+ }
1006
+ /** Every dormant build a component owns; an unreadable directory is left out and logged. */
1007
+ async function dormantBuildsOf(componentsRootDirPath, componentName) {
1008
+ const stagingRoot = (0, node_path_1.join)(componentsRootDirPath, exports.DEPLOY_STAGING_DIR);
1009
+ let deployments;
1010
+ try {
1011
+ deployments = await (0, promises_1.readdir)(stagingRoot, { withFileTypes: true });
1012
+ }
1013
+ catch (error) {
1014
+ if (error.code === 'ENOENT')
1015
+ return [];
1016
+ throw error;
1017
+ }
1018
+ const builds = [];
1019
+ for (const deployment of deployments) {
1020
+ if (!deployment.isDirectory())
1021
+ continue;
1022
+ const deploymentDirPath = (0, node_path_1.join)(stagingRoot, deployment.name);
1023
+ try {
1024
+ if ((await candidateComponentName(deploymentDirPath)) !== componentName)
1025
+ continue;
1026
+ if (await presentOrAbsent((0, node_path_1.join)(deploymentDirPath, ACTIVATION_JOURNAL)))
1027
+ continue;
1028
+ const build = await dormantBuildAt(deploymentDirPath, componentName);
1029
+ if (build)
1030
+ builds.push(build);
1031
+ }
1032
+ catch (error) {
1033
+ harper_logger_ts_1.default.warn(`Leaving deploy staging ${deploymentDirPath} out of retention; it could not be read:`, (0, harper_logger_ts_1.errorForLog)(error));
1034
+ }
1035
+ }
1036
+ return builds;
1037
+ }
913
1038
  /**
914
1039
  * Best-effort fsync of a directory. Best-effort by necessity — Node cannot fsync a directory on Windows —
915
1040
  * which is why roll-forward requires journal + candidate + complete marker to all be observable: a lost
@@ -953,6 +1078,54 @@ async function syncRenameParents(fromPath, toPath) {
953
1078
  for (const parent of parents)
954
1079
  await syncDirectory(parent);
955
1080
  }
1081
+ // Deliberately NOT `EEXIST`/`ENOTEMPTY`/`ENOTDIR`/`EISDIR`: those say the destination exists, which
1082
+ // nothing here clears between attempts, so waiting on them would only delay reporting a tree something
1083
+ // recreated — the case `settleInterruptedActivation` fails closed rather than guessing.
1084
+ // `rollbackExtractedDirectory` does retry them, because its placeholder logic repairs the destination.
1085
+ const TRANSIENT_RENAME_CODES = new Set(['EPERM', 'EACCES', 'EBUSY']);
1086
+ const RENAME_RETRY_BUDGET_MS = 5000;
1087
+ const RENAME_RETRY_INITIAL_DELAY_MS = 10;
1088
+ const RENAME_RETRY_MAX_DELAY_MS = 500;
1089
+ /**
1090
+ * Rename, waiting out a holder that has not let go yet — on Windows a rename is refused outright while
1091
+ * anything still has a handle in the source tree.
1092
+ *
1093
+ * `onBackoff` replaces the sleep between attempts; `deadline` lets a rename it performs share this
1094
+ * call's budget instead of opening its own.
1095
+ */
1096
+ async function renameThroughTransientHolder(fromPath, toPath, options = {}) {
1097
+ const deadline = options.deadline ?? performance.now() + RENAME_RETRY_BUDGET_MS;
1098
+ let delayMs = RENAME_RETRY_INITIAL_DELAY_MS;
1099
+ for (let attempts = 1;; attempts++) {
1100
+ try {
1101
+ await (0, promises_1.rename)(fromPath, toPath);
1102
+ if (attempts > 1) {
1103
+ harper_logger_ts_1.default.warn(`Renamed ${fromPath} to ${toPath} only on attempt ${attempts}; something was holding it`);
1104
+ }
1105
+ return;
1106
+ }
1107
+ catch (error) {
1108
+ const code = error.code ?? '';
1109
+ if (!TRANSIENT_RENAME_CODES.has(code))
1110
+ throw error;
1111
+ if (performance.now() >= deadline) {
1112
+ // Which side was still there separates a holder on the source from a destination something
1113
+ // recreated, and neither survives on the rethrown error. A failed probe reports its own code:
1114
+ // an `EPERM` reading the destination is itself evidence, and calling it absent would send the
1115
+ // next investigation the wrong way.
1116
+ const state = async (path) => (0, promises_1.lstat)(path).then(() => 'present', (probeError) => probeError?.code ?? 'unreadable');
1117
+ harper_logger_ts_1.default.warn(`Could not rename ${fromPath} to ${toPath}: ${code} after ${attempts} attempts ` +
1118
+ `(source ${await state(fromPath)}, destination ${await state(toPath)})`);
1119
+ throw error;
1120
+ }
1121
+ if (options.onBackoff)
1122
+ await options.onBackoff(delayMs, deadline);
1123
+ else
1124
+ await (0, promises_3.setTimeout)(delayMs);
1125
+ delayMs = Math.min(delayMs * 2, RENAME_RETRY_MAX_DELAY_MS);
1126
+ }
1127
+ }
1128
+ }
956
1129
  /**
957
1130
  * Write a control file so its final name NEVER exists with partial contents. Opening the final path with
958
1131
  * `wx` publishes the directory entry before anything is written, so a crash in between leaves a zero-byte
@@ -988,6 +1161,150 @@ function candidateComponentFilePath(componentDirPath, deploymentId) {
988
1161
  function activationJournalPath(componentDirPath, deploymentId) {
989
1162
  return (0, node_path_1.join)(candidateDeploymentDirPath(componentDirPath, deploymentId), ACTIVATION_JOURNAL);
990
1163
  }
1164
+ function candidateArtifactFilePath(componentDirPath, deploymentId) {
1165
+ return (0, node_path_1.join)(candidateDeploymentDirPath(componentDirPath, deploymentId), CANDIDATE_ARTIFACT_FILE);
1166
+ }
1167
+ /**
1168
+ * Read and fully validate a staged artifact's descriptor. Every field is checked, not just the ones the
1169
+ * caller happens to use: a descriptor is activation input for a build this process did not make, possibly
1170
+ * not even on this node, so a partially-checked one is a way to activate under someone else's intent.
1171
+ * Absence is `undefined`; anything present but unusable throws, because a staged artifact that cannot
1172
+ * describe itself must be refused rather than activated under defaults.
1173
+ */
1174
+ async function readArtifactDescriptor(deploymentDirPath, componentName) {
1175
+ const descriptorPath = (0, node_path_1.join)(deploymentDirPath, CANDIDATE_ARTIFACT_FILE);
1176
+ // The artifact the caller named exists and is theirs, but does not describe a build this can activate —
1177
+ // a conflict with what is on disk, not a server fault.
1178
+ const unusable = (message) => new hdbError_ts_1.ClientError(message, 409);
1179
+ const raw = await (0, promises_1.readFile)(descriptorPath, 'utf8').catch((error) => {
1180
+ if (error?.code === 'ENOENT')
1181
+ return undefined;
1182
+ throw error;
1183
+ });
1184
+ if (raw === undefined)
1185
+ return undefined;
1186
+ let parsed;
1187
+ try {
1188
+ parsed = JSON.parse(raw);
1189
+ }
1190
+ catch (error) {
1191
+ throw unusable(`Artifact descriptor ${descriptorPath} is not readable JSON: ${errorMessage(error)}`);
1192
+ }
1193
+ if (!parsed || typeof parsed !== 'object' || parsed.v !== ARTIFACT_DESCRIPTOR_VERSION) {
1194
+ throw unusable(`Artifact descriptor ${descriptorPath} is version ${parsed?.v}, which this build cannot activate`);
1195
+ }
1196
+ if (!isJoinableComponentName(parsed.component) || parsed.component !== componentName) {
1197
+ throw unusable(`Artifact descriptor ${descriptorPath} names component '${parsed.component}', not '${componentName}'`);
1198
+ }
1199
+ if (typeof parsed.installationIsOpaque !== 'boolean' || typeof parsed.isolated !== 'boolean') {
1200
+ throw unusable(`Artifact descriptor ${descriptorPath} does not record its build's runtime decisions`);
1201
+ }
1202
+ if (parsed.rootConfig !== null && (typeof parsed.rootConfig !== 'object' || Array.isArray(parsed.rootConfig))) {
1203
+ throw unusable(`Artifact descriptor ${descriptorPath} does not record a root-config entry or its absence`);
1204
+ }
1205
+ // Admission reads one field and publication writes the other, so two authorities that disagree would
1206
+ // admit one isolation and then publish the opposite.
1207
+ if (parsed.rootConfig && Boolean(parsed.rootConfig.isolated) !== parsed.isolated) {
1208
+ throw unusable(`Artifact descriptor ${descriptorPath} admits isolated=${parsed.isolated} but publishes ` +
1209
+ `isolated=${Boolean(parsed.rootConfig.isolated)}`);
1210
+ }
1211
+ return parsed;
1212
+ }
1213
+ const SEPARATORS_IN_LINK_TARGETS = process.platform === 'win32' ? /[\\/]/ : /\//;
1214
+ // `node_modules/harper` and `node_modules/harperdb` are links the LOADER owns: it points them at the
1215
+ // running install on every non-root component load and repairs them when they are missing or stale. They
1216
+ // are outside the artifact by construction and by design, so they are the one external link a staged
1217
+ // artifact may carry.
1218
+ const LOADER_OWNED_LINKS = new Set(['harper', 'harperdb']);
1219
+ /**
1220
+ * Reject a staged artifact that reaches outside itself.
1221
+ *
1222
+ * Certification fsyncs the tree but follows no links, and the post-swap relocation repair deliberately
1223
+ * leaves external targets alone — so a symlink into a directory this artifact does not own is a hole in
1224
+ * "activate exactly the bytes that were certified": the target can be edited, or replaced wholesale,
1225
+ * between the stage and the activation. An immediate deploy is not exposed to this, because certification
1226
+ * and activation happen within one call; the delay is what makes it reachable.
1227
+ *
1228
+ */
1229
+ async function assertOwnedArtifactTree(candidateDirPath, componentName, action = 'stage') {
1230
+ // The operator supplied a component that cannot be staged (400); or the artifact they named exists and is
1231
+ // theirs but is no longer what was certified (409). Neither is a server fault, and both reached the
1232
+ // operations handler as a bare 500 until a live run showed what that looks like to a caller.
1233
+ const refuse = (message) => new hdbError_ts_1.ClientError(message, action === 'stage' ? 400 : 409);
1234
+ const ownedRoot = await (0, promises_1.realpath)(candidateDirPath);
1235
+ // The loader repairs the component's OWN `node_modules/harper`, not a copy nested inside a dependency,
1236
+ // so only that one path is exempt. Matching the name at any depth would let `dep/node_modules/harper`
1237
+ // point anywhere and still pass.
1238
+ const loaderOwnedDir = (0, node_path_1.join)(candidateDirPath, 'node_modules');
1239
+ const walk = async (dirPath) => {
1240
+ const entries = await (0, promises_1.readdir)(dirPath, { withFileTypes: true });
1241
+ for (const entry of entries) {
1242
+ const entryPath = (0, node_path_1.join)(dirPath, entry.name);
1243
+ if (entry.isDirectory()) {
1244
+ await walk(entryPath);
1245
+ continue;
1246
+ }
1247
+ // Junctions report as symbolic links here, which is what makes this cover Windows.
1248
+ if (!entry.isSymbolicLink())
1249
+ continue;
1250
+ if (LOADER_OWNED_LINKS.has(entry.name) && dirPath === loaderOwnedDir)
1251
+ continue;
1252
+ // An unresolvable link is rejected for the same reason a foreign one is: nothing certified what
1253
+ // it will resolve to by the time somebody activates it.
1254
+ const target = await (0, promises_1.realpath)(entryPath).catch(() => undefined);
1255
+ if (target === undefined || (target !== ownedRoot && !target.startsWith(ownedRoot + node_path_1.sep))) {
1256
+ throw refuse(`Cannot ${action} ${componentName}: ${entryPath} links outside the build to ${target ?? 'a missing target'}, ` +
1257
+ `so the bytes activated later would not be the bytes this build certified`);
1258
+ }
1259
+ // `repairRelocatedDependencyLinks` re-points links after the swap, but it runs PAST THE COMMIT
1260
+ // POINT and can only warn — it logs and continues on a failed re-point, and skips a whole subtree
1261
+ // on EACCES/EMFILE — so a component could go live holding a link to a path that no longer exists
1262
+ // while the operation reports success. Staging fails closed instead. The cost: npm writes absolute
1263
+ // junctions under `node_modules` on Windows for a `file:`/workspace dependency, so such a component
1264
+ // deploys immediately but cannot be staged until its links are relative.
1265
+ const linkTarget = await (0, promises_1.readlink)(entryPath);
1266
+ if ((0, node_path_1.isAbsolute)(linkTarget)) {
1267
+ throw refuse(`Cannot ${action} ${componentName}: ${entryPath} names its target inside the build by absolute path ` +
1268
+ `(${linkTarget}), which activation moves. Re-link it relatively — on Windows, npm ` +
1269
+ `writes absolute junctions for 'file:' and workspace dependencies, so those have to be relative ` +
1270
+ `before the component can be staged.`);
1271
+ }
1272
+ // Where the link ENDS UP is not enough: a target that leaves the candidate and comes back resolves
1273
+ // inside it today and somewhere else once activation renames the tree, because the same relative
1274
+ // expression is then evaluated from `components/<component>/…`. Counting `..` segments does not
1275
+ // catch it either, since an intermediate symlink (`up -> ..`) reduces depth without spelling it.
1276
+ // So every PREFIX of the walk is resolved, with symlinks followed as the filesystem will follow
1277
+ // them, and each one has to still be inside the candidate.
1278
+ let prefix = (0, node_path_1.dirname)(entryPath);
1279
+ // Only Windows treats a backslash as a separator. On POSIX it is an ordinary filename character, so
1280
+ // splitting on it there turns a link to the single legal entry `..\asset` — which resolves inside
1281
+ // the candidate and keeps resolving there after relocation — into `..` plus `asset`, and refuses a
1282
+ // component that never left its own tree.
1283
+ for (const segment of linkTarget.split(SEPARATORS_IN_LINK_TARGETS)) {
1284
+ if (segment === '' || segment === '.')
1285
+ continue;
1286
+ prefix = await (0, promises_1.realpath)((0, node_path_1.join)(prefix, segment)).catch(() => (0, node_path_1.join)(prefix, segment));
1287
+ if (prefix !== ownedRoot && !prefix.startsWith(ownedRoot + node_path_1.sep)) {
1288
+ throw refuse(`Cannot ${action} ${componentName}: ${entryPath} reaches ${prefix} on its way to ${linkTarget}, ` +
1289
+ `leaving the build — after activation moves the tree that path resolves somewhere else`);
1290
+ }
1291
+ }
1292
+ }
1293
+ };
1294
+ await walk(candidateDirPath);
1295
+ }
1296
+ /** Record the build's decisions beside the candidate. Called before `.complete`, which vouches for it. */
1297
+ async function writeArtifactDescriptor(componentDirPath, deploymentId, descriptor) {
1298
+ try {
1299
+ await writeControlFileDurably(candidateArtifactFilePath(componentDirPath, deploymentId), JSON.stringify(descriptor));
1300
+ }
1301
+ catch (error) {
1302
+ // An existing descriptor belongs to this same artifact — the id is claimed exclusively, so nothing
1303
+ // else can have written one — which makes this a retry of its own stage rather than a conflict.
1304
+ if (error.code !== 'EEXIST')
1305
+ throw error;
1306
+ }
1307
+ }
991
1308
  /**
992
1309
  * A component name safe to join onto the components root: no separator, no traversal, not dot-prefixed.
993
1310
  * Applied to EVERY source of the name — the journal and the sidecar — because validating one and trusting
@@ -1037,8 +1354,23 @@ async function readActivationJournal(journalPath) {
1037
1354
  }
1038
1355
  return parsed;
1039
1356
  }
1040
- /** The deployment directory holding one candidate build: `<root>/.deploy-staging/<deploymentId>`. */
1357
+ /**
1358
+ * The deployment directory holding one candidate build: `<root>/.deploy-staging/<deploymentId>`.
1359
+ *
1360
+ * The id is asserted here rather than only at the request boundary because every caller funnels through
1361
+ * this one join, and the id is now operator-supplied (`deployment_id`) or replication-supplied
1362
+ * (`_deploymentId`). A traversal-bearing id would otherwise direct both the build and its removal outside
1363
+ * `.deploy-staging`.
1364
+ */
1041
1365
  function candidateDeploymentDirPath(componentDirPath, deploymentId) {
1366
+ if (typeof deploymentId !== 'string' ||
1367
+ deploymentId.length === 0 ||
1368
+ deploymentId !== (0, node_path_1.basename)(deploymentId) ||
1369
+ // `basename` returns these unchanged, so the comparison above admits both.
1370
+ deploymentId === '.' ||
1371
+ deploymentId === '..') {
1372
+ throw new Error(`Deployment id '${deploymentId}' is not a single path segment`);
1373
+ }
1042
1374
  return (0, node_path_1.join)((0, node_path_1.dirname)(componentDirPath), exports.DEPLOY_STAGING_DIR, deploymentId);
1043
1375
  }
1044
1376
  /** Where a candidate build lives: `<root>/.deploy-staging/<deploymentId>/<component>`. */
@@ -1113,6 +1445,92 @@ async function ensureSecureStagingDirectory(stagingDir) {
1113
1445
  await (0, promises_1.chmod)(stagingDir, 0o700).catch((error) => harper_logger_ts_1.default.warn(`Could not restrict component deploy staging permissions for ${stagingDir}:`, (0, harper_logger_ts_1.errorForLog)(error)));
1114
1446
  }
1115
1447
  }
1448
+ /**
1449
+ * Claim a deployment directory for this build, EXCLUSIVELY. The id is the public deployment id, which an
1450
+ * operator can repeat and a redelivered replication can repeat for them, so tolerating an existing
1451
+ * directory would let a replayed stage rewrite the bytes under an existing `.complete` and descriptor —
1452
+ * and a crash mid-rebuild would leave a partial tree that still reads as certified.
1453
+ *
1454
+ * The caller holds the component's preparation lock, which is what makes the EEXIST verdicts sound: no
1455
+ * other preparation of THIS component is running, and a directory belonging to another component is not
1456
+ * this lock's to touch.
1457
+ */
1458
+ async function claimDeploymentDirectory(deploymentDirPath, componentName) {
1459
+ // Every refusal below is a conflict over an id that already exists, which is the caller's to resolve by
1460
+ // naming a different one — not a server fault, and not the 500 a bare Error reaches the caller as.
1461
+ const taken = (message) => new hdbError_ts_1.ClientError(message, 409);
1462
+ try {
1463
+ await (0, promises_1.mkdir)(deploymentDirPath, { mode: 0o700 });
1464
+ }
1465
+ catch (error) {
1466
+ if (error.code !== 'EEXIST')
1467
+ throw error;
1468
+ const owner = await candidateComponentName(deploymentDirPath);
1469
+ if (owner !== undefined && owner !== componentName) {
1470
+ throw taken(`Deployment id ${(0, node_path_1.basename)(deploymentDirPath)} already holds a build of '${owner}'; a deployment id ` +
1471
+ `names one artifact for its lifetime`);
1472
+ }
1473
+ // Ownership has to be POSITIVE to reclaim, and EMPTINESS IS NOT A VERDICT. The sidecar below is
1474
+ // written as part of the claim, so a directory naming nobody is another component between its own
1475
+ // mkdir and that write, and a deploy can hold a nearly empty one for minutes while it resolves and
1476
+ // packs. Nothing on disk separates that from a claim that got no further, and the lock that
1477
+ // serializes it is not this one, so only the exclusive create may conclude the id is free: the
1478
+ // directory can also have been discarded by a failed build between the first mkdir and this read,
1479
+ // and another component can claim it in that same gap.
1480
+ if (owner === undefined) {
1481
+ await (0, promises_1.mkdir)(deploymentDirPath, { mode: 0o700 }).catch((retry) => {
1482
+ if (retry?.code !== 'EEXIST')
1483
+ throw retry;
1484
+ throw taken(`Deployment id ${(0, node_path_1.basename)(deploymentDirPath)} is already claimed by a build that has not named its ` +
1485
+ `component yet. If no deploy of any component is in flight, that directory is abandoned and has ` +
1486
+ `to be removed by hand; deploying again without a deployment_id mints a fresh id`);
1487
+ });
1488
+ }
1489
+ else {
1490
+ if (await presentOrAbsent((0, node_path_1.join)(deploymentDirPath, CANDIDATE_COMPLETE_MARKER))) {
1491
+ throw taken(`Deployment id ${(0, node_path_1.basename)(deploymentDirPath)} already holds a completed build of '${componentName}'; ` +
1492
+ `deploy it with deployment_id, or deploy again to build a new one`);
1493
+ }
1494
+ // This component's own preparation lock serializes the claim and nothing certified it, so nothing
1495
+ // is lost by rebuilding over it.
1496
+ await (0, promises_1.rm)(deploymentDirPath, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
1497
+ await (0, promises_1.mkdir)(deploymentDirPath, { mode: 0o700 });
1498
+ }
1499
+ }
1500
+ const claimed = await (0, promises_1.lstat)(deploymentDirPath);
1501
+ if (!claimed.isDirectory() || claimed.isSymbolicLink()) {
1502
+ throw new Error(`Component deploy staging path is not a directory: ${deploymentDirPath}`);
1503
+ }
1504
+ if (process.platform !== 'win32' && (claimed.mode & 0o777) !== 0o700) {
1505
+ await (0, promises_1.chmod)(deploymentDirPath, 0o700).catch((error) => harper_logger_ts_1.default.warn(`Could not restrict component deploy staging permissions for ${deploymentDirPath}:`, (0, harper_logger_ts_1.errorForLog)(error)));
1506
+ }
1507
+ await publishClaimOwnership(deploymentDirPath, componentName);
1508
+ }
1509
+ /**
1510
+ * Name the component that owns a deployment directory THIS CALL created, and take the directory back if that
1511
+ * cannot be recorded.
1512
+ *
1513
+ * Ownership is published as part of the claim rather than at certification, because resolving and packing can
1514
+ * take minutes and until a tree exists to infer an owner from, a directory answering to nobody is one another
1515
+ * component will take for abandoned. Unattributed is a permanent refusal, though, so a failure here — a full
1516
+ * disk, an EIO on the temp write or its sync — would burn this deployment id for good, every retry refused by
1517
+ * its own wreckage. The removal is scoped to the directory this invocation made, and deliberately not
1518
+ * broadened to one found by EEXIST: that directory may be another claimant's, and removing it is the race the
1519
+ * refusal exists to prevent. Best-effort, because the claim failure is what the caller needs to see.
1520
+ *
1521
+ * `write` is a parameter so the failure is testable without a filesystem that can be made to fail on exactly
1522
+ * this write and nothing else.
1523
+ */
1524
+ async function publishClaimOwnership(deploymentDirPath, componentName, write = writeControlFileDurably) {
1525
+ try {
1526
+ await write((0, node_path_1.join)(deploymentDirPath, CANDIDATE_COMPONENT_FILE), componentName);
1527
+ }
1528
+ catch (error) {
1529
+ await (0, promises_1.rm)(deploymentDirPath, { recursive: true, force: true }).catch((cleanupError) => harper_logger_ts_1.default.warn(`Could not remove the deployment directory ${deploymentDirPath} after failing to publish its ` +
1530
+ `ownership; the id stays unusable until it is removed:`, (0, harper_logger_ts_1.errorForLog)(cleanupError)));
1531
+ throw error;
1532
+ }
1533
+ }
1116
1534
  async function ensureExtractionStagingDirectory(asideStagingDir) {
1117
1535
  for (const stagingDir of [(0, node_path_1.dirname)(asideStagingDir), asideStagingDir]) {
1118
1536
  await ensureSecureStagingDirectory(stagingDir);
@@ -1258,7 +1676,8 @@ async function inProgressAsideRecords(asideStagingDir) {
1258
1676
  .reverse();
1259
1677
  }
1260
1678
  /**
1261
- * Settle journaled activations for ONE component, assuming the caller already holds its preparation lock.
1679
+ * Settle journaled activations for ONE component, and bound its dormant staged builds, assuming the caller
1680
+ * already holds its preparation lock.
1262
1681
  *
1263
1682
  * Exists because the journal-first rule has to hold at every entry point, not just startup. A deploy runs
1264
1683
  * `recoverOrCleanupStaleExtractionPaths` first. After an activation whose retirement failed, the aside
@@ -1266,7 +1685,7 @@ async function inProgressAsideRecords(asideStagingDir) {
1266
1685
  * pass refuses to restore against a surviving journal, but refusing is a stalled component; settling first
1267
1686
  * is what lets the deploy proceed.
1268
1687
  */
1269
- async function settleJournaledActivationsForComponent(componentsRootDirPath, componentName) {
1688
+ async function settleStagingForComponent(componentsRootDirPath, componentName, pinnedDeploymentId) {
1270
1689
  const stagingRoot = (0, node_path_1.join)(componentsRootDirPath, exports.DEPLOY_STAGING_DIR);
1271
1690
  let deployments;
1272
1691
  try {
@@ -1277,6 +1696,7 @@ async function settleJournaledActivationsForComponent(componentsRootDirPath, com
1277
1696
  return;
1278
1697
  throw error;
1279
1698
  }
1699
+ const dormant = [];
1280
1700
  for (const deployment of deployments) {
1281
1701
  if (!deployment.isDirectory())
1282
1702
  continue;
@@ -1307,8 +1727,19 @@ async function settleJournaledActivationsForComponent(componentsRootDirPath, com
1307
1727
  // A journal-less directory is what a SUCCESSFUL settlement leaves when its best-effort sweep fails, so
1308
1728
  // this must not fail closed. An unattributable *activation* still does: `readActivationJournal` throws
1309
1729
  // on a journal that exists but cannot be read.
1310
- if (!journal)
1730
+ if (!journal) {
1731
+ if (ownerUnreadable)
1732
+ continue;
1733
+ try {
1734
+ const build = await dormantBuildAt(deploymentDirPath, componentName);
1735
+ if (build)
1736
+ dormant.push(build);
1737
+ }
1738
+ catch (error) {
1739
+ harper_logger_ts_1.default.warn(`Leaving staged ${componentName} build ${deploymentDirPath} out of retention; it could not be read:`, (0, harper_logger_ts_1.errorForLog)(error));
1740
+ }
1311
1741
  continue;
1742
+ }
1312
1743
  // The journal decides, not the sidecar. Skipping on an unreadable sidecar alone would leave this
1313
1744
  // component's own unsettled activation in place while a new deploy proceeded over it, and an
1314
1745
  // activation interrupted before B1 has no rollback record for the restore gate to catch.
@@ -1316,6 +1747,9 @@ async function settleJournaledActivationsForComponent(componentsRootDirPath, com
1316
1747
  continue;
1317
1748
  await settleInterruptedActivation(componentsRootDirPath, deploymentDirPath, journal);
1318
1749
  }
1750
+ const maxCount = getStagingRetentionMaxCount();
1751
+ if (dormant.length > maxCount)
1752
+ await pruneDormantBuilds(componentName, dormant, maxCount, pinnedDeploymentId);
1319
1753
  }
1320
1754
  /**
1321
1755
  * Components that on-disk evidence says were left in a state nobody settled — determined READ-ONLY, so any
@@ -1424,29 +1858,20 @@ async function recoverInterruptedActivations(componentsRootDirPath) {
1424
1858
  return failures;
1425
1859
  throw error;
1426
1860
  }
1861
+ const dormant = new Map();
1862
+ const catalogue = (owner, build) => {
1863
+ const builds = dormant.get(owner);
1864
+ if (builds)
1865
+ builds.push(build);
1866
+ else
1867
+ dormant.set(owner, [build]);
1868
+ };
1427
1869
  for (const deployment of deployments) {
1428
1870
  if (!deployment.isDirectory())
1429
1871
  continue;
1430
1872
  const deploymentDirPath = (0, node_path_1.join)(stagingRoot, deployment.name);
1431
1873
  const journalPath = (0, node_path_1.join)(deploymentDirPath, ACTIVATION_JOURNAL);
1432
- const fail = async (component, error) => {
1433
- const failure = error instanceof Error ? error : new Error(String(error));
1434
- if (!failures.has(component))
1435
- failures.set(component, failure);
1436
- harper_logger_ts_1.default.error(`Could not settle the interrupted activation of ${component}:`, (0, harper_logger_ts_1.errorForLog)(failure));
1437
- // A DEFERRAL is not a verdict, and only verdicts go on disk. A held lock means a live deploy, which
1438
- // settles its own journal; a marker written here would outlive that deploy and have
1439
- // `unsettleableComponentsFromDisk` read it as an authoritative "cannot be settled", failing a
1440
- // healthy component closed on every worker. The failure is already recorded above, so this thread
1441
- // still defers — it just leaves nothing behind.
1442
- if (failure instanceof componentPreparationLock_ts_1.ComponentPreparationLockTimeoutError)
1443
- return;
1444
- // Everything else IS a verdict, recorded so workers reach the same one. An unreadable journal is
1445
- // self-evident, but a well-formed journal this pass could not settle looks exactly like a deploy
1446
- // in flight, and a worker would otherwise load the component over state nobody reconciled.
1447
- // Best-effort: the alternative to a missing marker is today's behavior, not a worse one.
1448
- await (0, promises_1.writeFile)((0, node_path_1.join)(deploymentDirPath, UNSETTLED_MARKER), failure.message, { mode: 0o600 }).catch((markerError) => harper_logger_ts_1.default.warn(`Could not record the unsettled activation of ${component}: ${errorMessage(markerError)}`));
1449
- };
1874
+ const fail = (component, error) => recordUnsettled(failures, component, error, deploymentDirPath);
1450
1875
  let journal;
1451
1876
  try {
1452
1877
  journal = await readActivationJournal(journalPath);
@@ -1507,6 +1932,43 @@ async function recoverInterruptedActivations(componentsRootDirPath) {
1507
1932
  activationToFail = undefined;
1508
1933
  return;
1509
1934
  }
1935
+ // Re-classified UNDER the lock: the unlocked read that routed this here can predate the `.complete`
1936
+ // a deploy wrote before dying, and that is a retainable build, not residue.
1937
+ const build = await dormantBuildAt(deploymentDirPath, owner);
1938
+ if (build) {
1939
+ catalogue(owner, build);
1940
+ return;
1941
+ }
1942
+ // A DESCRIBED artifact carrying a stale verdict is settled, not residue. `fail()` only ever writes
1943
+ // `.unsettled` beside a journal it keeps, so a marker with no journal says settlement finished
1944
+ // and only the marker's own removal was lost — which is exactly what a crash between a dormant
1945
+ // return's two unlinks leaves, and the barrier that orders them cannot run on Windows. Deleting
1946
+ // here would destroy a build somebody staged deliberately, whose payload may already have been
1947
+ // reclaimed, on the strength of a verdict that no longer applies. Clearing the marker is the
1948
+ // idempotent completion of the settlement that wrote it; an undescribed build stays disposable.
1949
+ if (await presentOrAbsent((0, node_path_1.join)(deploymentDirPath, CANDIDATE_ARTIFACT_FILE))) {
1950
+ // A fault clearing the marker is not a licence to delete what it is attached to — and not a
1951
+ // licence to say nothing either. The marker survives, every worker fails the component closed
1952
+ // on it, and main reporting success is the split where main serves what every worker refuses.
1953
+ // Failing the component brings main to the workers' verdict instead, the rule the settled tail
1954
+ // follows, and leaves the artifact for the next pass to clear.
1955
+ try {
1956
+ await clearUnsettledVerdict(deploymentDirPath, owner);
1957
+ }
1958
+ catch (error) {
1959
+ activationToFail = owner;
1960
+ throw error;
1961
+ }
1962
+ const settled = await dormantBuildAt(deploymentDirPath, owner);
1963
+ if (settled) {
1964
+ harper_logger_ts_1.default.info?.(`Cleared a stale unsettled verdict from the staged build ${(0, node_path_1.basename)(deploymentDirPath)} of ` +
1965
+ `${owner}; its activation was already settled`);
1966
+ catalogue(owner, settled);
1967
+ return;
1968
+ }
1969
+ // Cleared, and still not a retainable build — an incomplete or treeless staged directory. That
1970
+ // is residue like any other, so it falls through to the removal below.
1971
+ }
1510
1972
  // Cleanup, not settlement. There was no activation here — this is most often the residue a
1511
1973
  // SUCCESSFUL settlement leaves when its own sweep failed — so a sweep that fails again cannot
1512
1974
  // make anything unsettled, and recording it would refuse a live component on every worker
@@ -1528,6 +1990,22 @@ async function recoverInterruptedActivations(componentsRootDirPath) {
1528
1990
  `ownership cannot be read: ${errorMessage(error)}`);
1529
1991
  continue;
1530
1992
  }
1993
+ // Catalogued WITHOUT the lock and left alone: a retained build is never removed here, so a per-directory
1994
+ // lock would recur on every pass and contend with sibling threads for a component nothing is deploying.
1995
+ if (owner) {
1996
+ let build;
1997
+ try {
1998
+ build = await dormantBuildAt(deploymentDirPath, owner);
1999
+ }
2000
+ catch (error) {
2001
+ harper_logger_ts_1.default.warn(`Leaving deploy staging ${deploymentDirPath} in place; it could not be read:`, (0, harper_logger_ts_1.errorForLog)(error));
2002
+ continue;
2003
+ }
2004
+ if (build) {
2005
+ catalogue(owner, build);
2006
+ continue;
2007
+ }
2008
+ }
1531
2009
  // Scoped to THIS deployment, like the journaled branch below: a lock timeout or an EIO here used to
1532
2010
  // abort the entire scan, leaving every later deployment unsettled and unmarked.
1533
2011
  try {
@@ -1595,6 +2073,109 @@ async function recoverInterruptedActivations(componentsRootDirPath) {
1595
2073
  await fail(journal.component, error);
1596
2074
  }
1597
2075
  }
2076
+ const maxCount = getStagingRetentionMaxCount();
2077
+ for (const [owner, builds] of dormant) {
2078
+ for (const [component, error] of await reconcileDormantBuilds(componentsRootDirPath, owner, builds, maxCount)) {
2079
+ if (!failures.has(component))
2080
+ failures.set(component, error);
2081
+ }
2082
+ }
2083
+ return failures;
2084
+ }
2085
+ /**
2086
+ * Record a settlement failure against a component. A lock TIMEOUT is a deferral, not a verdict, and only
2087
+ * verdicts go on disk: a held lock means a live deploy, which settles its own journal, and a marker written
2088
+ * here would outlive it and have `unsettleableComponentsFromDisk` fail a healthy component closed on every
2089
+ * worker. Everything else is written so workers reach the same verdict — a well-formed journal this pass
2090
+ * could not settle looks exactly like a deploy in flight otherwise. Marker write is best-effort.
2091
+ */
2092
+ async function recordUnsettled(failures, component, error, deploymentDirPath) {
2093
+ const failure = error instanceof Error ? error : new Error(String(error));
2094
+ if (!failures.has(component))
2095
+ failures.set(component, failure);
2096
+ harper_logger_ts_1.default.error(`Could not settle the interrupted activation of ${component}:`, (0, harper_logger_ts_1.errorForLog)(failure));
2097
+ if (failure instanceof componentPreparationLock_ts_1.ComponentPreparationLockTimeoutError)
2098
+ return;
2099
+ await (0, promises_1.writeFile)((0, node_path_1.join)(deploymentDirPath, UNSETTLED_MARKER), failure.message, { mode: 0o600 }).catch((markerError) => harper_logger_ts_1.default.warn(`Could not record the unsettled activation of ${component}: ${errorMessage(markerError)}`));
2100
+ }
2101
+ /**
2102
+ * Finish one owner's catalogued dormant builds after the scan. The catalog was read without the lock, so a
2103
+ * deploy may have published a journal into one of these directories since — and if it then died mid-swap,
2104
+ * only settlement brings the component back. So: settle any journal that appeared, then bound what is still
2105
+ * dormant. The lock is taken only when there is something to do; a lock a live deploy holds is the same
2106
+ * deferral the residue branch records.
2107
+ */
2108
+ async function reconcileDormantBuilds(componentsRootDirPath, owner, builds, maxCount) {
2109
+ const failures = new Map();
2110
+ let journaled;
2111
+ for (const build of builds) {
2112
+ // Anything but a clean ENOENT means "read it properly, under the lock".
2113
+ const appeared = await presentOrAbsent((0, node_path_1.join)(build.deploymentDirPath, ACTIVATION_JOURNAL)).then((stats) => stats !== undefined, () => true);
2114
+ if (appeared) {
2115
+ journaled = build;
2116
+ break;
2117
+ }
2118
+ }
2119
+ if (!journaled && builds.length <= maxCount)
2120
+ return failures;
2121
+ try {
2122
+ await (0, componentPreparationLock_ts_1.withComponentPreparationLock)((0, node_path_1.join)(componentsRootDirPath, owner), async () => {
2123
+ const stillDormant = [];
2124
+ for (const build of builds) {
2125
+ let journal;
2126
+ try {
2127
+ journal = await readActivationJournal((0, node_path_1.join)(build.deploymentDirPath, ACTIVATION_JOURNAL));
2128
+ }
2129
+ catch (error) {
2130
+ await recordUnsettled(failures, owner, error, build.deploymentDirPath);
2131
+ continue;
2132
+ }
2133
+ if (!journal) {
2134
+ stillDormant.push(build);
2135
+ continue;
2136
+ }
2137
+ // The lock held here is the SIDECAR owner's, as in the residue branch: a journal naming someone
2138
+ // else is not settled under it, and both names are failed.
2139
+ const splitNames = splitAttributionOwners(journal.component, owner);
2140
+ if (splitNames) {
2141
+ const split = splitAttributionError(build.deploymentDirPath, journal.component, splitNames[0]);
2142
+ for (const name of splitNames)
2143
+ await recordUnsettled(failures, name, split, build.deploymentDirPath);
2144
+ continue;
2145
+ }
2146
+ try {
2147
+ await settleInterruptedActivation(componentsRootDirPath, build.deploymentDirPath, journal);
2148
+ }
2149
+ catch (error) {
2150
+ await recordUnsettled(failures, journal.component, error, build.deploymentDirPath);
2151
+ }
2152
+ }
2153
+ if (stillDormant.length > maxCount)
2154
+ await pruneDormantBuilds(owner, stillDormant, maxCount);
2155
+ }, {
2156
+ purpose: 'activation-recovery',
2157
+ ...RECOVERY_LOCK_WAIT,
2158
+ isOwnerAlive: (lockOwner) => lockOwner.pid !== process.pid || (0, manageThreads_js_1.isThreadRunning)(lockOwner.threadId),
2159
+ });
2160
+ }
2161
+ catch (error) {
2162
+ // With a journal in view this is an activation that could not be settled — recorded exactly as the
2163
+ // scan records one it saw directly (a timeout defers, anything else is a verdict). Without one it is
2164
+ // hygiene that could not run, unless a live deploy holds the lock, which defers as everywhere else.
2165
+ if (journaled) {
2166
+ await recordUnsettled(failures, owner, error, journaled.deploymentDirPath);
2167
+ return failures;
2168
+ }
2169
+ const failure = error instanceof Error ? error : new Error(String(error));
2170
+ if (failure instanceof componentPreparationLock_ts_1.ComponentPreparationLockTimeoutError) {
2171
+ if (!failures.has(owner))
2172
+ failures.set(owner, failure);
2173
+ harper_logger_ts_1.default.info?.(`Deferred pruning the dormant staged builds of ${owner}: a deploy holds its lock`);
2174
+ }
2175
+ else {
2176
+ harper_logger_ts_1.default.warn(`Could not prune the dormant staged builds of ${owner}:`, (0, harper_logger_ts_1.errorForLog)(failure));
2177
+ }
2178
+ }
1598
2179
  return failures;
1599
2180
  }
1600
2181
  /**
@@ -1615,6 +2196,25 @@ async function sweepAsideRecords(records, componentName, liveDirPath, asideStagi
1615
2196
  await cleanupExtractionPaths({ name: componentName, dirPath: liveDirPath, logger: harper_logger_ts_1.default }, asideStagingDir, new Set([record, retiredMarkerPath])).catch((error) => harper_logger_ts_1.default.warn(`Settled ${componentName} but could not sweep ${record}:`, (0, harper_logger_ts_1.errorForLog)(error)));
1616
2197
  }
1617
2198
  }
2199
+ /**
2200
+ * Clear an earlier failed recovery's verdict once this settlement has decided. Treated as CORRECTNESS, not
2201
+ * cleanup: main would report the component settled and load it while every worker read the stale marker and
2202
+ * failed it closed, so a failure here throws and lets main reach the same verdict. The journal outlives it
2203
+ * either way, so the next start settles again.
2204
+ */
2205
+ async function clearUnsettledVerdict(deploymentDirPath, componentName) {
2206
+ try {
2207
+ await (0, promises_1.rm)((0, node_path_1.join)(deploymentDirPath, UNSETTLED_MARKER), { force: true });
2208
+ // Flushed here, not with whatever follows: the journal's removal must never be the one that survives
2209
+ // a crash alone, or the verdict outlives the only thing that would settle it again.
2210
+ await syncDirectory(deploymentDirPath);
2211
+ }
2212
+ catch (error) {
2213
+ throw new Error(`Could not clear the stale unsettled marker of ${componentName} at ` +
2214
+ `${(0, node_path_1.join)(deploymentDirPath, UNSETTLED_MARKER)}; the component stays failed closed on every thread ` +
2215
+ `until that file can be removed: ${errorMessage(error)}`, { cause: error });
2216
+ }
2217
+ }
1618
2218
  /**
1619
2219
  * One interrupted activation, under the component preparation lock. Ambiguity exists only while the live
1620
2220
  * path is absent, and there the `complete` marker is the roll-forward authority: without it the candidate
@@ -1637,7 +2237,7 @@ async function settleInterruptedActivation(componentsRootDirPath, deploymentDirP
1637
2237
  const asideRecords = await inProgressAsideRecords(asideStagingDir);
1638
2238
  const rollForward = async () => {
1639
2239
  if (!liveExists)
1640
- await (0, promises_1.rename)(candidateDirPath, liveDirPath);
2240
+ await renameThroughTransientHolder(candidateDirPath, liveDirPath);
1641
2241
  // Unconditional, not only when THIS pass performed the rename: a crash after normal activation
1642
2242
  // renamed the candidate but before it repaired the links leaves live present with stale targets, and
1643
2243
  // gating the repair on the rename would skip exactly that case. Idempotent when there is nothing to
@@ -1652,7 +2252,7 @@ async function settleInterruptedActivation(componentsRootDirPath, deploymentDirP
1652
2252
  };
1653
2253
  const rollBack = async (restoreFrom) => {
1654
2254
  if (restoreFrom) {
1655
- await (0, promises_1.rename)(restoreFrom, liveDirPath);
2255
+ await renameThroughTransientHolder(restoreFrom, liveDirPath);
1656
2256
  await syncRenameParents(restoreFrom, liveDirPath);
1657
2257
  }
1658
2258
  for (const record of asideRecords) {
@@ -1705,6 +2305,31 @@ async function settleInterruptedActivation(componentsRootDirPath, deploymentDirP
1705
2305
  `it aside, so which tree is current cannot be determined without losing one of them. Remove ` +
1706
2306
  `whichever of the two is not the release you want once you have determined which that is.`);
1707
2307
  }
2308
+ // A STAGED artifact is not a disposable build. `rollBack()` removes the whole deployment directory,
2309
+ // which is right for an immediate deploy — the candidate came from a payload the operator still has —
2310
+ // but wrong for one somebody staged deliberately and may have had its payload reclaimed. The
2311
+ // descriptor is what tells the two apart, and it is on disk precisely so recovery can. Returning the
2312
+ // artifact to dormant by removing only the journal leaves it exactly as `deployment_id` expects it.
2313
+ if (await presentOrAbsent((0, node_path_1.join)(deploymentDirPath, CANDIDATE_ARTIFACT_FILE))) {
2314
+ // This branch returns early and so reaches none of the settled tail below — which can be careless
2315
+ // about both, because it removes the whole directory afterwards. This one keeps it, so the order
2316
+ // the two unlinks REACH STORAGE decides whether the artifact survives: `.unsettled` with no
2317
+ // journal is a verdict nothing will ever settle and the next retention pass deletes the build on.
2318
+ // Its flush is the barrier between the two unlinks: skipping it would let the journal's removal
2319
+ // persist alone, leaving a verdict nothing will settle again. Throwing is safe here and leaves the
2320
+ // journal, so the next start settles again. Windows cannot fsync a directory, which is why the
2321
+ // residue pass also refuses to read this state as disposable — see DESIGN.md.
2322
+ await clearUnsettledVerdict(deploymentDirPath, journal.component);
2323
+ await (0, promises_1.rm)(journalPath, { force: true });
2324
+ // Nothing may throw past the journal removal, the rule the settled tail follows: the caller
2325
+ // records a failure by writing `.unsettled`, which is the state this branch exists to avoid. An
2326
+ // unflushed removal is the safe direction — a power loss resurrects a journal that settles again.
2327
+ await syncDirectory(deploymentDirPath).catch((error) => harper_logger_ts_1.default.warn(`Returned the staged build ${(0, node_path_1.basename)(deploymentDirPath)} of ${journal.component} to dormant but ` +
2328
+ `could not flush that to storage; a power loss could resurrect its activation journal:`, (0, harper_logger_ts_1.errorForLog)(error)));
2329
+ harper_logger_ts_1.default.info?.(`Returned the staged build ${(0, node_path_1.basename)(deploymentDirPath)} of ${journal.component} to dormant after an ` +
2330
+ `activation that never moved its live tree aside`);
2331
+ return;
2332
+ }
1708
2333
  await rollBack();
1709
2334
  }
1710
2335
  else {
@@ -1731,18 +2356,7 @@ async function settleInterruptedActivation(componentsRootDirPath, deploymentDirP
1731
2356
  // An earlier failed recovery may have left an unsettled marker here. Cleared BEFORE the journal and
1732
2357
  // treated as correctness: main would report this component settled and load it, while every worker read
1733
2358
  // the stale marker and failed it closed.
1734
- try {
1735
- await (0, promises_1.rm)((0, node_path_1.join)(deploymentDirPath, UNSETTLED_MARKER), { force: true });
1736
- }
1737
- catch (error) {
1738
- // The tree decision is applied, but the marker still says otherwise and every worker reads it and
1739
- // fails the component closed. Thrown rather than returned so MAIN reaches that same verdict instead
1740
- // of reporting the component settled — a split where main serves what every worker refuses is worse
1741
- // than both refusing. The journal survives, so the next start settles again.
1742
- throw new Error(`Settled the interrupted activation of ${journal.component} but could not clear its unsettled ` +
1743
- `marker at ${(0, node_path_1.join)(deploymentDirPath, UNSETTLED_MARKER)}; the component stays failed closed on ` +
1744
- `every thread until that file can be removed: ${errorMessage(error)}`, { cause: error });
1745
- }
2359
+ await clearUnsettledVerdict(deploymentDirPath, journal.component);
1746
2360
  await (0, promises_1.rm)(journalPath, { force: true }).catch((error) => harper_logger_ts_1.default.warn(`Settled ${journal.component} but could not remove its activation journal:`, (0, harper_logger_ts_1.errorForLog)(error)));
1747
2361
  await (0, promises_1.rm)(deploymentDirPath, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }).catch((error) => harper_logger_ts_1.default.warn(`Settled ${journal.component} but could not clean up its staging directory:`, (0, harper_logger_ts_1.errorForLog)(error)));
1748
2362
  await (0, promises_1.rmdir)((0, node_path_1.dirname)(deploymentDirPath)).catch(() => { });
@@ -1864,17 +2478,37 @@ async function markCandidateComplete(componentDirPath, deploymentId, componentNa
1864
2478
  throw error;
1865
2479
  }
1866
2480
  }
2481
+ /**
2482
+ * Make the newly created ancestors of a deployment directory durable, child-first.
2483
+ *
2484
+ * Only a staged artifact needs this. A deploy's own candidate is transient — power loss just abandons a
2485
+ * build nobody was told about — but a stage is ACKNOWLEDGED, and `writeControlFileDurably` flushes only the
2486
+ * control file's immediate parent while `ensureSecureStagingDirectory` flushes none. Without this a stage
2487
+ * can report success before the `.deploy-staging/<id>` entry exists on storage, and the automatic payload
2488
+ * reclaim may already have dropped the tarball it could have been rebuilt from. Best-effort on Windows, like
2489
+ * every other directory sync here.
2490
+ */
2491
+ async function syncArtifactAncestors(deploymentDirPath) {
2492
+ const stagingRoot = (0, node_path_1.dirname)(deploymentDirPath);
2493
+ for (const directory of [deploymentDirPath, stagingRoot, (0, node_path_1.dirname)(stagingRoot)]) {
2494
+ await syncDirectory(directory);
2495
+ }
2496
+ }
1867
2497
  /**
1868
2498
  * Make a built and validated candidate live, as one compensating transaction over two effects: the live tree
1869
- * moves aside, then the candidate takes its place. Root config is NOT one of them — it is still published
1870
- * before the build, unchanged, and making it transactional is tracked separately (#2315).
2499
+ * moves aside, then the candidate takes its place. Root config is NOT one of them — for an immediate deploy
2500
+ * it is still published before the build, unchanged, and making it transactional is tracked separately
2501
+ * (#2315). A delayed activation hands its artifact's recorded entry in as `afterJournal`, which publishes it
2502
+ * from inside the window a crash rolls forward from — see that call site.
1871
2503
  *
1872
- * The `complete` marker and the activation journal are written and fsynced BEFORE the first rename, so a
1873
- * crash anywhere below is recoverable — see `settleInterruptedActivation` for the state matrix. The second
1874
- * rename is the COMMIT POINT: nothing after it may compensate, because the live path holds the candidate and
1875
- * renaming the aside back over it cannot succeed.
2504
+ * The candidate must ALREADY be certified: `markCandidateComplete` is the caller's, so a delayed activation
2505
+ * does not re-walk and re-fsync a whole dependency tree it certified when it was built. The activation
2506
+ * journal is still written and fsynced BEFORE the first rename, so a crash anywhere below is recoverable —
2507
+ * see `settleInterruptedActivation` for the state matrix. The second rename is the COMMIT POINT: nothing
2508
+ * after it may compensate, because the live path holds the candidate and renaming the aside back over it
2509
+ * cannot succeed.
1876
2510
  */
1877
- async function activateCandidateApplication(application, deploymentId) {
2511
+ async function activateCandidateApplication(application, deploymentId, options = {}) {
1878
2512
  const liveDirPath = application.dirPath;
1879
2513
  const candidateDirPath = candidateApplicationPath(liveDirPath, deploymentId);
1880
2514
  const deploymentDirPath = candidateDeploymentDirPath(liveDirPath, deploymentId);
@@ -1889,64 +2523,166 @@ async function activateCandidateApplication(application, deploymentId) {
1889
2523
  if (!candidateStat || !(candidateStat.isDirectory() || candidateStat.isSymbolicLink())) {
1890
2524
  throw new Error(`Cannot activate ${application.name}: no candidate build at ${candidateDirPath}`);
1891
2525
  }
1892
- await markCandidateComplete(liveDirPath, deploymentId, application.name);
1893
2526
  const journalPath = activationJournalPath(liveDirPath, deploymentId);
1894
- try {
1895
- await writeControlFileDurably(journalPath, JSON.stringify({
1896
- v: ACTIVATION_JOURNAL_VERSION,
1897
- component: application.name,
1898
- candidateId: deploymentId,
1899
- }));
1900
- }
1901
- catch (error) {
1902
- // An existing journal is a retry of this same activation, not a conflict.
1903
- if (error.code !== 'EEXIST')
1904
- throw error;
1905
- }
1906
- await ensureExtractionStagingDirectory(asideStagingDir);
1907
- const liveExists = await (0, promises_1.lstat)(liveDirPath).then(() => true, (error) => {
1908
- if (error.code === 'ENOENT')
1909
- return false;
1910
- throw error;
1911
- });
1912
- application.isNewComponent = !liveExists;
1913
2527
  // B1 — the live tree moves aside. It stays the rollback source until B4 retires it.
1914
2528
  let asidePath;
1915
2529
  let priorAbsentRecordPath;
1916
- if (liveExists) {
1917
- asidePath = (0, node_path_1.join)(asideStagingDir, `${IN_PROGRESS_ASIDE_PREFIX}${Date.now()}-${process.pid}-${(0, node_crypto_1.randomUUID)()}`);
1918
- await (0, promises_1.rename)(liveDirPath, asidePath);
1919
- }
1920
- else {
1921
- priorAbsentRecordPath = (0, node_path_1.join)(asideStagingDir, `${IN_PROGRESS_ASIDE_PREFIX}${Date.now()}-${process.pid}-${(0, node_crypto_1.randomUUID)()}${PRIOR_ABSENT_RECORD_SUFFIX}`);
1922
- await (0, promises_1.writeFile)(priorAbsentRecordPath, '', { flag: 'wx', mode: 0o600 });
1923
- }
2530
+ // The swap below moves the previous tree back and forth around every wait, so a chosen aside path no
2531
+ // longer implies the tree is at it — and compensation needs to know which.
2532
+ let liveIsDisplaced = false;
1924
2533
  const restoreLive = async () => {
1925
- if (asidePath)
1926
- await (0, promises_1.rename)(asidePath, liveDirPath);
2534
+ if (liveIsDisplaced) {
2535
+ await renameThroughTransientHolder(asidePath, liveDirPath);
2536
+ liveIsDisplaced = false;
2537
+ }
1927
2538
  else if (priorAbsentRecordPath)
1928
2539
  await (0, promises_1.rm)(priorAbsentRecordPath, { force: true });
1929
- await syncRenameParents(asidePath ?? priorAbsentRecordPath, liveDirPath);
2540
+ // Nothing was displaced and no record was written, so there is nothing to put back and no rename to
2541
+ // flush — the failure happened before the first effect.
2542
+ if (asidePath || priorAbsentRecordPath) {
2543
+ await syncRenameParents(asidePath ?? priorAbsentRecordPath, liveDirPath);
2544
+ }
2545
+ };
2546
+ /**
2547
+ * Put a compensated candidate back to DORMANT — complete, described, no journal — so it is a retryable
2548
+ * artifact rather than one the next preparation destroys.
2549
+ *
2550
+ * Without this, a failure that leaves the journal in place makes `settleStagingForComponent` read
2551
+ * live-plus-candidate as an activation that never got there and remove the whole deployment directory;
2552
+ * for a first deploy the restored state is live-ABSENT, and it rolls the candidate forward instead,
2553
+ * ahead of the caller's own verification. Both destroy an artifact whose whole purpose is to be
2554
+ * activated again.
2555
+ *
2556
+ * The unlink needs its own barrier: syncing the aside directories persists the rollback record's
2557
+ * disposal, not the journal's, so without this sync a power loss resurrects a journal the operator was
2558
+ * told had been rolled back. Best-effort by necessity — failing here must not replace the activation
2559
+ * failure the caller is reporting — so a failure says explicitly that the artifact is not retryable, and
2560
+ * the surviving journal is exactly what startup recovery settles.
2561
+ */
2562
+ const returnToDormant = async () => {
2563
+ try {
2564
+ await (0, promises_1.rm)(journalPath, { force: true });
2565
+ }
2566
+ catch (error) {
2567
+ application.logger.warn(`Restored ${application.name} after a failed activation, but its staged build ${deploymentId} still ` +
2568
+ `carries an activation journal and is not retryable until recovery settles it:`, error);
2569
+ return;
2570
+ }
2571
+ // The journal is gone in this process, so the artifact is retryable now; what is uncertain is whether
2572
+ // its removal reached storage.
2573
+ await syncDirectory(deploymentDirPath).catch((error) => application.logger.warn(`Restored ${application.name} and returned its staged build ${deploymentId} to a retryable state, but ` +
2574
+ `could not flush that to storage; a power loss could resurrect its activation journal:`, error));
1930
2575
  };
1931
- // Still BEFORE the commit point, so this is compensable — and must be compensated. Letting a storage
1932
- // failure escape here leaves live already moved aside, and the caller reads an uncompensated throw as an
1933
- // ordinary build failure and discards the candidate, its `.complete` marker and its journal: the
1934
- // component ends up with no version at all and nothing saying how to get one back.
2576
+ /**
2577
+ * One pre-commit failure boundary, with the journal write inside it: every step from there to the commit
2578
+ * rename leaves a journal behind if it only rethrows, and the next settlement then deletes a certified
2579
+ * artifact, or (first deploy, live absent) activates it with nobody asking. Nothing below the commit
2580
+ * rename may enter this catch — see B2.
2581
+ */
2582
+ let pendingEffect = 'record the activation';
2583
+ let undoAfterJournal;
1935
2584
  try {
2585
+ try {
2586
+ await writeControlFileDurably(journalPath, JSON.stringify({
2587
+ v: ACTIVATION_JOURNAL_VERSION,
2588
+ component: application.name,
2589
+ candidateId: deploymentId,
2590
+ }));
2591
+ }
2592
+ catch (error) {
2593
+ // An existing journal is a retry of this same activation, not a conflict.
2594
+ if (error.code !== 'EEXIST')
2595
+ throw error;
2596
+ }
2597
+ pendingEffect = 'prepare the component staging directory';
2598
+ await ensureExtractionStagingDirectory(asideStagingDir);
2599
+ pendingEffect = 'read the live component directory';
2600
+ const liveExists = await (0, promises_1.lstat)(liveDirPath).then(() => true, (error) => {
2601
+ if (error.code === 'ENOENT')
2602
+ return false;
2603
+ throw error;
2604
+ });
2605
+ application.isNewComponent = !liveExists;
2606
+ pendingEffect = 'move the previous version aside';
2607
+ if (liveExists) {
2608
+ asidePath = (0, node_path_1.join)(asideStagingDir, `${IN_PROGRESS_ASIDE_PREFIX}${Date.now()}-${process.pid}-${(0, node_crypto_1.randomUUID)()}`);
2609
+ await renameThroughTransientHolder(liveDirPath, asidePath);
2610
+ liveIsDisplaced = true;
2611
+ }
2612
+ else {
2613
+ priorAbsentRecordPath = (0, node_path_1.join)(asideStagingDir, `${IN_PROGRESS_ASIDE_PREFIX}${Date.now()}-${process.pid}-${(0, node_crypto_1.randomUUID)()}${PRIOR_ABSENT_RECORD_SUFFIX}`);
2614
+ await (0, promises_1.writeFile)(priorAbsentRecordPath, '', { flag: 'wx', mode: 0o600 });
2615
+ }
2616
+ // Letting a storage failure escape here leaves live already moved aside, and the caller reads an
2617
+ // uncompensated throw as an ordinary build failure and discards the candidate, its `.complete` marker
2618
+ // and its journal: the component ends up with no version at all and nothing saying how to get one back.
2619
+ pendingEffect = 'record the displaced component directory';
1936
2620
  await syncRenameParents(liveDirPath, asidePath ?? priorAbsentRecordPath);
2621
+ // Config is published HERE — after B1, before the commit — because this is the only point where the
2622
+ // on-disk state recovery would find rolls FORWARD to the certified artifact: live is displaced, the
2623
+ // candidate is complete, and the rollback record exists. Publishing before B1 (with or without the
2624
+ // journal) leaves live present and the candidate present with no rollback record, which settlement
2625
+ // reads as an activation that never started: it deletes the deployment directory, and the next boot
2626
+ // re-resolves the published package identifier from the registry instead — the substitution this step
2627
+ // exists to prevent.
2628
+ //
2629
+ // A crash in the remaining window — after the roll-forward state exists but before this publish — is
2630
+ // the inverse hazard: `rollForward()` renames the candidate live and publishes nothing, so the
2631
+ // certified artifact serves under the PREVIOUS release's config. That includes its ISOLATION intent,
2632
+ // which is a containment boundary and not just a version string: a component staged to run isolated
2633
+ // comes back non-isolated after an ordinary crash, with nothing in the operation reporting it.
2634
+ // Closing it needs config to be an effect of the journal itself, which is #2315 step 3.
2635
+ pendingEffect = 'publish the root configuration';
2636
+ undoAfterJournal = await options.afterJournal?.();
2637
+ // B2 — the candidate becomes live. THE RENAME IS THE COMMIT POINT: nothing after it may compensate,
2638
+ // because the live path now holds the candidate and renaming the aside back over it cannot succeed. A
2639
+ // compensating step there fails its own rollback and reports a failure for a deploy that is live. It is
2640
+ // the LAST statement in this block for that reason.
2641
+ pendingEffect = 'move the candidate into place';
2642
+ await renameThroughTransientHolder(candidateDirPath, liveDirPath, {
2643
+ // The previous version occupies the live path through the wait rather than the component being
2644
+ // absent for the whole budget: a read of a component file, and any concurrent scan of the
2645
+ // components root, still finds the last committed tree. (Watchers are already paused for the
2646
+ // deploy, so they are not what this protects.) A first-ever deploy has nothing to put back.
2647
+ onBackoff: async (delayMs, deadline) => {
2648
+ if (!liveIsDisplaced)
2649
+ return (0, promises_3.setTimeout)(delayMs);
2650
+ await renameThroughTransientHolder(asidePath, liveDirPath, { deadline });
2651
+ liveIsDisplaced = false;
2652
+ await syncRenameParents(asidePath, liveDirPath);
2653
+ await (0, promises_3.setTimeout)(delayMs);
2654
+ await renameThroughTransientHolder(liveDirPath, asidePath, { deadline });
2655
+ liveIsDisplaced = true;
2656
+ await syncRenameParents(liveDirPath, asidePath);
2657
+ },
2658
+ });
1937
2659
  }
1938
2660
  catch (error) {
1939
- await compensate(error, 'record the displaced component directory', restoreLive, application);
1940
- throw error;
1941
- }
1942
- // B2 — the candidate becomes live. THE RENAME IS THE COMMIT POINT: nothing after it may compensate,
1943
- // because the live path now holds the candidate and renaming the aside back over it cannot succeed. A
1944
- // compensating step there fails its own rollback and reports a failure for a deploy that is live.
1945
- try {
1946
- await (0, promises_1.rename)(candidateDirPath, liveDirPath);
1947
- }
1948
- catch (error) {
1949
- await compensate(error, 'move the candidate into place', restoreLive, application);
2661
+ // Whether the journal can still carry this activation forward, decided BEFORE compensation removes the
2662
+ // evidence it is read from. Only a first-ever deploy qualifies: `restoreLive` leaves the live path
2663
+ // absent, and recovery reads absent-plus-complete-candidate as a roll forward. A component that
2664
+ // already had a tree gets that tree back and loses its rollback record with it, so the next settle
2665
+ // reads live-plus-candidate-with-no-record and returns the artifact to dormant whatever the journal
2666
+ // says — keeping it there defers the same verdict to the next start and strands config until then.
2667
+ const recoveryCanRollForward = priorAbsentRecordPath !== undefined;
2668
+ await compensate(error, pendingEffect, restoreLive, application);
2669
+ let configRestored = true;
2670
+ if (undoAfterJournal) {
2671
+ configRestored = await undoAfterJournal().then(() => true, (undoError) => {
2672
+ application.logger.warn(`Restored ${application.name} after a failed activation but could not restore its root config, ` +
2673
+ `which still names deployment ${deploymentId}` +
2674
+ (recoveryCanRollForward
2675
+ ? '; keeping its activation journal so recovery can roll the certified build forward instead:'
2676
+ : '. The certified build stays dormant and the previous release stays live, so the two ' +
2677
+ 'disagree until an operator republishes the component or activates it again:'), undoError);
2678
+ return false;
2679
+ });
2680
+ }
2681
+ // Kept only where it changes the outcome. Config is stranded either way for a component that already
2682
+ // had a tree — the durable-config window #2315 step 3 closes — and a journal that recovery will only
2683
+ // settle back to dormant buys nothing for holding it.
2684
+ if (configRestored || !recoveryCanRollForward)
2685
+ await returnToDormant();
1950
2686
  throw error;
1951
2687
  }
1952
2688
  // Past the point of no return: each failure below leaves a state recovery settles forward, so they are
@@ -2139,15 +2875,20 @@ async function discardCandidate(application, deploymentId) {
2139
2875
  * Failure needs no compensation, which is the whole point: nothing about the live component was modified,
2140
2876
  * so the abandoned candidate is simply removed and the error propagates.
2141
2877
  */
2142
- async function buildCandidateApplication(application, deploymentId) {
2878
+ async function buildCandidateApplication(application, deploymentId, options = {}) {
2143
2879
  const deploymentDirPath = candidateDeploymentDirPath(application.dirPath, deploymentId);
2144
2880
  const candidateDirPath = candidateApplicationPath(application.dirPath, deploymentId);
2145
2881
  await ensureSecureStagingDirectory((0, node_path_1.dirname)(deploymentDirPath));
2146
- await ensureSecureStagingDirectory(deploymentDirPath);
2882
+ await claimDeploymentDirectory(deploymentDirPath, application.name);
2147
2883
  try {
2148
2884
  // Replaced, not extracted into: a prior attempt on this id may have left a partial tree.
2149
2885
  await (0, promises_1.rm)(candidateDirPath, { recursive: true, force: true });
2150
2886
  const resolved = await resolveApplicationTarball(application);
2887
+ if (resolved.kind === 'link' && options.rejectLinkSource) {
2888
+ // What the operator asked for, not a server fault: the same component deploys immediately.
2889
+ throw new hdbError_ts_1.ClientError(`Cannot stage ${application.name} from ${application.packageIdentifier}: a 'file:' directory is linked ` +
2890
+ `rather than copied, so the bytes activated later are not the bytes this build certified`);
2891
+ }
2151
2892
  if (resolved.kind === 'link') {
2152
2893
  // A `file:` directory becomes a symlink AT THE CANDIDATE PATH, so it is validated and swapped in
2153
2894
  // like any other candidate instead of appearing at the live path unvalidated.
@@ -2337,6 +3078,13 @@ async function dropComponentDirectory(componentDirPath, componentName = (0, node
2337
3078
  throw error;
2338
3079
  }
2339
3080
  await cleanupExtractionPaths({ name: componentName, dirPath: componentDirPath, logger: componentLogger }, asideStagingDir, new Set([droppedPath]));
3081
+ // A dropped component has no next deploy to bound its dormant builds.
3082
+ try {
3083
+ await pruneDormantBuilds(componentName, await dormantBuildsOf((0, node_path_1.dirname)(componentDirPath), componentName), 0);
3084
+ }
3085
+ catch (error) {
3086
+ componentLogger.warn(`Dropped ${componentName} but could not reclaim its dormant staged builds:`, (0, harper_logger_ts_1.errorForLog)(error));
3087
+ }
2340
3088
  }
2341
3089
  async function cleanupExtractionPaths(application, asideStagingDir, paths) {
2342
3090
  const retiredMarkers = [];
@@ -2692,13 +3440,17 @@ async function installApplication(application, buildDirPath = application.dirPat
2692
3440
  throw err;
2693
3441
  // If node_modules doesn't exist, we need to install dependencies
2694
3442
  }
3443
+ const allowInstallScripts = !!application.install?.allowInstallScripts;
2695
3444
  // If custom install command is specified, run it
2696
3445
  if (application.install?.command) {
3446
+ if (application.install.allowInstallScripts === undefined) {
3447
+ application.logger.warn(`Application ${application.name} uses install_command without install_allow_scripts; package lifecycle scripts are disabled by default for npm and tools that honor npm_config_ignore_scripts, including npm run pre/post hooks. Set install_allow_scripts (or install.allowInstallScripts in root config) to true to opt in`);
3448
+ }
2697
3449
  const [command, ...args] = application.install.command.split(' ');
2698
3450
  const customOnLine = application.onInstallLine
2699
3451
  ? (stream, line) => application.onInstallLine(command, stream, line)
2700
3452
  : undefined;
2701
- const { stdout, stderr, code } = await nonInteractiveSpawn(application.name, command, args, buildDirPath, application.install?.timeout, customOnLine, application.npmUserconfigPath);
3453
+ const { stdout, stderr, code } = await nonInteractiveSpawn(application.name, command, args, buildDirPath, application.install?.timeout, customOnLine, application.npmUserconfigPath, undefined, !allowInstallScripts);
2702
3454
  // if it succeeds, return
2703
3455
  if (code === 0) {
2704
3456
  application.installationIsOpaque = true;
@@ -2713,7 +3465,6 @@ async function installApplication(application, buildDirPath = application.dirPat
2713
3465
  // and throw a descriptive error
2714
3466
  throw new Error(`Failed to install dependencies for ${application.name} using custom install command: ${application.install.command}. Exit code: ${code}`);
2715
3467
  }
2716
- const allowInstallScripts = !!application.install?.allowInstallScripts;
2717
3468
  const { packageManager } = packageJSON.devEngines || {};
2718
3469
  if (dependencyFieldHasWork(packageJSON, 'devDependencies')) {
2719
3470
  application.logger.warn(`Application ${application.name} declares devDependencies; automatic npm installation omits them, while explicitly selected non-npm package managers retain their own install defaults. Use install_command when deployment requires custom behavior`);
@@ -2975,10 +3726,13 @@ function shouldPackLocalDirectory(packageIdentifier, platform = process.platform
2975
3726
  return platform === 'win32' && !!packageIdentifier && isBareAbsolutePackagePath(packageIdentifier);
2976
3727
  }
2977
3728
  async function prepareApplication(application, options = {}) {
2978
- const deploymentId = await (0, deployLifecycle_ts_1.broadcastDeployStart)(application.name);
3729
+ const lifecycleToken = await (0, deployLifecycle_ts_1.broadcastDeployStart)(application.name);
3730
+ const mode = options.mode ?? 'deploy';
3731
+ const artifactId = options.artifactId ?? lifecycleToken;
2979
3732
  try {
2980
3733
  const commandTimeoutMs = application.install?.timeout ?? DEFAULT_COMMAND_TIMEOUT_MS;
2981
3734
  await (0, componentPreparationLock_ts_1.withComponentPreparationLock)(application.dirPath, async () => {
3735
+ await options.beforePrepare?.();
2982
3736
  const asideStagingDir = extractionStagingDirectory(application.dirPath);
2983
3737
  let recoveryPending = true;
2984
3738
  try {
@@ -2991,7 +3745,12 @@ async function prepareApplication(application, options = {}) {
2991
3745
  }
2992
3746
  // BEFORE the legacy pass. That pass refuses to restore while a journal survives, so skipping
2993
3747
  // this would not lose data — it would just stall the deploy behind its own unsettled state.
2994
- await settleJournaledActivationsForComponent((0, node_path_1.dirname)(application.dirPath), application.name);
3748
+ //
3749
+ // The id this request names is pinned against the retention this runs. An activation would
3750
+ // otherwise have its own artifact deleted by its own preamble; and a redelivered stage would
3751
+ // have the artifact evicted out from under the exclusive claim below, which would then rebuild
3752
+ // different bytes under an id that already named some.
3753
+ await settleStagingForComponent((0, node_path_1.dirname)(application.dirPath), application.name, artifactId);
2995
3754
  if (recoveryPending) {
2996
3755
  await ensureExtractionStagingDirectory(asideStagingDir);
2997
3756
  await recoverOrCleanupStaleExtractionPaths(application, asideStagingDir);
@@ -3004,6 +3763,10 @@ async function prepareApplication(application, options = {}) {
3004
3763
  return false;
3005
3764
  throw error;
3006
3765
  }));
3766
+ if (mode === 'activate') {
3767
+ await activateStagedArtifact(application, artifactId, previousPackageMetadata, options);
3768
+ return;
3769
+ }
3007
3770
  try {
3008
3771
  // Materialize the per-deploy `.npmrc` before the build so both `npm pack` and `npm install`
3009
3772
  // authenticate against the private registry; always remove it afterward.
@@ -3014,7 +3777,9 @@ async function prepareApplication(application, options = {}) {
3014
3777
  // credential is already gone before any install script runs. This finally covers the paths
3015
3778
  // that fail before it gets there.
3016
3779
  await application.startGitCredentialSession();
3017
- candidateDirPath = await buildCandidateApplication(application, deploymentId);
3780
+ candidateDirPath = await buildCandidateApplication(application, artifactId, {
3781
+ rejectLinkSource: mode === 'stage',
3782
+ });
3018
3783
  }
3019
3784
  finally {
3020
3785
  await application.cleanupGitCredentialSession();
@@ -3026,7 +3791,25 @@ async function prepareApplication(application, options = {}) {
3026
3791
  if (!application.isNewComponent) {
3027
3792
  application.packageMetadataChanged = installedRuntimeChanged(previousPackageMetadata, await readInstalledPackageMetadata(candidateDirPath), application.installationIsOpaque);
3028
3793
  }
3029
- await activateCandidateApplication(application, deploymentId);
3794
+ if (mode === 'stage') {
3795
+ await assertOwnedArtifactTree(candidateDirPath, application.name);
3796
+ // The descriptor goes first so `.complete` vouches for it: after this pair the artifact
3797
+ // is dormant, and a delayed activation reads its build's decisions from here because
3798
+ // nothing on disk carries them otherwise.
3799
+ const declared = options.describeArtifact?.() ?? { rootConfig: null, isolated: false };
3800
+ await writeArtifactDescriptor(application.dirPath, artifactId, {
3801
+ v: ARTIFACT_DESCRIPTOR_VERSION,
3802
+ component: application.name,
3803
+ rootConfig: declared.rootConfig,
3804
+ installationIsOpaque: application.installationIsOpaque,
3805
+ isolated: declared.isolated,
3806
+ });
3807
+ await markCandidateComplete(application.dirPath, artifactId, application.name);
3808
+ await syncArtifactAncestors(candidateDeploymentDirPath(application.dirPath, artifactId));
3809
+ return;
3810
+ }
3811
+ await markCandidateComplete(application.dirPath, artifactId, application.name);
3812
+ await activateCandidateApplication(application, artifactId);
3030
3813
  }
3031
3814
  catch (error) {
3032
3815
  // The builder's own cleanup only covers a failed BUILD. A rejected validation, or an
@@ -3038,7 +3821,7 @@ async function prepareApplication(application, options = {}) {
3038
3821
  // what recovery needs to roll the validated deploy forward at the next start. Discarding
3039
3822
  // them there trades a bounded disk cost for a component with no version at all.
3040
3823
  if (!compensationIncomplete(error))
3041
- await discardCandidate(application, deploymentId);
3824
+ await discardCandidate(application, artifactId);
3042
3825
  throw error;
3043
3826
  }
3044
3827
  }
@@ -3059,9 +3842,71 @@ async function prepareApplication(application, options = {}) {
3059
3842
  });
3060
3843
  }
3061
3844
  finally {
3062
- (0, deployLifecycle_ts_1.broadcastDeployEnd)(application.name, deploymentId);
3845
+ (0, deployLifecycle_ts_1.broadcastDeployEnd)(application.name, lifecycleToken);
3063
3846
  }
3064
3847
  }
3848
+ /**
3849
+ * Swap an already-certified artifact into the live path. Called with the component's preparation lock held
3850
+ * and after the same recovery preamble every build runs, so the state read here is settled.
3851
+ *
3852
+ * Nothing is resolved, fetched or installed: the bytes were certified when they were staged, which is the
3853
+ * whole point of addressing one by id. Verification is therefore the only gate, and it is strict — this is
3854
+ * activation input for a build this process did not make and may not have made on this node.
3855
+ *
3856
+ * A rejection here must NOT discard: the artifact belongs to whoever staged it, a wrong-component request
3857
+ * must not delete another component's build, and a retry needs what a failed attempt left behind.
3858
+ */
3859
+ async function activateStagedArtifact(application, artifactId, previousPackageMetadata, options) {
3860
+ const deploymentDirPath = candidateDeploymentDirPath(application.dirPath, artifactId);
3861
+ const candidateDirPath = candidateApplicationPath(application.dirPath, artifactId);
3862
+ // 404 means only one thing — no artifact answers to this id here — so a caller can tell "never existed or
3863
+ // already used" from "present, but not something this can activate", which is every other refusal below
3864
+ // and a 409 like the rest of that family.
3865
+ const refuse = (why, statusCode) => new hdbError_ts_1.ClientError(`Cannot deploy ${application.name} from deployment ${artifactId}: ${why}`, statusCode);
3866
+ const missing = (why) => refuse(why, 404);
3867
+ const unusable = (why) => refuse(why, 409);
3868
+ const owner = await candidateComponentName(deploymentDirPath);
3869
+ if (owner === undefined)
3870
+ throw missing('there is no staged build with that id on this node');
3871
+ if (owner !== application.name)
3872
+ throw unusable(`that staged build belongs to '${owner}'`);
3873
+ if (!(await presentOrAbsent((0, node_path_1.join)(deploymentDirPath, CANDIDATE_COMPLETE_MARKER)))) {
3874
+ throw unusable('its build never completed');
3875
+ }
3876
+ if (await presentOrAbsent((0, node_path_1.join)(deploymentDirPath, UNSETTLED_MARKER))) {
3877
+ throw unusable('recovery could not settle it, so it is not safe to activate');
3878
+ }
3879
+ if (await presentOrAbsent((0, node_path_1.join)(deploymentDirPath, ACTIVATION_JOURNAL))) {
3880
+ throw unusable('an activation of it is unsettled');
3881
+ }
3882
+ const candidateStat = await presentOrAbsent(candidateDirPath);
3883
+ if (!candidateStat || !candidateStat.isDirectory()) {
3884
+ // Unlike an immediate deploy, a symlink is refused: a `file:` directory is linked rather than
3885
+ // copied, so what it points at now is not what was certified. Staging rejects the source for the
3886
+ // same reason; this is the other end of the same rule, for an artifact staged by an older build.
3887
+ throw unusable('its build tree is missing or is a link rather than a copy');
3888
+ }
3889
+ const descriptor = await readArtifactDescriptor(deploymentDirPath, application.name);
3890
+ if (!descriptor)
3891
+ throw unusable('it does not record what its build decided');
3892
+ await options.admitIsolation?.(descriptor);
3893
+ // `.complete` is a durability marker over the bytes, not a seal on them, and every check that made this
3894
+ // artifact safe ran at stage time. The link rule especially cannot be skipped:
3895
+ // `repairRelocatedDependencyLinks` runs past the commit point and can only warn, so a link planted while
3896
+ // the artifact sat dormant would otherwise go live with the operation reporting success.
3897
+ await assertOwnedArtifactTree(candidateDirPath, application.name, 'activate');
3898
+ // Also the activation's `prepare` phase end: the deploy path emits `prepare`/`start` for every mode but
3899
+ // only ever emitted its `done` from here.
3900
+ await options.validateCandidate?.(candidateDirPath);
3901
+ if (!application.isNewComponent) {
3902
+ application.packageMetadataChanged = installedRuntimeChanged(previousPackageMetadata, await readInstalledPackageMetadata(candidateDirPath), descriptor.installationIsOpaque);
3903
+ }
3904
+ await activateCandidateApplication(application, artifactId, {
3905
+ // Returns its own undo, which the swap runs inside its pre-commit boundary — see there for why it
3906
+ // cannot be run out here.
3907
+ afterJournal: descriptor.rootConfig ? () => options.publishRootConfig(descriptor.rootConfig) : undefined,
3908
+ });
3909
+ }
3065
3910
  /**
3066
3911
  * Install all applications specified in the root config.
3067
3912
  *
@@ -3399,16 +4244,16 @@ function createLineSplitter(onLine) {
3399
4244
  * @param timeoutMs The timeout for the command in milliseconds. Defaults to DEFAULT_COMMAND_TIMEOUT_MS.
3400
4245
  * @returns A promise that resolves when the command completes.
3401
4246
  */
3402
- async function nonInteractiveSpawn(applicationName, command, args, cwd, timeoutMs = DEFAULT_COMMAND_TIMEOUT_MS, onLine, npmUserconfigPath, gitCredentialEnv) {
4247
+ async function nonInteractiveSpawn(applicationName, command, args, cwd, timeoutMs = DEFAULT_COMMAND_TIMEOUT_MS, onLine, npmUserconfigPath, gitCredentialEnv, ignoreNpmScripts = false) {
3403
4248
  const gitSSH = await materializeGitSSH();
3404
4249
  try {
3405
- return await spawnWithEnv(applicationName, command, args, cwd, timeoutMs, onLine, npmUserconfigPath, gitSSH?.command, gitCredentialEnv);
4250
+ return await spawnWithEnv(applicationName, command, args, cwd, timeoutMs, onLine, npmUserconfigPath, gitSSH?.command, gitCredentialEnv, ignoreNpmScripts);
3406
4251
  }
3407
4252
  finally {
3408
4253
  await gitSSH?.cleanup();
3409
4254
  }
3410
4255
  }
3411
- function spawnWithEnv(applicationName, command, args, cwd, timeoutMs, onLine, npmUserconfigPath, gitSSHCommand, gitCredentialEnv) {
4256
+ function spawnWithEnv(applicationName, command, args, cwd, timeoutMs, onLine, npmUserconfigPath, gitSSHCommand, gitCredentialEnv, ignoreNpmScripts) {
3412
4257
  return new Promise((resolve, reject) => {
3413
4258
  harper_logger_ts_1.default
3414
4259
  .loggerWithTag(`${applicationName}:spawn:${command}`)
@@ -3441,6 +4286,13 @@ function spawnWithEnv(applicationName, command, args, cwd, timeoutMs, onLine, np
3441
4286
  }
3442
4287
  env.npm_config_userconfig = npmUserconfigPath;
3443
4288
  }
4289
+ if (ignoreNpmScripts) {
4290
+ for (const key of Object.keys(env)) {
4291
+ if (key.toLowerCase() === 'npm_config_ignore_scripts')
4292
+ delete env[key];
4293
+ }
4294
+ env.npm_config_ignore_scripts = 'true';
4295
+ }
3444
4296
  if (process.platform === 'win32' && command === 'npm') {
3445
4297
  command = 'npm.cmd';
3446
4298
  }