@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
@@ -1,6 +1,7 @@
1
1
  import { type Logger } from '../utility/logging/logger.ts';
2
2
  import { getConfigObj, getConfigValue, getConfigPath } from '../config/configUtils.ts';
3
3
  import { CONFIG_PARAMS } from '../utility/hdbTerms.ts';
4
+ import { ClientError } from '../utility/errors/hdbError.ts';
4
5
  import logger, { errorForLog } from '../utility/logging/harper_logger.ts';
5
6
  import { broadcastDeployStart, broadcastDeployEnd } from './deployLifecycle.ts';
6
7
  import { ComponentPreparationLockTimeoutError, withComponentPreparationLock } from './componentPreparationLock.ts';
@@ -20,7 +21,7 @@ import {
20
21
  import { getSecretDecryptor } from '../resources/secretDecryptor.ts';
21
22
  import { ENV_ENCRYPTED_PREFIX } from '../utility/envFile.ts';
22
23
 
23
- import { basename, dirname, extname, isAbsolute, join, relative, win32 } from 'node:path';
24
+ import { basename, dirname, extname, isAbsolute, join, relative, sep, win32 } from 'node:path';
24
25
  import {
25
26
  access,
26
27
  chmod,
@@ -33,6 +34,7 @@ import {
33
34
  readdir,
34
35
  readFile,
35
36
  readlink,
37
+ realpath,
36
38
  rename,
37
39
  rmdir,
38
40
  rm,
@@ -84,6 +86,8 @@ interface ApplicationConfig {
84
86
  * Per-application globals are a property of thread-level isolation, not of branching.
85
87
  */
86
88
  branchedDatabases?: string[] | true;
89
+ /** Run in a worker thread of its own that loads no other application. */
90
+ isolated?: boolean;
87
91
  // an application config can have other arbitrary properties
88
92
  [key: string]: unknown;
89
93
  }
@@ -218,6 +222,15 @@ export function assertApplicationConfig(
218
222
  }
219
223
  }
220
224
  assertBranchedDatabases(applicationName, applicationConfig.branchedDatabases);
225
+ assertIsolationConfig(applicationName, applicationConfig.isolated);
226
+ }
227
+
228
+ export function assertIsolationConfig(applicationName: string, isolated: unknown): void {
229
+ if (isolated !== undefined && typeof isolated !== 'boolean') {
230
+ throw new TypeError(
231
+ `Invalid 'isolated' for application ${applicationName}: expected a boolean, got ${typeof isolated}`
232
+ );
233
+ }
221
234
  }
222
235
 
223
236
  /**
@@ -1032,7 +1045,134 @@ const CANDIDATE_COMPONENT_FILE = '.component';
1032
1045
  // well-formed. Workers cannot infer that case: a well-formed journal is indistinguishable from one belonging
1033
1046
  // to a deploy in flight, so without a record they would treat an unsettled component as healthy and load it.
1034
1047
  const UNSETTLED_MARKER = '.unsettled';
1048
+ // Everything the build decided that a later activation cannot re-derive: the root-config entry to publish,
1049
+ // whether the installation is opaque to metadata comparison, and the isolation intent that was admitted.
1050
+ // Written before `.complete`, so the marker vouches for it. An OPTIONAL record would not do: it could not
1051
+ // distinguish a payload build, which owns no root config, from a package build whose record was lost.
1052
+ const CANDIDATE_ARTIFACT_FILE = '.artifact.json';
1035
1053
  const ACTIVATION_JOURNAL_VERSION = 1;
1054
+ const ARTIFACT_DESCRIPTOR_VERSION = 1;
1055
+ const DEFAULT_STAGING_RETENTION_MAX_COUNT = 5;
1056
+
1057
+ /** `deployment_stagingRetention_maxCount`; 0 keeps none. Only a number or numeric string counts, so `true`/`[]`/blank cannot become "keep nothing". */
1058
+ export function getStagingRetentionMaxCount(): number {
1059
+ const configured = getConfigValue(CONFIG_PARAMS.DEPLOYMENT_STAGINGRETENTION_MAXCOUNT);
1060
+ if (typeof configured !== 'number' && typeof configured !== 'string') return DEFAULT_STAGING_RETENTION_MAX_COUNT;
1061
+ if (typeof configured === 'string' && configured.trim() === '') return DEFAULT_STAGING_RETENTION_MAX_COUNT;
1062
+ const parsed = Number(configured);
1063
+ return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : DEFAULT_STAGING_RETENTION_MAX_COUNT;
1064
+ }
1065
+
1066
+ type DormantBuild = {
1067
+ deploymentDirPath: string;
1068
+ deploymentId: string;
1069
+ completedAt: number;
1070
+ };
1071
+
1072
+ async function presentOrAbsent(path: string): Promise<import('node:fs').Stats | undefined> {
1073
+ return lstat(path).catch((error: NodeJS.ErrnoException) => {
1074
+ if (error?.code === 'ENOENT') return undefined;
1075
+ throw error;
1076
+ });
1077
+ }
1078
+
1079
+ /**
1080
+ * A dormant build: complete, tree present, no journal. Activation writes `.complete` moments before its
1081
+ * journal under the owner's preparation lock, so only a read under that lock is a verdict. A stale
1082
+ * `.unsettled` makes it residue instead, since only removing the directory clears that marker for workers.
1083
+ * Only ENOENT is absence; any other read error propagates so the caller preserves the entry.
1084
+ */
1085
+ async function dormantBuildAt(deploymentDirPath: string, owner: string): Promise<DormantBuild | undefined> {
1086
+ const complete = await presentOrAbsent(join(deploymentDirPath, CANDIDATE_COMPLETE_MARKER));
1087
+ if (!complete) return undefined;
1088
+ if (await presentOrAbsent(join(deploymentDirPath, UNSETTLED_MARKER))) return undefined;
1089
+ const tree = await presentOrAbsent(join(deploymentDirPath, owner));
1090
+ if (!tree || !(tree.isDirectory() || tree.isSymbolicLink())) return undefined;
1091
+ return { deploymentDirPath, deploymentId: basename(deploymentDirPath), completedAt: complete.mtimeMs };
1092
+ }
1093
+
1094
+ /**
1095
+ * Remove the oldest dormant builds beyond `maxCount`. The caller must hold the component's preparation lock;
1096
+ * every catalogued build is re-derived under it before the kept set is chosen, so a catalog read unlocked
1097
+ * cannot hold or miss a slot. Never throws: a failure must neither fail a component closed nor replace a
1098
+ * deploy's own error.
1099
+ *
1100
+ * `pinnedDeploymentId` is never evicted. A delayed activation runs this preamble under the same lock it is
1101
+ * about to activate under, so without the pin retention would delete the artifact the request named —
1102
+ * immediately, when the knob is `0`. The pin is applied after the kept set is chosen, so a pinned build in
1103
+ * the eviction tail leaves `maxCount + 1` on disk for the life of the request; the next preamble that does
1104
+ * not pin it brings the count back down.
1105
+ */
1106
+ export async function pruneDormantBuilds(
1107
+ componentName: string,
1108
+ builds: DormantBuild[],
1109
+ maxCount: number,
1110
+ pinnedDeploymentId?: string
1111
+ ): Promise<void> {
1112
+ const current: DormantBuild[] = [];
1113
+ for (const build of builds) {
1114
+ try {
1115
+ const fresh = await dormantBuildAt(build.deploymentDirPath, componentName);
1116
+ if (fresh && !(await presentOrAbsent(join(build.deploymentDirPath, ACTIVATION_JOURNAL)))) current.push(fresh);
1117
+ } catch (error) {
1118
+ logger.warn(
1119
+ `Leaving deploy staging ${build.deploymentDirPath} out of retention; it could not be read:`,
1120
+ errorForLog(error)
1121
+ );
1122
+ }
1123
+ }
1124
+ const evictions = current
1125
+ .sort(
1126
+ (left, right) =>
1127
+ right.completedAt - left.completedAt ||
1128
+ (left.deploymentId < right.deploymentId ? -1 : left.deploymentId > right.deploymentId ? 1 : 0)
1129
+ )
1130
+ .slice(Math.max(0, maxCount))
1131
+ .filter((build) => build.deploymentId !== pinnedDeploymentId);
1132
+ for (const build of evictions) {
1133
+ try {
1134
+ await rm(build.deploymentDirPath, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
1135
+ logger.debug?.(
1136
+ `Pruned dormant staged build ${build.deploymentId} of ${componentName} beyond deployment_stagingRetention_maxCount=${maxCount}`
1137
+ );
1138
+ } catch (error) {
1139
+ logger.warn(
1140
+ `Could not prune dormant staged build ${build.deploymentDirPath} of ${componentName}; it remains beyond ` +
1141
+ `deployment_stagingRetention_maxCount=${maxCount}:`,
1142
+ errorForLog(error)
1143
+ );
1144
+ }
1145
+ }
1146
+ }
1147
+
1148
+ /** Every dormant build a component owns; an unreadable directory is left out and logged. */
1149
+ async function dormantBuildsOf(componentsRootDirPath: string, componentName: string): Promise<DormantBuild[]> {
1150
+ const stagingRoot = join(componentsRootDirPath, DEPLOY_STAGING_DIR);
1151
+ let deployments;
1152
+ try {
1153
+ deployments = await readdir(stagingRoot, { withFileTypes: true });
1154
+ } catch (error) {
1155
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
1156
+ throw error;
1157
+ }
1158
+ const builds: DormantBuild[] = [];
1159
+ for (const deployment of deployments) {
1160
+ if (!deployment.isDirectory()) continue;
1161
+ const deploymentDirPath = join(stagingRoot, deployment.name);
1162
+ try {
1163
+ if ((await candidateComponentName(deploymentDirPath)) !== componentName) continue;
1164
+ if (await presentOrAbsent(join(deploymentDirPath, ACTIVATION_JOURNAL))) continue;
1165
+ const build = await dormantBuildAt(deploymentDirPath, componentName);
1166
+ if (build) builds.push(build);
1167
+ } catch (error) {
1168
+ logger.warn(
1169
+ `Leaving deploy staging ${deploymentDirPath} out of retention; it could not be read:`,
1170
+ errorForLog(error)
1171
+ );
1172
+ }
1173
+ }
1174
+ return builds;
1175
+ }
1036
1176
 
1037
1177
  /**
1038
1178
  * Best-effort fsync of a directory. Best-effort by necessity — Node cannot fsync a directory on Windows —
@@ -1073,6 +1213,62 @@ async function syncRenameParents(fromPath: string, toPath: string): Promise<void
1073
1213
  for (const parent of parents) await syncDirectory(parent);
1074
1214
  }
1075
1215
 
1216
+ // Deliberately NOT `EEXIST`/`ENOTEMPTY`/`ENOTDIR`/`EISDIR`: those say the destination exists, which
1217
+ // nothing here clears between attempts, so waiting on them would only delay reporting a tree something
1218
+ // recreated — the case `settleInterruptedActivation` fails closed rather than guessing.
1219
+ // `rollbackExtractedDirectory` does retry them, because its placeholder logic repairs the destination.
1220
+ const TRANSIENT_RENAME_CODES = new Set(['EPERM', 'EACCES', 'EBUSY']);
1221
+ const RENAME_RETRY_BUDGET_MS = 5000;
1222
+ const RENAME_RETRY_INITIAL_DELAY_MS = 10;
1223
+ const RENAME_RETRY_MAX_DELAY_MS = 500;
1224
+
1225
+ /**
1226
+ * Rename, waiting out a holder that has not let go yet — on Windows a rename is refused outright while
1227
+ * anything still has a handle in the source tree.
1228
+ *
1229
+ * `onBackoff` replaces the sleep between attempts; `deadline` lets a rename it performs share this
1230
+ * call's budget instead of opening its own.
1231
+ */
1232
+ async function renameThroughTransientHolder(
1233
+ fromPath: string,
1234
+ toPath: string,
1235
+ options: { onBackoff?: (delayMs: number, deadline: number) => Promise<void>; deadline?: number } = {}
1236
+ ): Promise<void> {
1237
+ const deadline = options.deadline ?? performance.now() + RENAME_RETRY_BUDGET_MS;
1238
+ let delayMs = RENAME_RETRY_INITIAL_DELAY_MS;
1239
+ for (let attempts = 1; ; attempts++) {
1240
+ try {
1241
+ await rename(fromPath, toPath);
1242
+ if (attempts > 1) {
1243
+ logger.warn(`Renamed ${fromPath} to ${toPath} only on attempt ${attempts}; something was holding it`);
1244
+ }
1245
+ return;
1246
+ } catch (error) {
1247
+ const code = (error as NodeJS.ErrnoException).code ?? '';
1248
+ if (!TRANSIENT_RENAME_CODES.has(code)) throw error;
1249
+ if (performance.now() >= deadline) {
1250
+ // Which side was still there separates a holder on the source from a destination something
1251
+ // recreated, and neither survives on the rethrown error. A failed probe reports its own code:
1252
+ // an `EPERM` reading the destination is itself evidence, and calling it absent would send the
1253
+ // next investigation the wrong way.
1254
+ const state = async (path: string) =>
1255
+ lstat(path).then(
1256
+ () => 'present',
1257
+ (probeError) => (probeError as NodeJS.ErrnoException)?.code ?? 'unreadable'
1258
+ );
1259
+ logger.warn(
1260
+ `Could not rename ${fromPath} to ${toPath}: ${code} after ${attempts} attempts ` +
1261
+ `(source ${await state(fromPath)}, destination ${await state(toPath)})`
1262
+ );
1263
+ throw error;
1264
+ }
1265
+ if (options.onBackoff) await options.onBackoff(delayMs, deadline);
1266
+ else await delay(delayMs);
1267
+ delayMs = Math.min(delayMs * 2, RENAME_RETRY_MAX_DELAY_MS);
1268
+ }
1269
+ }
1270
+ }
1271
+
1076
1272
  /**
1077
1273
  * Write a control file so its final name NEVER exists with partial contents. Opening the final path with
1078
1274
  * `wx` publishes the directory entry before anything is written, so a crash in between leaves a zero-byte
@@ -1116,6 +1312,181 @@ function activationJournalPath(componentDirPath: string, deploymentId: string):
1116
1312
  return join(candidateDeploymentDirPath(componentDirPath, deploymentId), ACTIVATION_JOURNAL);
1117
1313
  }
1118
1314
 
1315
+ function candidateArtifactFilePath(componentDirPath: string, deploymentId: string): string {
1316
+ return join(candidateDeploymentDirPath(componentDirPath, deploymentId), CANDIDATE_ARTIFACT_FILE);
1317
+ }
1318
+
1319
+ export type ArtifactDescriptor = {
1320
+ v: number;
1321
+ component: string;
1322
+ /** The root-config entry to publish with the activation; `null` for a payload build, which owns none. */
1323
+ rootConfig: Record<string, unknown> | null;
1324
+ installationIsOpaque: boolean;
1325
+ isolated: boolean;
1326
+ };
1327
+
1328
+ /**
1329
+ * Read and fully validate a staged artifact's descriptor. Every field is checked, not just the ones the
1330
+ * caller happens to use: a descriptor is activation input for a build this process did not make, possibly
1331
+ * not even on this node, so a partially-checked one is a way to activate under someone else's intent.
1332
+ * Absence is `undefined`; anything present but unusable throws, because a staged artifact that cannot
1333
+ * describe itself must be refused rather than activated under defaults.
1334
+ */
1335
+ async function readArtifactDescriptor(
1336
+ deploymentDirPath: string,
1337
+ componentName: string
1338
+ ): Promise<ArtifactDescriptor | undefined> {
1339
+ const descriptorPath = join(deploymentDirPath, CANDIDATE_ARTIFACT_FILE);
1340
+ // The artifact the caller named exists and is theirs, but does not describe a build this can activate —
1341
+ // a conflict with what is on disk, not a server fault.
1342
+ const unusable = (message: string) => new ClientError(message, 409);
1343
+ const raw = await readFile(descriptorPath, 'utf8').catch((error: NodeJS.ErrnoException) => {
1344
+ if (error?.code === 'ENOENT') return undefined;
1345
+ throw error;
1346
+ });
1347
+ if (raw === undefined) return undefined;
1348
+ let parsed: any;
1349
+ try {
1350
+ parsed = JSON.parse(raw);
1351
+ } catch (error) {
1352
+ throw unusable(`Artifact descriptor ${descriptorPath} is not readable JSON: ${errorMessage(error)}`);
1353
+ }
1354
+ if (!parsed || typeof parsed !== 'object' || parsed.v !== ARTIFACT_DESCRIPTOR_VERSION) {
1355
+ throw unusable(`Artifact descriptor ${descriptorPath} is version ${parsed?.v}, which this build cannot activate`);
1356
+ }
1357
+ if (!isJoinableComponentName(parsed.component) || parsed.component !== componentName) {
1358
+ throw unusable(
1359
+ `Artifact descriptor ${descriptorPath} names component '${parsed.component}', not '${componentName}'`
1360
+ );
1361
+ }
1362
+ if (typeof parsed.installationIsOpaque !== 'boolean' || typeof parsed.isolated !== 'boolean') {
1363
+ throw unusable(`Artifact descriptor ${descriptorPath} does not record its build's runtime decisions`);
1364
+ }
1365
+ if (parsed.rootConfig !== null && (typeof parsed.rootConfig !== 'object' || Array.isArray(parsed.rootConfig))) {
1366
+ throw unusable(`Artifact descriptor ${descriptorPath} does not record a root-config entry or its absence`);
1367
+ }
1368
+ // Admission reads one field and publication writes the other, so two authorities that disagree would
1369
+ // admit one isolation and then publish the opposite.
1370
+ if (parsed.rootConfig && Boolean(parsed.rootConfig.isolated) !== parsed.isolated) {
1371
+ throw unusable(
1372
+ `Artifact descriptor ${descriptorPath} admits isolated=${parsed.isolated} but publishes ` +
1373
+ `isolated=${Boolean(parsed.rootConfig.isolated)}`
1374
+ );
1375
+ }
1376
+ return parsed as ArtifactDescriptor;
1377
+ }
1378
+
1379
+ const SEPARATORS_IN_LINK_TARGETS = process.platform === 'win32' ? /[\\/]/ : /\//;
1380
+
1381
+ // `node_modules/harper` and `node_modules/harperdb` are links the LOADER owns: it points them at the
1382
+ // running install on every non-root component load and repairs them when they are missing or stale. They
1383
+ // are outside the artifact by construction and by design, so they are the one external link a staged
1384
+ // artifact may carry.
1385
+ const LOADER_OWNED_LINKS = new Set(['harper', 'harperdb']);
1386
+
1387
+ /**
1388
+ * Reject a staged artifact that reaches outside itself.
1389
+ *
1390
+ * Certification fsyncs the tree but follows no links, and the post-swap relocation repair deliberately
1391
+ * leaves external targets alone — so a symlink into a directory this artifact does not own is a hole in
1392
+ * "activate exactly the bytes that were certified": the target can be edited, or replaced wholesale,
1393
+ * between the stage and the activation. An immediate deploy is not exposed to this, because certification
1394
+ * and activation happen within one call; the delay is what makes it reachable.
1395
+ *
1396
+ */
1397
+ async function assertOwnedArtifactTree(
1398
+ candidateDirPath: string,
1399
+ componentName: string,
1400
+ action: 'stage' | 'activate' = 'stage'
1401
+ ): Promise<void> {
1402
+ // The operator supplied a component that cannot be staged (400); or the artifact they named exists and is
1403
+ // theirs but is no longer what was certified (409). Neither is a server fault, and both reached the
1404
+ // operations handler as a bare 500 until a live run showed what that looks like to a caller.
1405
+ const refuse = (message: string) => new ClientError(message, action === 'stage' ? 400 : 409);
1406
+ const ownedRoot = await realpath(candidateDirPath);
1407
+ // The loader repairs the component's OWN `node_modules/harper`, not a copy nested inside a dependency,
1408
+ // so only that one path is exempt. Matching the name at any depth would let `dep/node_modules/harper`
1409
+ // point anywhere and still pass.
1410
+ const loaderOwnedDir = join(candidateDirPath, 'node_modules');
1411
+ const walk = async (dirPath: string): Promise<void> => {
1412
+ const entries = await readdir(dirPath, { withFileTypes: true });
1413
+ for (const entry of entries) {
1414
+ const entryPath = join(dirPath, entry.name);
1415
+ if (entry.isDirectory()) {
1416
+ await walk(entryPath);
1417
+ continue;
1418
+ }
1419
+ // Junctions report as symbolic links here, which is what makes this cover Windows.
1420
+ if (!entry.isSymbolicLink()) continue;
1421
+ if (LOADER_OWNED_LINKS.has(entry.name) && dirPath === loaderOwnedDir) continue;
1422
+ // An unresolvable link is rejected for the same reason a foreign one is: nothing certified what
1423
+ // it will resolve to by the time somebody activates it.
1424
+ const target = await realpath(entryPath).catch(() => undefined);
1425
+ if (target === undefined || (target !== ownedRoot && !target.startsWith(ownedRoot + sep))) {
1426
+ throw refuse(
1427
+ `Cannot ${action} ${componentName}: ${entryPath} links outside the build to ${target ?? 'a missing target'}, ` +
1428
+ `so the bytes activated later would not be the bytes this build certified`
1429
+ );
1430
+ }
1431
+ // `repairRelocatedDependencyLinks` re-points links after the swap, but it runs PAST THE COMMIT
1432
+ // POINT and can only warn — it logs and continues on a failed re-point, and skips a whole subtree
1433
+ // on EACCES/EMFILE — so a component could go live holding a link to a path that no longer exists
1434
+ // while the operation reports success. Staging fails closed instead. The cost: npm writes absolute
1435
+ // junctions under `node_modules` on Windows for a `file:`/workspace dependency, so such a component
1436
+ // deploys immediately but cannot be staged until its links are relative.
1437
+ const linkTarget = await readlink(entryPath);
1438
+ if (isAbsolute(linkTarget)) {
1439
+ throw refuse(
1440
+ `Cannot ${action} ${componentName}: ${entryPath} names its target inside the build by absolute path ` +
1441
+ `(${linkTarget}), which activation moves. Re-link it relatively — on Windows, npm ` +
1442
+ `writes absolute junctions for 'file:' and workspace dependencies, so those have to be relative ` +
1443
+ `before the component can be staged.`
1444
+ );
1445
+ }
1446
+ // Where the link ENDS UP is not enough: a target that leaves the candidate and comes back resolves
1447
+ // inside it today and somewhere else once activation renames the tree, because the same relative
1448
+ // expression is then evaluated from `components/<component>/…`. Counting `..` segments does not
1449
+ // catch it either, since an intermediate symlink (`up -> ..`) reduces depth without spelling it.
1450
+ // So every PREFIX of the walk is resolved, with symlinks followed as the filesystem will follow
1451
+ // them, and each one has to still be inside the candidate.
1452
+ let prefix = dirname(entryPath);
1453
+ // Only Windows treats a backslash as a separator. On POSIX it is an ordinary filename character, so
1454
+ // splitting on it there turns a link to the single legal entry `..\asset` — which resolves inside
1455
+ // the candidate and keeps resolving there after relocation — into `..` plus `asset`, and refuses a
1456
+ // component that never left its own tree.
1457
+ for (const segment of linkTarget.split(SEPARATORS_IN_LINK_TARGETS)) {
1458
+ if (segment === '' || segment === '.') continue;
1459
+ prefix = await realpath(join(prefix, segment)).catch(() => join(prefix, segment));
1460
+ if (prefix !== ownedRoot && !prefix.startsWith(ownedRoot + sep)) {
1461
+ throw refuse(
1462
+ `Cannot ${action} ${componentName}: ${entryPath} reaches ${prefix} on its way to ${linkTarget}, ` +
1463
+ `leaving the build — after activation moves the tree that path resolves somewhere else`
1464
+ );
1465
+ }
1466
+ }
1467
+ }
1468
+ };
1469
+ await walk(candidateDirPath);
1470
+ }
1471
+
1472
+ /** Record the build's decisions beside the candidate. Called before `.complete`, which vouches for it. */
1473
+ async function writeArtifactDescriptor(
1474
+ componentDirPath: string,
1475
+ deploymentId: string,
1476
+ descriptor: ArtifactDescriptor
1477
+ ): Promise<void> {
1478
+ try {
1479
+ await writeControlFileDurably(
1480
+ candidateArtifactFilePath(componentDirPath, deploymentId),
1481
+ JSON.stringify(descriptor)
1482
+ );
1483
+ } catch (error) {
1484
+ // An existing descriptor belongs to this same artifact — the id is claimed exclusively, so nothing
1485
+ // else can have written one — which makes this a retry of its own stage rather than a conflict.
1486
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
1487
+ }
1488
+ }
1489
+
1119
1490
  /**
1120
1491
  * A component name safe to join onto the components root: no separator, no traversal, not dot-prefixed.
1121
1492
  * Applied to EVERY source of the name — the journal and the sidecar — because validating one and trusting
@@ -1170,8 +1541,25 @@ async function readActivationJournal(journalPath: string): Promise<ActivationJou
1170
1541
  return parsed as ActivationJournal;
1171
1542
  }
1172
1543
 
1173
- /** The deployment directory holding one candidate build: `<root>/.deploy-staging/<deploymentId>`. */
1544
+ /**
1545
+ * The deployment directory holding one candidate build: `<root>/.deploy-staging/<deploymentId>`.
1546
+ *
1547
+ * The id is asserted here rather than only at the request boundary because every caller funnels through
1548
+ * this one join, and the id is now operator-supplied (`deployment_id`) or replication-supplied
1549
+ * (`_deploymentId`). A traversal-bearing id would otherwise direct both the build and its removal outside
1550
+ * `.deploy-staging`.
1551
+ */
1174
1552
  function candidateDeploymentDirPath(componentDirPath: string, deploymentId: string): string {
1553
+ if (
1554
+ typeof deploymentId !== 'string' ||
1555
+ deploymentId.length === 0 ||
1556
+ deploymentId !== basename(deploymentId) ||
1557
+ // `basename` returns these unchanged, so the comparison above admits both.
1558
+ deploymentId === '.' ||
1559
+ deploymentId === '..'
1560
+ ) {
1561
+ throw new Error(`Deployment id '${deploymentId}' is not a single path segment`);
1562
+ }
1175
1563
  return join(dirname(componentDirPath), DEPLOY_STAGING_DIR, deploymentId);
1176
1564
  }
1177
1565
 
@@ -1259,6 +1647,109 @@ async function ensureSecureStagingDirectory(stagingDir: string): Promise<void> {
1259
1647
  }
1260
1648
  }
1261
1649
 
1650
+ /**
1651
+ * Claim a deployment directory for this build, EXCLUSIVELY. The id is the public deployment id, which an
1652
+ * operator can repeat and a redelivered replication can repeat for them, so tolerating an existing
1653
+ * directory would let a replayed stage rewrite the bytes under an existing `.complete` and descriptor —
1654
+ * and a crash mid-rebuild would leave a partial tree that still reads as certified.
1655
+ *
1656
+ * The caller holds the component's preparation lock, which is what makes the EEXIST verdicts sound: no
1657
+ * other preparation of THIS component is running, and a directory belonging to another component is not
1658
+ * this lock's to touch.
1659
+ */
1660
+ async function claimDeploymentDirectory(deploymentDirPath: string, componentName: string): Promise<void> {
1661
+ // Every refusal below is a conflict over an id that already exists, which is the caller's to resolve by
1662
+ // naming a different one — not a server fault, and not the 500 a bare Error reaches the caller as.
1663
+ const taken = (message: string) => new ClientError(message, 409);
1664
+ try {
1665
+ await mkdir(deploymentDirPath, { mode: 0o700 });
1666
+ } catch (error) {
1667
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
1668
+ const owner = await candidateComponentName(deploymentDirPath);
1669
+ if (owner !== undefined && owner !== componentName) {
1670
+ throw taken(
1671
+ `Deployment id ${basename(deploymentDirPath)} already holds a build of '${owner}'; a deployment id ` +
1672
+ `names one artifact for its lifetime`
1673
+ );
1674
+ }
1675
+ // Ownership has to be POSITIVE to reclaim, and EMPTINESS IS NOT A VERDICT. The sidecar below is
1676
+ // written as part of the claim, so a directory naming nobody is another component between its own
1677
+ // mkdir and that write, and a deploy can hold a nearly empty one for minutes while it resolves and
1678
+ // packs. Nothing on disk separates that from a claim that got no further, and the lock that
1679
+ // serializes it is not this one, so only the exclusive create may conclude the id is free: the
1680
+ // directory can also have been discarded by a failed build between the first mkdir and this read,
1681
+ // and another component can claim it in that same gap.
1682
+ if (owner === undefined) {
1683
+ await mkdir(deploymentDirPath, { mode: 0o700 }).catch((retry: NodeJS.ErrnoException) => {
1684
+ if (retry?.code !== 'EEXIST') throw retry;
1685
+ throw taken(
1686
+ `Deployment id ${basename(deploymentDirPath)} is already claimed by a build that has not named its ` +
1687
+ `component yet. If no deploy of any component is in flight, that directory is abandoned and has ` +
1688
+ `to be removed by hand; deploying again without a deployment_id mints a fresh id`
1689
+ );
1690
+ });
1691
+ } else {
1692
+ if (await presentOrAbsent(join(deploymentDirPath, CANDIDATE_COMPLETE_MARKER))) {
1693
+ throw taken(
1694
+ `Deployment id ${basename(deploymentDirPath)} already holds a completed build of '${componentName}'; ` +
1695
+ `deploy it with deployment_id, or deploy again to build a new one`
1696
+ );
1697
+ }
1698
+ // This component's own preparation lock serializes the claim and nothing certified it, so nothing
1699
+ // is lost by rebuilding over it.
1700
+ await rm(deploymentDirPath, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
1701
+ await mkdir(deploymentDirPath, { mode: 0o700 });
1702
+ }
1703
+ }
1704
+ const claimed = await lstat(deploymentDirPath);
1705
+ if (!claimed.isDirectory() || claimed.isSymbolicLink()) {
1706
+ throw new Error(`Component deploy staging path is not a directory: ${deploymentDirPath}`);
1707
+ }
1708
+ if (process.platform !== 'win32' && (claimed.mode & 0o777) !== 0o700) {
1709
+ await chmod(deploymentDirPath, 0o700).catch((error) =>
1710
+ logger.warn(
1711
+ `Could not restrict component deploy staging permissions for ${deploymentDirPath}:`,
1712
+ errorForLog(error)
1713
+ )
1714
+ );
1715
+ }
1716
+ await publishClaimOwnership(deploymentDirPath, componentName);
1717
+ }
1718
+
1719
+ /**
1720
+ * Name the component that owns a deployment directory THIS CALL created, and take the directory back if that
1721
+ * cannot be recorded.
1722
+ *
1723
+ * Ownership is published as part of the claim rather than at certification, because resolving and packing can
1724
+ * take minutes and until a tree exists to infer an owner from, a directory answering to nobody is one another
1725
+ * component will take for abandoned. Unattributed is a permanent refusal, though, so a failure here — a full
1726
+ * disk, an EIO on the temp write or its sync — would burn this deployment id for good, every retry refused by
1727
+ * its own wreckage. The removal is scoped to the directory this invocation made, and deliberately not
1728
+ * broadened to one found by EEXIST: that directory may be another claimant's, and removing it is the race the
1729
+ * refusal exists to prevent. Best-effort, because the claim failure is what the caller needs to see.
1730
+ *
1731
+ * `write` is a parameter so the failure is testable without a filesystem that can be made to fail on exactly
1732
+ * this write and nothing else.
1733
+ */
1734
+ export async function publishClaimOwnership(
1735
+ deploymentDirPath: string,
1736
+ componentName: string,
1737
+ write: (filePath: string, contents: string) => Promise<void> = writeControlFileDurably
1738
+ ): Promise<void> {
1739
+ try {
1740
+ await write(join(deploymentDirPath, CANDIDATE_COMPONENT_FILE), componentName);
1741
+ } catch (error) {
1742
+ await rm(deploymentDirPath, { recursive: true, force: true }).catch((cleanupError) =>
1743
+ logger.warn(
1744
+ `Could not remove the deployment directory ${deploymentDirPath} after failing to publish its ` +
1745
+ `ownership; the id stays unusable until it is removed:`,
1746
+ errorForLog(cleanupError)
1747
+ )
1748
+ );
1749
+ throw error;
1750
+ }
1751
+ }
1752
+
1262
1753
  async function ensureExtractionStagingDirectory(asideStagingDir: string): Promise<void> {
1263
1754
  for (const stagingDir of [dirname(asideStagingDir), asideStagingDir]) {
1264
1755
  await ensureSecureStagingDirectory(stagingDir);
@@ -1415,7 +1906,8 @@ async function inProgressAsideRecords(asideStagingDir: string): Promise<string[]
1415
1906
  }
1416
1907
 
1417
1908
  /**
1418
- * Settle journaled activations for ONE component, assuming the caller already holds its preparation lock.
1909
+ * Settle journaled activations for ONE component, and bound its dormant staged builds, assuming the caller
1910
+ * already holds its preparation lock.
1419
1911
  *
1420
1912
  * Exists because the journal-first rule has to hold at every entry point, not just startup. A deploy runs
1421
1913
  * `recoverOrCleanupStaleExtractionPaths` first. After an activation whose retirement failed, the aside
@@ -1423,9 +1915,10 @@ async function inProgressAsideRecords(asideStagingDir: string): Promise<string[]
1423
1915
  * pass refuses to restore against a surviving journal, but refusing is a stalled component; settling first
1424
1916
  * is what lets the deploy proceed.
1425
1917
  */
1426
- async function settleJournaledActivationsForComponent(
1918
+ async function settleStagingForComponent(
1427
1919
  componentsRootDirPath: string,
1428
- componentName: string
1920
+ componentName: string,
1921
+ pinnedDeploymentId?: string
1429
1922
  ): Promise<void> {
1430
1923
  const stagingRoot = join(componentsRootDirPath, DEPLOY_STAGING_DIR);
1431
1924
  let deployments;
@@ -1435,6 +1928,7 @@ async function settleJournaledActivationsForComponent(
1435
1928
  if ((error as NodeJS.ErrnoException).code === 'ENOENT') return;
1436
1929
  throw error;
1437
1930
  }
1931
+ const dormant: DormantBuild[] = [];
1438
1932
  for (const deployment of deployments) {
1439
1933
  if (!deployment.isDirectory()) continue;
1440
1934
  const deploymentDirPath = join(stagingRoot, deployment.name);
@@ -1464,13 +1958,27 @@ async function settleJournaledActivationsForComponent(
1464
1958
  // A journal-less directory is what a SUCCESSFUL settlement leaves when its best-effort sweep fails, so
1465
1959
  // this must not fail closed. An unattributable *activation* still does: `readActivationJournal` throws
1466
1960
  // on a journal that exists but cannot be read.
1467
- if (!journal) continue;
1961
+ if (!journal) {
1962
+ if (ownerUnreadable) continue;
1963
+ try {
1964
+ const build = await dormantBuildAt(deploymentDirPath, componentName);
1965
+ if (build) dormant.push(build);
1966
+ } catch (error) {
1967
+ logger.warn(
1968
+ `Leaving staged ${componentName} build ${deploymentDirPath} out of retention; it could not be read:`,
1969
+ errorForLog(error)
1970
+ );
1971
+ }
1972
+ continue;
1973
+ }
1468
1974
  // The journal decides, not the sidecar. Skipping on an unreadable sidecar alone would leave this
1469
1975
  // component's own unsettled activation in place while a new deploy proceeded over it, and an
1470
1976
  // activation interrupted before B1 has no rollback record for the restore gate to catch.
1471
1977
  if (journal.component !== componentName) continue;
1472
1978
  await settleInterruptedActivation(componentsRootDirPath, deploymentDirPath, journal);
1473
1979
  }
1980
+ const maxCount = getStagingRetentionMaxCount();
1981
+ if (dormant.length > maxCount) await pruneDormantBuilds(componentName, dormant, maxCount, pinnedDeploymentId);
1474
1982
  }
1475
1983
 
1476
1984
  /**
@@ -1582,30 +2090,18 @@ export async function recoverInterruptedActivations(componentsRootDirPath: strin
1582
2090
  if ((error as NodeJS.ErrnoException).code === 'ENOENT') return failures;
1583
2091
  throw error;
1584
2092
  }
2093
+ const dormant = new Map<string, DormantBuild[]>();
2094
+ const catalogue = (owner: string, build: DormantBuild) => {
2095
+ const builds = dormant.get(owner);
2096
+ if (builds) builds.push(build);
2097
+ else dormant.set(owner, [build]);
2098
+ };
1585
2099
 
1586
2100
  for (const deployment of deployments) {
1587
2101
  if (!deployment.isDirectory()) continue;
1588
2102
  const deploymentDirPath = join(stagingRoot, deployment.name);
1589
2103
  const journalPath = join(deploymentDirPath, ACTIVATION_JOURNAL);
1590
- const fail = async (component: string, error: unknown) => {
1591
- const failure = error instanceof Error ? error : new Error(String(error));
1592
- if (!failures.has(component)) failures.set(component, failure);
1593
- logger.error(`Could not settle the interrupted activation of ${component}:`, errorForLog(failure));
1594
- // A DEFERRAL is not a verdict, and only verdicts go on disk. A held lock means a live deploy, which
1595
- // settles its own journal; a marker written here would outlive that deploy and have
1596
- // `unsettleableComponentsFromDisk` read it as an authoritative "cannot be settled", failing a
1597
- // healthy component closed on every worker. The failure is already recorded above, so this thread
1598
- // still defers — it just leaves nothing behind.
1599
- if (failure instanceof ComponentPreparationLockTimeoutError) return;
1600
- // Everything else IS a verdict, recorded so workers reach the same one. An unreadable journal is
1601
- // self-evident, but a well-formed journal this pass could not settle looks exactly like a deploy
1602
- // in flight, and a worker would otherwise load the component over state nobody reconciled.
1603
- // Best-effort: the alternative to a missing marker is today's behavior, not a worse one.
1604
- await writeFile(join(deploymentDirPath, UNSETTLED_MARKER), failure.message, { mode: 0o600 }).catch(
1605
- (markerError) =>
1606
- logger.warn(`Could not record the unsettled activation of ${component}: ${errorMessage(markerError)}`)
1607
- );
1608
- };
2104
+ const fail = (component: string, error: unknown) => recordUnsettled(failures, component, error, deploymentDirPath);
1609
2105
 
1610
2106
  let journal: ActivationJournal | undefined;
1611
2107
  try {
@@ -1665,6 +2161,44 @@ export async function recoverInterruptedActivations(componentsRootDirPath: strin
1665
2161
  activationToFail = undefined;
1666
2162
  return;
1667
2163
  }
2164
+ // Re-classified UNDER the lock: the unlocked read that routed this here can predate the `.complete`
2165
+ // a deploy wrote before dying, and that is a retainable build, not residue.
2166
+ const build = await dormantBuildAt(deploymentDirPath, owner!);
2167
+ if (build) {
2168
+ catalogue(owner!, build);
2169
+ return;
2170
+ }
2171
+ // A DESCRIBED artifact carrying a stale verdict is settled, not residue. `fail()` only ever writes
2172
+ // `.unsettled` beside a journal it keeps, so a marker with no journal says settlement finished
2173
+ // and only the marker's own removal was lost — which is exactly what a crash between a dormant
2174
+ // return's two unlinks leaves, and the barrier that orders them cannot run on Windows. Deleting
2175
+ // here would destroy a build somebody staged deliberately, whose payload may already have been
2176
+ // reclaimed, on the strength of a verdict that no longer applies. Clearing the marker is the
2177
+ // idempotent completion of the settlement that wrote it; an undescribed build stays disposable.
2178
+ if (await presentOrAbsent(join(deploymentDirPath, CANDIDATE_ARTIFACT_FILE))) {
2179
+ // A fault clearing the marker is not a licence to delete what it is attached to — and not a
2180
+ // licence to say nothing either. The marker survives, every worker fails the component closed
2181
+ // on it, and main reporting success is the split where main serves what every worker refuses.
2182
+ // Failing the component brings main to the workers' verdict instead, the rule the settled tail
2183
+ // follows, and leaves the artifact for the next pass to clear.
2184
+ try {
2185
+ await clearUnsettledVerdict(deploymentDirPath, owner!);
2186
+ } catch (error) {
2187
+ activationToFail = owner;
2188
+ throw error;
2189
+ }
2190
+ const settled = await dormantBuildAt(deploymentDirPath, owner!);
2191
+ if (settled) {
2192
+ logger.info?.(
2193
+ `Cleared a stale unsettled verdict from the staged build ${basename(deploymentDirPath)} of ` +
2194
+ `${owner}; its activation was already settled`
2195
+ );
2196
+ catalogue(owner!, settled);
2197
+ return;
2198
+ }
2199
+ // Cleared, and still not a retainable build — an incomplete or treeless staged directory. That
2200
+ // is residue like any other, so it falls through to the removal below.
2201
+ }
1668
2202
  // Cleanup, not settlement. There was no activation here — this is most often the residue a
1669
2203
  // SUCCESSFUL settlement leaves when its own sweep failed — so a sweep that fails again cannot
1670
2204
  // make anything unsettled, and recording it would refuse a live component on every worker
@@ -1689,6 +2223,24 @@ export async function recoverInterruptedActivations(componentsRootDirPath: strin
1689
2223
  );
1690
2224
  continue;
1691
2225
  }
2226
+ // Catalogued WITHOUT the lock and left alone: a retained build is never removed here, so a per-directory
2227
+ // lock would recur on every pass and contend with sibling threads for a component nothing is deploying.
2228
+ if (owner) {
2229
+ let build: DormantBuild | undefined;
2230
+ try {
2231
+ build = await dormantBuildAt(deploymentDirPath, owner);
2232
+ } catch (error) {
2233
+ logger.warn(
2234
+ `Leaving deploy staging ${deploymentDirPath} in place; it could not be read:`,
2235
+ errorForLog(error)
2236
+ );
2237
+ continue;
2238
+ }
2239
+ if (build) {
2240
+ catalogue(owner, build);
2241
+ continue;
2242
+ }
2243
+ }
1692
2244
  // Scoped to THIS deployment, like the journaled branch below: a lock timeout or an EIO here used to
1693
2245
  // abort the entire scan, leaving every later deployment unsettled and unmarked.
1694
2246
  try {
@@ -1755,6 +2307,120 @@ export async function recoverInterruptedActivations(componentsRootDirPath: strin
1755
2307
  await fail(journal.component, error);
1756
2308
  }
1757
2309
  }
2310
+
2311
+ const maxCount = getStagingRetentionMaxCount();
2312
+ for (const [owner, builds] of dormant) {
2313
+ for (const [component, error] of await reconcileDormantBuilds(componentsRootDirPath, owner, builds, maxCount)) {
2314
+ if (!failures.has(component)) failures.set(component, error);
2315
+ }
2316
+ }
2317
+ return failures;
2318
+ }
2319
+
2320
+ /**
2321
+ * Record a settlement failure against a component. A lock TIMEOUT is a deferral, not a verdict, and only
2322
+ * verdicts go on disk: a held lock means a live deploy, which settles its own journal, and a marker written
2323
+ * here would outlive it and have `unsettleableComponentsFromDisk` fail a healthy component closed on every
2324
+ * worker. Everything else is written so workers reach the same verdict — a well-formed journal this pass
2325
+ * could not settle looks exactly like a deploy in flight otherwise. Marker write is best-effort.
2326
+ */
2327
+ async function recordUnsettled(
2328
+ failures: Map<string, Error>,
2329
+ component: string,
2330
+ error: unknown,
2331
+ deploymentDirPath: string
2332
+ ): Promise<void> {
2333
+ const failure = error instanceof Error ? error : new Error(String(error));
2334
+ if (!failures.has(component)) failures.set(component, failure);
2335
+ logger.error(`Could not settle the interrupted activation of ${component}:`, errorForLog(failure));
2336
+ if (failure instanceof ComponentPreparationLockTimeoutError) return;
2337
+ await writeFile(join(deploymentDirPath, UNSETTLED_MARKER), failure.message, { mode: 0o600 }).catch((markerError) =>
2338
+ logger.warn(`Could not record the unsettled activation of ${component}: ${errorMessage(markerError)}`)
2339
+ );
2340
+ }
2341
+
2342
+ /**
2343
+ * Finish one owner's catalogued dormant builds after the scan. The catalog was read without the lock, so a
2344
+ * deploy may have published a journal into one of these directories since — and if it then died mid-swap,
2345
+ * only settlement brings the component back. So: settle any journal that appeared, then bound what is still
2346
+ * dormant. The lock is taken only when there is something to do; a lock a live deploy holds is the same
2347
+ * deferral the residue branch records.
2348
+ */
2349
+ export async function reconcileDormantBuilds(
2350
+ componentsRootDirPath: string,
2351
+ owner: string,
2352
+ builds: DormantBuild[],
2353
+ maxCount: number
2354
+ ): Promise<Map<string, Error>> {
2355
+ const failures = new Map<string, Error>();
2356
+ let journaled: DormantBuild | undefined;
2357
+ for (const build of builds) {
2358
+ // Anything but a clean ENOENT means "read it properly, under the lock".
2359
+ const appeared = await presentOrAbsent(join(build.deploymentDirPath, ACTIVATION_JOURNAL)).then(
2360
+ (stats) => stats !== undefined,
2361
+ () => true
2362
+ );
2363
+ if (appeared) {
2364
+ journaled = build;
2365
+ break;
2366
+ }
2367
+ }
2368
+ if (!journaled && builds.length <= maxCount) return failures;
2369
+ try {
2370
+ await withComponentPreparationLock(
2371
+ join(componentsRootDirPath, owner),
2372
+ async () => {
2373
+ const stillDormant: DormantBuild[] = [];
2374
+ for (const build of builds) {
2375
+ let journal: ActivationJournal | undefined;
2376
+ try {
2377
+ journal = await readActivationJournal(join(build.deploymentDirPath, ACTIVATION_JOURNAL));
2378
+ } catch (error) {
2379
+ await recordUnsettled(failures, owner, error, build.deploymentDirPath);
2380
+ continue;
2381
+ }
2382
+ if (!journal) {
2383
+ stillDormant.push(build);
2384
+ continue;
2385
+ }
2386
+ // The lock held here is the SIDECAR owner's, as in the residue branch: a journal naming someone
2387
+ // else is not settled under it, and both names are failed.
2388
+ const splitNames = splitAttributionOwners(journal.component, owner);
2389
+ if (splitNames) {
2390
+ const split = splitAttributionError(build.deploymentDirPath, journal.component, splitNames[0]);
2391
+ for (const name of splitNames) await recordUnsettled(failures, name, split, build.deploymentDirPath);
2392
+ continue;
2393
+ }
2394
+ try {
2395
+ await settleInterruptedActivation(componentsRootDirPath, build.deploymentDirPath, journal);
2396
+ } catch (error) {
2397
+ await recordUnsettled(failures, journal.component, error, build.deploymentDirPath);
2398
+ }
2399
+ }
2400
+ if (stillDormant.length > maxCount) await pruneDormantBuilds(owner, stillDormant, maxCount);
2401
+ },
2402
+ {
2403
+ purpose: 'activation-recovery',
2404
+ ...RECOVERY_LOCK_WAIT,
2405
+ isOwnerAlive: (lockOwner) => lockOwner.pid !== process.pid || isThreadRunning(lockOwner.threadId),
2406
+ }
2407
+ );
2408
+ } catch (error) {
2409
+ // With a journal in view this is an activation that could not be settled — recorded exactly as the
2410
+ // scan records one it saw directly (a timeout defers, anything else is a verdict). Without one it is
2411
+ // hygiene that could not run, unless a live deploy holds the lock, which defers as everywhere else.
2412
+ if (journaled) {
2413
+ await recordUnsettled(failures, owner, error, journaled.deploymentDirPath);
2414
+ return failures;
2415
+ }
2416
+ const failure = error instanceof Error ? error : new Error(String(error));
2417
+ if (failure instanceof ComponentPreparationLockTimeoutError) {
2418
+ if (!failures.has(owner)) failures.set(owner, failure);
2419
+ logger.info?.(`Deferred pruning the dormant staged builds of ${owner}: a deploy holds its lock`);
2420
+ } else {
2421
+ logger.warn(`Could not prune the dormant staged builds of ${owner}:`, errorForLog(failure));
2422
+ }
2423
+ }
1758
2424
  return failures;
1759
2425
  }
1760
2426
 
@@ -1786,6 +2452,28 @@ async function sweepAsideRecords(
1786
2452
  }
1787
2453
  }
1788
2454
 
2455
+ /**
2456
+ * Clear an earlier failed recovery's verdict once this settlement has decided. Treated as CORRECTNESS, not
2457
+ * cleanup: main would report the component settled and load it while every worker read the stale marker and
2458
+ * failed it closed, so a failure here throws and lets main reach the same verdict. The journal outlives it
2459
+ * either way, so the next start settles again.
2460
+ */
2461
+ async function clearUnsettledVerdict(deploymentDirPath: string, componentName: string): Promise<void> {
2462
+ try {
2463
+ await rm(join(deploymentDirPath, UNSETTLED_MARKER), { force: true });
2464
+ // Flushed here, not with whatever follows: the journal's removal must never be the one that survives
2465
+ // a crash alone, or the verdict outlives the only thing that would settle it again.
2466
+ await syncDirectory(deploymentDirPath);
2467
+ } catch (error) {
2468
+ throw new Error(
2469
+ `Could not clear the stale unsettled marker of ${componentName} at ` +
2470
+ `${join(deploymentDirPath, UNSETTLED_MARKER)}; the component stays failed closed on every thread ` +
2471
+ `until that file can be removed: ${errorMessage(error)}`,
2472
+ { cause: error }
2473
+ );
2474
+ }
2475
+ }
2476
+
1789
2477
  /**
1790
2478
  * One interrupted activation, under the component preparation lock. Ambiguity exists only while the live
1791
2479
  * path is absent, and there the `complete` marker is the roll-forward authority: without it the candidate
@@ -1816,7 +2504,7 @@ async function settleInterruptedActivation(
1816
2504
  const asideRecords = await inProgressAsideRecords(asideStagingDir);
1817
2505
 
1818
2506
  const rollForward = async () => {
1819
- if (!liveExists) await rename(candidateDirPath, liveDirPath);
2507
+ if (!liveExists) await renameThroughTransientHolder(candidateDirPath, liveDirPath);
1820
2508
  // Unconditional, not only when THIS pass performed the rename: a crash after normal activation
1821
2509
  // renamed the candidate but before it repaired the links leaves live present with stale targets, and
1822
2510
  // gating the repair on the rename would skip exactly that case. Idempotent when there is nothing to
@@ -1831,7 +2519,7 @@ async function settleInterruptedActivation(
1831
2519
  };
1832
2520
  const rollBack = async (restoreFrom?: string) => {
1833
2521
  if (restoreFrom) {
1834
- await rename(restoreFrom, liveDirPath);
2522
+ await renameThroughTransientHolder(restoreFrom, liveDirPath);
1835
2523
  await syncRenameParents(restoreFrom, liveDirPath);
1836
2524
  }
1837
2525
  for (const record of asideRecords) {
@@ -1886,6 +2574,38 @@ async function settleInterruptedActivation(
1886
2574
  `whichever of the two is not the release you want once you have determined which that is.`
1887
2575
  );
1888
2576
  }
2577
+ // A STAGED artifact is not a disposable build. `rollBack()` removes the whole deployment directory,
2578
+ // which is right for an immediate deploy — the candidate came from a payload the operator still has —
2579
+ // but wrong for one somebody staged deliberately and may have had its payload reclaimed. The
2580
+ // descriptor is what tells the two apart, and it is on disk precisely so recovery can. Returning the
2581
+ // artifact to dormant by removing only the journal leaves it exactly as `deployment_id` expects it.
2582
+ if (await presentOrAbsent(join(deploymentDirPath, CANDIDATE_ARTIFACT_FILE))) {
2583
+ // This branch returns early and so reaches none of the settled tail below — which can be careless
2584
+ // about both, because it removes the whole directory afterwards. This one keeps it, so the order
2585
+ // the two unlinks REACH STORAGE decides whether the artifact survives: `.unsettled` with no
2586
+ // journal is a verdict nothing will ever settle and the next retention pass deletes the build on.
2587
+ // Its flush is the barrier between the two unlinks: skipping it would let the journal's removal
2588
+ // persist alone, leaving a verdict nothing will settle again. Throwing is safe here and leaves the
2589
+ // journal, so the next start settles again. Windows cannot fsync a directory, which is why the
2590
+ // residue pass also refuses to read this state as disposable — see DESIGN.md.
2591
+ await clearUnsettledVerdict(deploymentDirPath, journal.component);
2592
+ await rm(journalPath, { force: true });
2593
+ // Nothing may throw past the journal removal, the rule the settled tail follows: the caller
2594
+ // records a failure by writing `.unsettled`, which is the state this branch exists to avoid. An
2595
+ // unflushed removal is the safe direction — a power loss resurrects a journal that settles again.
2596
+ await syncDirectory(deploymentDirPath).catch((error) =>
2597
+ logger.warn(
2598
+ `Returned the staged build ${basename(deploymentDirPath)} of ${journal.component} to dormant but ` +
2599
+ `could not flush that to storage; a power loss could resurrect its activation journal:`,
2600
+ errorForLog(error)
2601
+ )
2602
+ );
2603
+ logger.info?.(
2604
+ `Returned the staged build ${basename(deploymentDirPath)} of ${journal.component} to dormant after an ` +
2605
+ `activation that never moved its live tree aside`
2606
+ );
2607
+ return;
2608
+ }
1889
2609
  await rollBack();
1890
2610
  } else {
1891
2611
  // The candidate is already live; only the tail of the transaction was lost.
@@ -1913,20 +2633,7 @@ async function settleInterruptedActivation(
1913
2633
  // An earlier failed recovery may have left an unsettled marker here. Cleared BEFORE the journal and
1914
2634
  // treated as correctness: main would report this component settled and load it, while every worker read
1915
2635
  // the stale marker and failed it closed.
1916
- try {
1917
- await rm(join(deploymentDirPath, UNSETTLED_MARKER), { force: true });
1918
- } catch (error) {
1919
- // The tree decision is applied, but the marker still says otherwise and every worker reads it and
1920
- // fails the component closed. Thrown rather than returned so MAIN reaches that same verdict instead
1921
- // of reporting the component settled — a split where main serves what every worker refuses is worse
1922
- // than both refusing. The journal survives, so the next start settles again.
1923
- throw new Error(
1924
- `Settled the interrupted activation of ${journal.component} but could not clear its unsettled ` +
1925
- `marker at ${join(deploymentDirPath, UNSETTLED_MARKER)}; the component stays failed closed on ` +
1926
- `every thread until that file can be removed: ${errorMessage(error)}`,
1927
- { cause: error }
1928
- );
1929
- }
2636
+ await clearUnsettledVerdict(deploymentDirPath, journal.component);
1930
2637
  await rm(journalPath, { force: true }).catch((error) =>
1931
2638
  logger.warn(`Settled ${journal.component} but could not remove its activation journal:`, errorForLog(error))
1932
2639
  );
@@ -2054,17 +2761,42 @@ export async function markCandidateComplete(
2054
2761
  }
2055
2762
  }
2056
2763
 
2764
+ /**
2765
+ * Make the newly created ancestors of a deployment directory durable, child-first.
2766
+ *
2767
+ * Only a staged artifact needs this. A deploy's own candidate is transient — power loss just abandons a
2768
+ * build nobody was told about — but a stage is ACKNOWLEDGED, and `writeControlFileDurably` flushes only the
2769
+ * control file's immediate parent while `ensureSecureStagingDirectory` flushes none. Without this a stage
2770
+ * can report success before the `.deploy-staging/<id>` entry exists on storage, and the automatic payload
2771
+ * reclaim may already have dropped the tarball it could have been rebuilt from. Best-effort on Windows, like
2772
+ * every other directory sync here.
2773
+ */
2774
+ async function syncArtifactAncestors(deploymentDirPath: string): Promise<void> {
2775
+ const stagingRoot = dirname(deploymentDirPath);
2776
+ for (const directory of [deploymentDirPath, stagingRoot, dirname(stagingRoot)]) {
2777
+ await syncDirectory(directory);
2778
+ }
2779
+ }
2780
+
2057
2781
  /**
2058
2782
  * Make a built and validated candidate live, as one compensating transaction over two effects: the live tree
2059
- * moves aside, then the candidate takes its place. Root config is NOT one of them — it is still published
2060
- * before the build, unchanged, and making it transactional is tracked separately (#2315).
2783
+ * moves aside, then the candidate takes its place. Root config is NOT one of them — for an immediate deploy
2784
+ * it is still published before the build, unchanged, and making it transactional is tracked separately
2785
+ * (#2315). A delayed activation hands its artifact's recorded entry in as `afterJournal`, which publishes it
2786
+ * from inside the window a crash rolls forward from — see that call site.
2061
2787
  *
2062
- * The `complete` marker and the activation journal are written and fsynced BEFORE the first rename, so a
2063
- * crash anywhere below is recoverable — see `settleInterruptedActivation` for the state matrix. The second
2064
- * rename is the COMMIT POINT: nothing after it may compensate, because the live path holds the candidate and
2065
- * renaming the aside back over it cannot succeed.
2788
+ * The candidate must ALREADY be certified: `markCandidateComplete` is the caller's, so a delayed activation
2789
+ * does not re-walk and re-fsync a whole dependency tree it certified when it was built. The activation
2790
+ * journal is still written and fsynced BEFORE the first rename, so a crash anywhere below is recoverable —
2791
+ * see `settleInterruptedActivation` for the state matrix. The second rename is the COMMIT POINT: nothing
2792
+ * after it may compensate, because the live path holds the candidate and renaming the aside back over it
2793
+ * cannot succeed.
2066
2794
  */
2067
- export async function activateCandidateApplication(application: Application, deploymentId: string): Promise<void> {
2795
+ export async function activateCandidateApplication(
2796
+ application: Application,
2797
+ deploymentId: string,
2798
+ options: { afterJournal?: () => Promise<(() => Promise<void>) | void> } = {}
2799
+ ): Promise<void> {
2068
2800
  const liveDirPath = application.dirPath;
2069
2801
  const candidateDirPath = candidateApplicationPath(liveDirPath, deploymentId);
2070
2802
  const deploymentDirPath = candidateDeploymentDirPath(liveDirPath, deploymentId);
@@ -2080,69 +2812,187 @@ export async function activateCandidateApplication(application: Application, dep
2080
2812
  throw new Error(`Cannot activate ${application.name}: no candidate build at ${candidateDirPath}`);
2081
2813
  }
2082
2814
 
2083
- await markCandidateComplete(liveDirPath, deploymentId, application.name);
2084
2815
  const journalPath = activationJournalPath(liveDirPath, deploymentId);
2085
- try {
2086
- await writeControlFileDurably(
2087
- journalPath,
2088
- JSON.stringify({
2089
- v: ACTIVATION_JOURNAL_VERSION,
2090
- component: application.name,
2091
- candidateId: deploymentId,
2092
- })
2093
- );
2094
- } catch (error) {
2095
- // An existing journal is a retry of this same activation, not a conflict.
2096
- if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
2097
- }
2098
-
2099
- await ensureExtractionStagingDirectory(asideStagingDir);
2100
- const liveExists = await lstat(liveDirPath).then(
2101
- () => true,
2102
- (error) => {
2103
- if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false;
2104
- throw error;
2105
- }
2106
- );
2107
- application.isNewComponent = !liveExists;
2108
2816
 
2109
2817
  // B1 — the live tree moves aside. It stays the rollback source until B4 retires it.
2110
2818
  let asidePath: string | undefined;
2111
2819
  let priorAbsentRecordPath: string | undefined;
2112
- if (liveExists) {
2113
- asidePath = join(asideStagingDir, `${IN_PROGRESS_ASIDE_PREFIX}${Date.now()}-${process.pid}-${randomUUID()}`);
2114
- await rename(liveDirPath, asidePath);
2115
- } else {
2116
- priorAbsentRecordPath = join(
2117
- asideStagingDir,
2118
- `${IN_PROGRESS_ASIDE_PREFIX}${Date.now()}-${process.pid}-${randomUUID()}${PRIOR_ABSENT_RECORD_SUFFIX}`
2119
- );
2120
- await writeFile(priorAbsentRecordPath, '', { flag: 'wx', mode: 0o600 });
2121
- }
2820
+ // The swap below moves the previous tree back and forth around every wait, so a chosen aside path no
2821
+ // longer implies the tree is at it — and compensation needs to know which.
2822
+ let liveIsDisplaced = false;
2122
2823
  const restoreLive = async () => {
2123
- if (asidePath) await rename(asidePath, liveDirPath);
2124
- else if (priorAbsentRecordPath) await rm(priorAbsentRecordPath, { force: true });
2125
- await syncRenameParents(asidePath ?? priorAbsentRecordPath!, liveDirPath);
2824
+ if (liveIsDisplaced) {
2825
+ await renameThroughTransientHolder(asidePath!, liveDirPath);
2826
+ liveIsDisplaced = false;
2827
+ } else if (priorAbsentRecordPath) await rm(priorAbsentRecordPath, { force: true });
2828
+ // Nothing was displaced and no record was written, so there is nothing to put back and no rename to
2829
+ // flush — the failure happened before the first effect.
2830
+ if (asidePath || priorAbsentRecordPath) {
2831
+ await syncRenameParents(asidePath ?? priorAbsentRecordPath!, liveDirPath);
2832
+ }
2126
2833
  };
2127
2834
 
2128
- // Still BEFORE the commit point, so this is compensable — and must be compensated. Letting a storage
2129
- // failure escape here leaves live already moved aside, and the caller reads an uncompensated throw as an
2130
- // ordinary build failure and discards the candidate, its `.complete` marker and its journal: the
2131
- // component ends up with no version at all and nothing saying how to get one back.
2835
+ /**
2836
+ * Put a compensated candidate back to DORMANT — complete, described, no journal — so it is a retryable
2837
+ * artifact rather than one the next preparation destroys.
2838
+ *
2839
+ * Without this, a failure that leaves the journal in place makes `settleStagingForComponent` read
2840
+ * live-plus-candidate as an activation that never got there and remove the whole deployment directory;
2841
+ * for a first deploy the restored state is live-ABSENT, and it rolls the candidate forward instead,
2842
+ * ahead of the caller's own verification. Both destroy an artifact whose whole purpose is to be
2843
+ * activated again.
2844
+ *
2845
+ * The unlink needs its own barrier: syncing the aside directories persists the rollback record's
2846
+ * disposal, not the journal's, so without this sync a power loss resurrects a journal the operator was
2847
+ * told had been rolled back. Best-effort by necessity — failing here must not replace the activation
2848
+ * failure the caller is reporting — so a failure says explicitly that the artifact is not retryable, and
2849
+ * the surviving journal is exactly what startup recovery settles.
2850
+ */
2851
+ const returnToDormant = async () => {
2852
+ try {
2853
+ await rm(journalPath, { force: true });
2854
+ } catch (error) {
2855
+ application.logger.warn(
2856
+ `Restored ${application.name} after a failed activation, but its staged build ${deploymentId} still ` +
2857
+ `carries an activation journal and is not retryable until recovery settles it:`,
2858
+ error
2859
+ );
2860
+ return;
2861
+ }
2862
+ // The journal is gone in this process, so the artifact is retryable now; what is uncertain is whether
2863
+ // its removal reached storage.
2864
+ await syncDirectory(deploymentDirPath).catch((error) =>
2865
+ application.logger.warn(
2866
+ `Restored ${application.name} and returned its staged build ${deploymentId} to a retryable state, but ` +
2867
+ `could not flush that to storage; a power loss could resurrect its activation journal:`,
2868
+ error
2869
+ )
2870
+ );
2871
+ };
2872
+
2873
+ /**
2874
+ * One pre-commit failure boundary, with the journal write inside it: every step from there to the commit
2875
+ * rename leaves a journal behind if it only rethrows, and the next settlement then deletes a certified
2876
+ * artifact, or (first deploy, live absent) activates it with nobody asking. Nothing below the commit
2877
+ * rename may enter this catch — see B2.
2878
+ */
2879
+ let pendingEffect = 'record the activation';
2880
+ let undoAfterJournal: (() => Promise<void>) | void;
2132
2881
  try {
2882
+ try {
2883
+ await writeControlFileDurably(
2884
+ journalPath,
2885
+ JSON.stringify({
2886
+ v: ACTIVATION_JOURNAL_VERSION,
2887
+ component: application.name,
2888
+ candidateId: deploymentId,
2889
+ })
2890
+ );
2891
+ } catch (error) {
2892
+ // An existing journal is a retry of this same activation, not a conflict.
2893
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
2894
+ }
2895
+
2896
+ pendingEffect = 'prepare the component staging directory';
2897
+ await ensureExtractionStagingDirectory(asideStagingDir);
2898
+ pendingEffect = 'read the live component directory';
2899
+ const liveExists = await lstat(liveDirPath).then(
2900
+ () => true,
2901
+ (error) => {
2902
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false;
2903
+ throw error;
2904
+ }
2905
+ );
2906
+ application.isNewComponent = !liveExists;
2907
+
2908
+ pendingEffect = 'move the previous version aside';
2909
+ if (liveExists) {
2910
+ asidePath = join(asideStagingDir, `${IN_PROGRESS_ASIDE_PREFIX}${Date.now()}-${process.pid}-${randomUUID()}`);
2911
+ await renameThroughTransientHolder(liveDirPath, asidePath);
2912
+ liveIsDisplaced = true;
2913
+ } else {
2914
+ priorAbsentRecordPath = join(
2915
+ asideStagingDir,
2916
+ `${IN_PROGRESS_ASIDE_PREFIX}${Date.now()}-${process.pid}-${randomUUID()}${PRIOR_ABSENT_RECORD_SUFFIX}`
2917
+ );
2918
+ await writeFile(priorAbsentRecordPath, '', { flag: 'wx', mode: 0o600 });
2919
+ }
2920
+
2921
+ // Letting a storage failure escape here leaves live already moved aside, and the caller reads an
2922
+ // uncompensated throw as an ordinary build failure and discards the candidate, its `.complete` marker
2923
+ // and its journal: the component ends up with no version at all and nothing saying how to get one back.
2924
+ pendingEffect = 'record the displaced component directory';
2133
2925
  await syncRenameParents(liveDirPath, asidePath ?? priorAbsentRecordPath!);
2134
- } catch (error) {
2135
- await compensate(error, 'record the displaced component directory', restoreLive, application);
2136
- throw error;
2137
- }
2138
2926
 
2139
- // B2 — the candidate becomes live. THE RENAME IS THE COMMIT POINT: nothing after it may compensate,
2140
- // because the live path now holds the candidate and renaming the aside back over it cannot succeed. A
2141
- // compensating step there fails its own rollback and reports a failure for a deploy that is live.
2142
- try {
2143
- await rename(candidateDirPath, liveDirPath);
2927
+ // Config is published HERE — after B1, before the commit — because this is the only point where the
2928
+ // on-disk state recovery would find rolls FORWARD to the certified artifact: live is displaced, the
2929
+ // candidate is complete, and the rollback record exists. Publishing before B1 (with or without the
2930
+ // journal) leaves live present and the candidate present with no rollback record, which settlement
2931
+ // reads as an activation that never started: it deletes the deployment directory, and the next boot
2932
+ // re-resolves the published package identifier from the registry instead — the substitution this step
2933
+ // exists to prevent.
2934
+ //
2935
+ // A crash in the remaining window — after the roll-forward state exists but before this publish — is
2936
+ // the inverse hazard: `rollForward()` renames the candidate live and publishes nothing, so the
2937
+ // certified artifact serves under the PREVIOUS release's config. That includes its ISOLATION intent,
2938
+ // which is a containment boundary and not just a version string: a component staged to run isolated
2939
+ // comes back non-isolated after an ordinary crash, with nothing in the operation reporting it.
2940
+ // Closing it needs config to be an effect of the journal itself, which is #2315 step 3.
2941
+ pendingEffect = 'publish the root configuration';
2942
+ undoAfterJournal = await options.afterJournal?.();
2943
+
2944
+ // B2 — the candidate becomes live. THE RENAME IS THE COMMIT POINT: nothing after it may compensate,
2945
+ // because the live path now holds the candidate and renaming the aside back over it cannot succeed. A
2946
+ // compensating step there fails its own rollback and reports a failure for a deploy that is live. It is
2947
+ // the LAST statement in this block for that reason.
2948
+ pendingEffect = 'move the candidate into place';
2949
+ await renameThroughTransientHolder(candidateDirPath, liveDirPath, {
2950
+ // The previous version occupies the live path through the wait rather than the component being
2951
+ // absent for the whole budget: a read of a component file, and any concurrent scan of the
2952
+ // components root, still finds the last committed tree. (Watchers are already paused for the
2953
+ // deploy, so they are not what this protects.) A first-ever deploy has nothing to put back.
2954
+ onBackoff: async (delayMs, deadline) => {
2955
+ if (!liveIsDisplaced) return delay(delayMs);
2956
+ await renameThroughTransientHolder(asidePath!, liveDirPath, { deadline });
2957
+ liveIsDisplaced = false;
2958
+ await syncRenameParents(asidePath!, liveDirPath);
2959
+ await delay(delayMs);
2960
+ await renameThroughTransientHolder(liveDirPath, asidePath!, { deadline });
2961
+ liveIsDisplaced = true;
2962
+ await syncRenameParents(liveDirPath, asidePath!);
2963
+ },
2964
+ });
2144
2965
  } catch (error) {
2145
- await compensate(error, 'move the candidate into place', restoreLive, application);
2966
+ // Whether the journal can still carry this activation forward, decided BEFORE compensation removes the
2967
+ // evidence it is read from. Only a first-ever deploy qualifies: `restoreLive` leaves the live path
2968
+ // absent, and recovery reads absent-plus-complete-candidate as a roll forward. A component that
2969
+ // already had a tree gets that tree back and loses its rollback record with it, so the next settle
2970
+ // reads live-plus-candidate-with-no-record and returns the artifact to dormant whatever the journal
2971
+ // says — keeping it there defers the same verdict to the next start and strands config until then.
2972
+ const recoveryCanRollForward = priorAbsentRecordPath !== undefined;
2973
+ await compensate(error, pendingEffect, restoreLive, application);
2974
+ let configRestored = true;
2975
+ if (undoAfterJournal) {
2976
+ configRestored = await undoAfterJournal().then(
2977
+ () => true,
2978
+ (undoError) => {
2979
+ application.logger.warn(
2980
+ `Restored ${application.name} after a failed activation but could not restore its root config, ` +
2981
+ `which still names deployment ${deploymentId}` +
2982
+ (recoveryCanRollForward
2983
+ ? '; keeping its activation journal so recovery can roll the certified build forward instead:'
2984
+ : '. The certified build stays dormant and the previous release stays live, so the two ' +
2985
+ 'disagree until an operator republishes the component or activates it again:'),
2986
+ undoError
2987
+ );
2988
+ return false;
2989
+ }
2990
+ );
2991
+ }
2992
+ // Kept only where it changes the outcome. Config is stranded either way for a component that already
2993
+ // had a tree — the durable-config window #2315 step 3 closes — and a journal that recovery will only
2994
+ // settle back to dormant buys nothing for holding it.
2995
+ if (configRestored || !recoveryCanRollForward) await returnToDormant();
2146
2996
  throw error;
2147
2997
  }
2148
2998
 
@@ -2344,15 +3194,26 @@ async function discardCandidate(application: Application, deploymentId: string):
2344
3194
  * Failure needs no compensation, which is the whole point: nothing about the live component was modified,
2345
3195
  * so the abandoned candidate is simply removed and the error propagates.
2346
3196
  */
2347
- export async function buildCandidateApplication(application: Application, deploymentId: string): Promise<string> {
3197
+ export async function buildCandidateApplication(
3198
+ application: Application,
3199
+ deploymentId: string,
3200
+ options: { rejectLinkSource?: boolean } = {}
3201
+ ): Promise<string> {
2348
3202
  const deploymentDirPath = candidateDeploymentDirPath(application.dirPath, deploymentId);
2349
3203
  const candidateDirPath = candidateApplicationPath(application.dirPath, deploymentId);
2350
3204
  await ensureSecureStagingDirectory(dirname(deploymentDirPath));
2351
- await ensureSecureStagingDirectory(deploymentDirPath);
3205
+ await claimDeploymentDirectory(deploymentDirPath, application.name);
2352
3206
  try {
2353
3207
  // Replaced, not extracted into: a prior attempt on this id may have left a partial tree.
2354
3208
  await rm(candidateDirPath, { recursive: true, force: true });
2355
3209
  const resolved = await resolveApplicationTarball(application);
3210
+ if (resolved.kind === 'link' && options.rejectLinkSource) {
3211
+ // What the operator asked for, not a server fault: the same component deploys immediately.
3212
+ throw new ClientError(
3213
+ `Cannot stage ${application.name} from ${application.packageIdentifier}: a 'file:' directory is linked ` +
3214
+ `rather than copied, so the bytes activated later are not the bytes this build certified`
3215
+ );
3216
+ }
2356
3217
  if (resolved.kind === 'link') {
2357
3218
  // A `file:` directory becomes a symlink AT THE CANDIDATE PATH, so it is validated and swapped in
2358
3219
  // like any other candidate instead of appearing at the live path unvalidated.
@@ -2593,6 +3454,15 @@ export async function dropComponentDirectory(
2593
3454
  asideStagingDir,
2594
3455
  new Set([droppedPath])
2595
3456
  );
3457
+ // A dropped component has no next deploy to bound its dormant builds.
3458
+ try {
3459
+ await pruneDormantBuilds(componentName, await dormantBuildsOf(dirname(componentDirPath), componentName), 0);
3460
+ } catch (error) {
3461
+ componentLogger.warn(
3462
+ `Dropped ${componentName} but could not reclaim its dormant staged builds:`,
3463
+ errorForLog(error)
3464
+ );
3465
+ }
2596
3466
  }
2597
3467
 
2598
3468
  async function cleanupExtractionPaths(
@@ -2986,8 +3856,15 @@ export async function installApplication(application: Application, buildDirPath
2986
3856
  // If node_modules doesn't exist, we need to install dependencies
2987
3857
  }
2988
3858
 
3859
+ const allowInstallScripts = !!application.install?.allowInstallScripts;
3860
+
2989
3861
  // If custom install command is specified, run it
2990
3862
  if (application.install?.command) {
3863
+ if (application.install.allowInstallScripts === undefined) {
3864
+ application.logger.warn(
3865
+ `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`
3866
+ );
3867
+ }
2991
3868
  const [command, ...args] = application.install.command.split(' ');
2992
3869
  const customOnLine = application.onInstallLine
2993
3870
  ? (stream: 'stdout' | 'stderr', line: string) => application.onInstallLine!(command, stream, line)
@@ -2999,7 +3876,9 @@ export async function installApplication(application: Application, buildDirPath
2999
3876
  buildDirPath,
3000
3877
  application.install?.timeout,
3001
3878
  customOnLine,
3002
- application.npmUserconfigPath
3879
+ application.npmUserconfigPath,
3880
+ undefined,
3881
+ !allowInstallScripts
3003
3882
  );
3004
3883
  // if it succeeds, return
3005
3884
  if (code === 0) {
@@ -3019,7 +3898,6 @@ export async function installApplication(application: Application, buildDirPath
3019
3898
  );
3020
3899
  }
3021
3900
 
3022
- const allowInstallScripts = !!application.install?.allowInstallScripts;
3023
3901
  const { packageManager } = packageJSON.devEngines || {};
3024
3902
  if (dependencyFieldHasWork(packageJSON, 'devDependencies')) {
3025
3903
  application.logger.warn(
@@ -3359,6 +4237,7 @@ export function shouldPackLocalDirectory(packageIdentifier: string | undefined,
3359
4237
  * @returns A promise that resolves when all preparation steps complete.
3360
4238
  */
3361
4239
  export type PrepareApplicationOptions = {
4240
+ beforePrepare?: () => Promise<void>;
3362
4241
  /**
3363
4242
  * Runs against the built candidate while the live version is still serving, and BEFORE the swap. A
3364
4243
  * throw here means the candidate never goes live — which is the whole difference from the previous
@@ -3366,15 +4245,46 @@ export type PrepareApplicationOptions = {
3366
4245
  * broken release.
3367
4246
  */
3368
4247
  validateCandidate?: (candidateDirPath: string) => Promise<void>;
4248
+ /**
4249
+ * Names `.deploy-staging/<artifactId>`, and is not the deploy-lifecycle token: that token de-duplicates
4250
+ * overlapping deploys of one component and is released by the first matching end, so two activations of
4251
+ * one artifact sharing it would resume watchers while the second is still swapping. Defaults to the
4252
+ * lifecycle token, which is unique per invocation.
4253
+ */
4254
+ artifactId?: string;
4255
+ /**
4256
+ * `deploy` builds, certifies and activates. `stage` builds and certifies, then stops, leaving an
4257
+ * artifact a later `activate` can address by `artifactId`. `activate` verifies an existing artifact
4258
+ * and swaps it in, doing no resolution, no install and no network work at all.
4259
+ */
4260
+ mode?: 'deploy' | 'stage' | 'activate';
4261
+ /**
4262
+ * `stage` only: the build's declared intent, recorded with the artifact for whoever activates it.
4263
+ * A callback rather than a value because `beforePrepare` is what determines it, and that runs after
4264
+ * these options have been constructed.
4265
+ */
4266
+ describeArtifact?: () => { rootConfig: Record<string, unknown> | null; isolated: boolean };
4267
+ /**
4268
+ * `activate` only: publish the artifact's recorded root-config entry, immediately before the swap. May
4269
+ * return an undo, run if the activation then fails before it commits — otherwise a failed activation
4270
+ * leaves config naming a release that is not live, which the next boot install would resolve and build
4271
+ * from scratch over a component whose certified artifact is sitting beside it.
4272
+ */
4273
+ publishRootConfig?: (entry: Record<string, unknown>) => Promise<(() => Promise<void>) | void>;
4274
+ /** `activate` only: admit the artifact's isolation intent under the preparation lock, before the swap. */
4275
+ admitIsolation?: (descriptor: ArtifactDescriptor) => Promise<void>;
3369
4276
  };
3370
4277
 
3371
4278
  export async function prepareApplication(application: Application, options: PrepareApplicationOptions = {}) {
3372
- const deploymentId = await broadcastDeployStart(application.name);
4279
+ const lifecycleToken = await broadcastDeployStart(application.name);
4280
+ const mode = options.mode ?? 'deploy';
4281
+ const artifactId = options.artifactId ?? lifecycleToken;
3373
4282
  try {
3374
4283
  const commandTimeoutMs = application.install?.timeout ?? DEFAULT_COMMAND_TIMEOUT_MS;
3375
4284
  await withComponentPreparationLock(
3376
4285
  application.dirPath,
3377
4286
  async () => {
4287
+ await options.beforePrepare?.();
3378
4288
  const asideStagingDir = extractionStagingDirectory(application.dirPath);
3379
4289
  let recoveryPending = true;
3380
4290
  try {
@@ -3385,7 +4295,12 @@ export async function prepareApplication(application: Application, options: Prep
3385
4295
  }
3386
4296
  // BEFORE the legacy pass. That pass refuses to restore while a journal survives, so skipping
3387
4297
  // this would not lose data — it would just stall the deploy behind its own unsettled state.
3388
- await settleJournaledActivationsForComponent(dirname(application.dirPath), application.name);
4298
+ //
4299
+ // The id this request names is pinned against the retention this runs. An activation would
4300
+ // otherwise have its own artifact deleted by its own preamble; and a redelivered stage would
4301
+ // have the artifact evicted out from under the exclusive claim below, which would then rebuild
4302
+ // different bytes under an id that already named some.
4303
+ await settleStagingForComponent(dirname(application.dirPath), application.name, artifactId);
3389
4304
  if (recoveryPending) {
3390
4305
  await ensureExtractionStagingDirectory(asideStagingDir);
3391
4306
  await recoverOrCleanupStaleExtractionPaths(application, asideStagingDir);
@@ -3400,6 +4315,10 @@ export async function prepareApplication(application: Application, options: Prep
3400
4315
  throw error;
3401
4316
  }
3402
4317
  ));
4318
+ if (mode === 'activate') {
4319
+ await activateStagedArtifact(application, artifactId, previousPackageMetadata, options);
4320
+ return;
4321
+ }
3403
4322
  try {
3404
4323
  // Materialize the per-deploy `.npmrc` before the build so both `npm pack` and `npm install`
3405
4324
  // authenticate against the private registry; always remove it afterward.
@@ -3410,7 +4329,9 @@ export async function prepareApplication(application: Application, options: Prep
3410
4329
  // credential is already gone before any install script runs. This finally covers the paths
3411
4330
  // that fail before it gets there.
3412
4331
  await application.startGitCredentialSession();
3413
- candidateDirPath = await buildCandidateApplication(application, deploymentId);
4332
+ candidateDirPath = await buildCandidateApplication(application, artifactId, {
4333
+ rejectLinkSource: mode === 'stage',
4334
+ });
3414
4335
  } finally {
3415
4336
  await application.cleanupGitCredentialSession();
3416
4337
  }
@@ -3425,7 +4346,25 @@ export async function prepareApplication(application: Application, options: Prep
3425
4346
  application.installationIsOpaque
3426
4347
  );
3427
4348
  }
3428
- await activateCandidateApplication(application, deploymentId);
4349
+ if (mode === 'stage') {
4350
+ await assertOwnedArtifactTree(candidateDirPath, application.name);
4351
+ // The descriptor goes first so `.complete` vouches for it: after this pair the artifact
4352
+ // is dormant, and a delayed activation reads its build's decisions from here because
4353
+ // nothing on disk carries them otherwise.
4354
+ const declared = options.describeArtifact?.() ?? { rootConfig: null, isolated: false };
4355
+ await writeArtifactDescriptor(application.dirPath, artifactId, {
4356
+ v: ARTIFACT_DESCRIPTOR_VERSION,
4357
+ component: application.name,
4358
+ rootConfig: declared.rootConfig,
4359
+ installationIsOpaque: application.installationIsOpaque,
4360
+ isolated: declared.isolated,
4361
+ });
4362
+ await markCandidateComplete(application.dirPath, artifactId, application.name);
4363
+ await syncArtifactAncestors(candidateDeploymentDirPath(application.dirPath, artifactId));
4364
+ return;
4365
+ }
4366
+ await markCandidateComplete(application.dirPath, artifactId, application.name);
4367
+ await activateCandidateApplication(application, artifactId);
3429
4368
  } catch (error) {
3430
4369
  // The builder's own cleanup only covers a failed BUILD. A rejected validation, or an
3431
4370
  // activation that was cleanly compensated, would otherwise leave a whole installed
@@ -3435,7 +4374,7 @@ export async function prepareApplication(application: Application, options: Prep
3435
4374
  // path may be absent, and the candidate plus its `.complete` marker and journal are exactly
3436
4375
  // what recovery needs to roll the validated deploy forward at the next start. Discarding
3437
4376
  // them there trades a bounded disk cost for a component with no version at all.
3438
- if (!compensationIncomplete(error)) await discardCandidate(application, deploymentId);
4377
+ if (!compensationIncomplete(error)) await discardCandidate(application, artifactId);
3439
4378
  throw error;
3440
4379
  }
3441
4380
  } finally {
@@ -3464,8 +4403,82 @@ export async function prepareApplication(application: Application, options: Prep
3464
4403
  }
3465
4404
  );
3466
4405
  } finally {
3467
- broadcastDeployEnd(application.name, deploymentId);
4406
+ broadcastDeployEnd(application.name, lifecycleToken);
4407
+ }
4408
+ }
4409
+
4410
+ /**
4411
+ * Swap an already-certified artifact into the live path. Called with the component's preparation lock held
4412
+ * and after the same recovery preamble every build runs, so the state read here is settled.
4413
+ *
4414
+ * Nothing is resolved, fetched or installed: the bytes were certified when they were staged, which is the
4415
+ * whole point of addressing one by id. Verification is therefore the only gate, and it is strict — this is
4416
+ * activation input for a build this process did not make and may not have made on this node.
4417
+ *
4418
+ * A rejection here must NOT discard: the artifact belongs to whoever staged it, a wrong-component request
4419
+ * must not delete another component's build, and a retry needs what a failed attempt left behind.
4420
+ */
4421
+ async function activateStagedArtifact(
4422
+ application: Application,
4423
+ artifactId: string,
4424
+ previousPackageMetadata: InstalledPackageMetadata,
4425
+ options: PrepareApplicationOptions
4426
+ ): Promise<void> {
4427
+ const deploymentDirPath = candidateDeploymentDirPath(application.dirPath, artifactId);
4428
+ const candidateDirPath = candidateApplicationPath(application.dirPath, artifactId);
4429
+ // 404 means only one thing — no artifact answers to this id here — so a caller can tell "never existed or
4430
+ // already used" from "present, but not something this can activate", which is every other refusal below
4431
+ // and a 409 like the rest of that family.
4432
+ const refuse = (why: string, statusCode: number) =>
4433
+ new ClientError(`Cannot deploy ${application.name} from deployment ${artifactId}: ${why}`, statusCode);
4434
+ const missing = (why: string) => refuse(why, 404);
4435
+ const unusable = (why: string) => refuse(why, 409);
4436
+
4437
+ const owner = await candidateComponentName(deploymentDirPath);
4438
+ if (owner === undefined) throw missing('there is no staged build with that id on this node');
4439
+ if (owner !== application.name) throw unusable(`that staged build belongs to '${owner}'`);
4440
+ if (!(await presentOrAbsent(join(deploymentDirPath, CANDIDATE_COMPLETE_MARKER)))) {
4441
+ throw unusable('its build never completed');
4442
+ }
4443
+ if (await presentOrAbsent(join(deploymentDirPath, UNSETTLED_MARKER))) {
4444
+ throw unusable('recovery could not settle it, so it is not safe to activate');
4445
+ }
4446
+ if (await presentOrAbsent(join(deploymentDirPath, ACTIVATION_JOURNAL))) {
4447
+ throw unusable('an activation of it is unsettled');
4448
+ }
4449
+ const candidateStat = await presentOrAbsent(candidateDirPath);
4450
+ if (!candidateStat || !candidateStat.isDirectory()) {
4451
+ // Unlike an immediate deploy, a symlink is refused: a `file:` directory is linked rather than
4452
+ // copied, so what it points at now is not what was certified. Staging rejects the source for the
4453
+ // same reason; this is the other end of the same rule, for an artifact staged by an older build.
4454
+ throw unusable('its build tree is missing or is a link rather than a copy');
4455
+ }
4456
+ const descriptor = await readArtifactDescriptor(deploymentDirPath, application.name);
4457
+ if (!descriptor) throw unusable('it does not record what its build decided');
4458
+
4459
+ await options.admitIsolation?.(descriptor);
4460
+
4461
+ // `.complete` is a durability marker over the bytes, not a seal on them, and every check that made this
4462
+ // artifact safe ran at stage time. The link rule especially cannot be skipped:
4463
+ // `repairRelocatedDependencyLinks` runs past the commit point and can only warn, so a link planted while
4464
+ // the artifact sat dormant would otherwise go live with the operation reporting success.
4465
+ await assertOwnedArtifactTree(candidateDirPath, application.name, 'activate');
4466
+ // Also the activation's `prepare` phase end: the deploy path emits `prepare`/`start` for every mode but
4467
+ // only ever emitted its `done` from here.
4468
+ await options.validateCandidate?.(candidateDirPath);
4469
+
4470
+ if (!application.isNewComponent) {
4471
+ application.packageMetadataChanged = installedRuntimeChanged(
4472
+ previousPackageMetadata,
4473
+ await readInstalledPackageMetadata(candidateDirPath),
4474
+ descriptor.installationIsOpaque
4475
+ );
3468
4476
  }
4477
+ await activateCandidateApplication(application, artifactId, {
4478
+ // Returns its own undo, which the swap runs inside its pre-commit boundary — see there for why it
4479
+ // cannot be run out here.
4480
+ afterJournal: descriptor.rootConfig ? () => options.publishRootConfig!(descriptor.rootConfig!) : undefined,
4481
+ });
3469
4482
  }
3470
4483
 
3471
4484
  /**
@@ -3856,7 +4869,8 @@ export async function nonInteractiveSpawn(
3856
4869
  timeoutMs: number = DEFAULT_COMMAND_TIMEOUT_MS,
3857
4870
  onLine?: (stream: 'stdout' | 'stderr', line: string) => void,
3858
4871
  npmUserconfigPath?: string,
3859
- gitCredentialEnv?: Record<string, string>
4872
+ gitCredentialEnv?: Record<string, string>,
4873
+ ignoreNpmScripts = false
3860
4874
  ): Promise<{ stdout: string; stderr: string; code: number }> {
3861
4875
  const gitSSH = await materializeGitSSH();
3862
4876
  try {
@@ -3869,7 +4883,8 @@ export async function nonInteractiveSpawn(
3869
4883
  onLine,
3870
4884
  npmUserconfigPath,
3871
4885
  gitSSH?.command,
3872
- gitCredentialEnv
4886
+ gitCredentialEnv,
4887
+ ignoreNpmScripts
3873
4888
  );
3874
4889
  } finally {
3875
4890
  await gitSSH?.cleanup();
@@ -3885,7 +4900,8 @@ function spawnWithEnv(
3885
4900
  onLine: ((stream: 'stdout' | 'stderr', line: string) => void) | undefined,
3886
4901
  npmUserconfigPath: string | undefined,
3887
4902
  gitSSHCommand: string | undefined,
3888
- gitCredentialEnv: Record<string, string> | undefined
4903
+ gitCredentialEnv: Record<string, string> | undefined,
4904
+ ignoreNpmScripts: boolean
3889
4905
  ): Promise<{ stdout: string; stderr: string; code: number }> {
3890
4906
  return new Promise((resolve, reject) => {
3891
4907
  logger
@@ -3921,6 +4937,12 @@ function spawnWithEnv(
3921
4937
  }
3922
4938
  env.npm_config_userconfig = npmUserconfigPath;
3923
4939
  }
4940
+ if (ignoreNpmScripts) {
4941
+ for (const key of Object.keys(env)) {
4942
+ if (key.toLowerCase() === 'npm_config_ignore_scripts') delete env[key];
4943
+ }
4944
+ env.npm_config_ignore_scripts = 'true';
4945
+ }
3924
4946
 
3925
4947
  if (process.platform === 'win32' && command === 'npm') {
3926
4948
  command = 'npm.cmd';