@harperfast/harper 5.2.9 → 5.3.0-alpha.1

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 (668) hide show
  1. package/agent/mcpTools.ts +1 -1
  2. package/agent/session.ts +25 -14
  3. package/bin/cliOperations.ts +46 -9
  4. package/bin/copyDb.ts +282 -59
  5. package/bin/deploySetup.ts +16 -5
  6. package/bin/harper.ts +1 -1
  7. package/bin/help.ts +4 -1
  8. package/bin/lite.ts +4 -1
  9. package/bin/restart.ts +59 -3
  10. package/bin/run.ts +6 -11
  11. package/bin/upgrade.js +7 -3
  12. package/bin/workloadIdentity.ts +119 -0
  13. package/components/Application.ts +2414 -242
  14. package/components/ApplicationScope.ts +8 -0
  15. package/components/EntryHandler.ts +59 -39
  16. package/components/OptionsWatcher.ts +150 -74
  17. package/components/RuntimeModuleTracker.ts +38 -7
  18. package/components/Scope.ts +37 -15
  19. package/components/awaitRestart.ts +84 -0
  20. package/components/componentLoader.ts +340 -30
  21. package/components/componentPreparationLock.ts +16 -5
  22. package/components/mcp/adapters/harperHttp.ts +4 -0
  23. package/components/mcp/listChanged.ts +4 -0
  24. package/components/mcp/toolRegistry.ts +2 -0
  25. package/components/mcp/tools/operations.ts +9 -0
  26. package/components/mcp/tools/schemas/operationDescriptions.ts +2 -2
  27. package/components/operations.js +270 -78
  28. package/components/operationsValidation.js +49 -1
  29. package/components/status/ComponentStatusRegistry.ts +59 -0
  30. package/config/RootConfigWatcher.ts +80 -34
  31. package/config/configUtils.ts +291 -34
  32. package/config/harperConfigEnvVars.ts +170 -27
  33. package/config-root.schema.json +29 -0
  34. package/dataLayer/blobBackup.ts +160 -50
  35. package/dataLayer/delete.ts +6 -1
  36. package/dataLayer/harperBridge/ResourceBridge.ts +52 -8
  37. package/dataLayer/harperBridge/lmdbBridge/lmdbMethods/DeleteAuditLogsBeforeResults.js +3 -1
  38. package/dataLayer/hdbInfoController.ts +34 -1
  39. package/dataLayer/insert.ts +44 -1
  40. package/dataLayer/rocksdbBackup.ts +53 -10
  41. package/dataLayer/schema.ts +11 -1
  42. package/dataLayer/schemaDescribe.ts +8 -1
  43. package/dist/agent/mcpTools.js +1 -1
  44. package/dist/agent/mcpTools.js.map +1 -1
  45. package/dist/agent/session.d.ts +22 -0
  46. package/dist/agent/session.js +26 -15
  47. package/dist/agent/session.js.map +1 -1
  48. package/dist/bin/cliOperations.js +48 -9
  49. package/dist/bin/cliOperations.js.map +1 -1
  50. package/dist/bin/copyDb.d.ts +12 -1
  51. package/dist/bin/copyDb.js +248 -60
  52. package/dist/bin/copyDb.js.map +1 -1
  53. package/dist/bin/deploySetup.d.ts +2 -0
  54. package/dist/bin/deploySetup.js +11 -3
  55. package/dist/bin/deploySetup.js.map +1 -1
  56. package/dist/bin/harper.js +1 -1
  57. package/dist/bin/harper.js.map +1 -1
  58. package/dist/bin/help.js +4 -1
  59. package/dist/bin/help.js.map +1 -1
  60. package/dist/bin/lite.js +4 -1
  61. package/dist/bin/lite.js.map +1 -1
  62. package/dist/bin/restart.js +54 -5
  63. package/dist/bin/restart.js.map +1 -1
  64. package/dist/bin/run.js +4 -10
  65. package/dist/bin/run.js.map +1 -1
  66. package/dist/bin/upgrade.js +4 -3
  67. package/dist/bin/upgrade.js.map +1 -1
  68. package/dist/bin/workloadIdentity.d.ts +18 -0
  69. package/dist/bin/workloadIdentity.js +100 -0
  70. package/dist/bin/workloadIdentity.js.map +1 -0
  71. package/dist/components/Application.d.ts +139 -16
  72. package/dist/components/Application.js +2215 -267
  73. package/dist/components/Application.js.map +1 -1
  74. package/dist/components/ApplicationScope.d.ts +8 -0
  75. package/dist/components/ApplicationScope.js +7 -0
  76. package/dist/components/ApplicationScope.js.map +1 -1
  77. package/dist/components/EntryHandler.js +26 -10
  78. package/dist/components/EntryHandler.js.map +1 -1
  79. package/dist/components/OptionsWatcher.d.ts +1 -0
  80. package/dist/components/OptionsWatcher.js +141 -74
  81. package/dist/components/OptionsWatcher.js.map +1 -1
  82. package/dist/components/RuntimeModuleTracker.js +40 -6
  83. package/dist/components/RuntimeModuleTracker.js.map +1 -1
  84. package/dist/components/Scope.js +38 -13
  85. package/dist/components/Scope.js.map +1 -1
  86. package/dist/components/awaitRestart.d.ts +33 -0
  87. package/dist/components/awaitRestart.js +61 -0
  88. package/dist/components/awaitRestart.js.map +1 -0
  89. package/dist/components/componentLoader.d.ts +38 -1
  90. package/dist/components/componentLoader.js +279 -22
  91. package/dist/components/componentLoader.js.map +1 -1
  92. package/dist/components/componentPreparationLock.d.ts +5 -0
  93. package/dist/components/componentPreparationLock.js +14 -6
  94. package/dist/components/componentPreparationLock.js.map +1 -1
  95. package/dist/components/mcp/adapters/harperHttp.js +4 -0
  96. package/dist/components/mcp/adapters/harperHttp.js.map +1 -1
  97. package/dist/components/mcp/listChanged.js +5 -0
  98. package/dist/components/mcp/listChanged.js.map +1 -1
  99. package/dist/components/mcp/toolRegistry.d.ts +1 -0
  100. package/dist/components/mcp/toolRegistry.js.map +1 -1
  101. package/dist/components/mcp/tools/operations.d.ts +5 -0
  102. package/dist/components/mcp/tools/operations.js +9 -0
  103. package/dist/components/mcp/tools/operations.js.map +1 -1
  104. package/dist/components/mcp/tools/schemas/operationDescriptions.js +2 -2
  105. package/dist/components/mcp/tools/schemas/operationDescriptions.js.map +1 -1
  106. package/dist/components/operations.js +231 -77
  107. package/dist/components/operations.js.map +1 -1
  108. package/dist/components/operationsValidation.js +49 -1
  109. package/dist/components/operationsValidation.js.map +1 -1
  110. package/dist/components/status/ComponentStatusRegistry.d.ts +0 -4
  111. package/dist/components/status/ComponentStatusRegistry.js +63 -0
  112. package/dist/components/status/ComponentStatusRegistry.js.map +1 -1
  113. package/dist/config/RootConfigWatcher.d.ts +7 -1
  114. package/dist/config/RootConfigWatcher.js +64 -27
  115. package/dist/config/RootConfigWatcher.js.map +1 -1
  116. package/dist/config/configUtils.d.ts +9 -1
  117. package/dist/config/configUtils.js +254 -33
  118. package/dist/config/configUtils.js.map +1 -1
  119. package/dist/config/harperConfigEnvVars.d.ts +16 -0
  120. package/dist/config/harperConfigEnvVars.js +162 -25
  121. package/dist/config/harperConfigEnvVars.js.map +1 -1
  122. package/dist/dataLayer/blobBackup.d.ts +49 -20
  123. package/dist/dataLayer/blobBackup.js +139 -50
  124. package/dist/dataLayer/blobBackup.js.map +1 -1
  125. package/dist/dataLayer/delete.js +1 -1
  126. package/dist/dataLayer/delete.js.map +1 -1
  127. package/dist/dataLayer/harperBridge/ResourceBridge.d.ts +14 -1
  128. package/dist/dataLayer/harperBridge/ResourceBridge.js +51 -10
  129. package/dist/dataLayer/harperBridge/ResourceBridge.js.map +1 -1
  130. package/dist/dataLayer/harperBridge/lmdbBridge/lmdbMethods/DeleteAuditLogsBeforeResults.d.ts +3 -1
  131. package/dist/dataLayer/harperBridge/lmdbBridge/lmdbMethods/DeleteAuditLogsBeforeResults.js +3 -1
  132. package/dist/dataLayer/harperBridge/lmdbBridge/lmdbMethods/DeleteAuditLogsBeforeResults.js.map +1 -1
  133. package/dist/dataLayer/hdbInfoController.d.ts +10 -0
  134. package/dist/dataLayer/hdbInfoController.js +30 -1
  135. package/dist/dataLayer/hdbInfoController.js.map +1 -1
  136. package/dist/dataLayer/insert.d.ts +9 -1
  137. package/dist/dataLayer/insert.js +30 -0
  138. package/dist/dataLayer/insert.js.map +1 -1
  139. package/dist/dataLayer/rocksdbBackup.d.ts +2 -2
  140. package/dist/dataLayer/rocksdbBackup.js +45 -8
  141. package/dist/dataLayer/rocksdbBackup.js.map +1 -1
  142. package/dist/dataLayer/schema.js +8 -0
  143. package/dist/dataLayer/schema.js.map +1 -1
  144. package/dist/dataLayer/schemaDescribe.js +8 -1
  145. package/dist/dataLayer/schemaDescribe.js.map +1 -1
  146. package/dist/index.d.ts +1 -1
  147. package/dist/index.js.map +1 -1
  148. package/dist/json/systemSchema.json +52 -0
  149. package/dist/resources/DatabaseTransaction.d.ts +55 -0
  150. package/dist/resources/DatabaseTransaction.js +459 -45
  151. package/dist/resources/DatabaseTransaction.js.map +1 -1
  152. package/dist/resources/LMDBTransaction.js +22 -4
  153. package/dist/resources/LMDBTransaction.js.map +1 -1
  154. package/dist/resources/PrimaryRocksDatabase.d.ts +1 -0
  155. package/dist/resources/PrimaryRocksDatabase.js +29 -0
  156. package/dist/resources/PrimaryRocksDatabase.js.map +1 -1
  157. package/dist/resources/RecordEncoder.d.ts +20 -0
  158. package/dist/resources/RecordEncoder.js +94 -9
  159. package/dist/resources/RecordEncoder.js.map +1 -1
  160. package/dist/resources/RequestTarget.d.ts +2 -0
  161. package/dist/resources/RequestTarget.js.map +1 -1
  162. package/dist/resources/Resource.js +20 -11
  163. package/dist/resources/Resource.js.map +1 -1
  164. package/dist/resources/ResourceInterface.d.ts +20 -1
  165. package/dist/resources/ResourceInterface.js.map +1 -1
  166. package/dist/resources/RocksIndexStore.d.ts +6 -1
  167. package/dist/resources/RocksIndexStore.js +24 -9
  168. package/dist/resources/RocksIndexStore.js.map +1 -1
  169. package/dist/resources/RocksTransactionLogStore.d.ts +14 -1
  170. package/dist/resources/RocksTransactionLogStore.js +57 -17
  171. package/dist/resources/RocksTransactionLogStore.js.map +1 -1
  172. package/dist/resources/Table.d.ts +112 -8
  173. package/dist/resources/Table.js +1073 -191
  174. package/dist/resources/Table.js.map +1 -1
  175. package/dist/resources/auditStore.d.ts +11 -2
  176. package/dist/resources/auditStore.js +325 -71
  177. package/dist/resources/auditStore.js.map +1 -1
  178. package/dist/resources/blob.d.ts +129 -9
  179. package/dist/resources/blob.js +992 -114
  180. package/dist/resources/blob.js.map +1 -1
  181. package/dist/resources/branchDatabase.d.ts +48 -0
  182. package/dist/resources/branchDatabase.js +892 -0
  183. package/dist/resources/branchDatabase.js.map +1 -0
  184. package/dist/resources/crdt.js +50 -12
  185. package/dist/resources/crdt.js.map +1 -1
  186. package/dist/resources/databases.d.ts +150 -0
  187. package/dist/resources/databases.js +1200 -239
  188. package/dist/resources/databases.js.map +1 -1
  189. package/dist/resources/defineTable.d.ts +10 -2
  190. package/dist/resources/defineTable.js +9 -1
  191. package/dist/resources/defineTable.js.map +1 -1
  192. package/dist/resources/graphql.d.ts +1 -1
  193. package/dist/resources/graphql.js +50 -15
  194. package/dist/resources/graphql.js.map +1 -1
  195. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.d.ts +1 -0
  196. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js +6 -2
  197. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js.map +1 -1
  198. package/dist/resources/longLivedTransactions.d.ts +71 -0
  199. package/dist/resources/longLivedTransactions.js +358 -0
  200. package/dist/resources/longLivedTransactions.js.map +1 -0
  201. package/dist/resources/models/backendRegistry.d.ts +26 -0
  202. package/dist/resources/models/backendRegistry.js +60 -2
  203. package/dist/resources/models/backendRegistry.js.map +1 -1
  204. package/dist/resources/models/bootstrap.d.ts +33 -1
  205. package/dist/resources/models/bootstrap.js +416 -31
  206. package/dist/resources/models/bootstrap.js.map +1 -1
  207. package/dist/resources/recordLock.d.ts +80 -0
  208. package/dist/resources/recordLock.js +184 -0
  209. package/dist/resources/recordLock.js.map +1 -0
  210. package/dist/resources/replayLogs.d.ts +14 -1
  211. package/dist/resources/replayLogs.js +152 -24
  212. package/dist/resources/replayLogs.js.map +1 -1
  213. package/dist/resources/replayLogsGuards.d.ts +94 -7
  214. package/dist/resources/replayLogsGuards.js +111 -7
  215. package/dist/resources/replayLogsGuards.js.map +1 -1
  216. package/dist/resources/search.js +158 -6
  217. package/dist/resources/search.js.map +1 -1
  218. package/dist/resources/transactionBroadcast.d.ts +1 -1
  219. package/dist/resources/transactionBroadcast.js +2 -2
  220. package/dist/resources/transactionBroadcast.js.map +1 -1
  221. package/dist/security/auth.js +61 -30
  222. package/dist/security/auth.js.map +1 -1
  223. package/dist/security/authn/oidc/claims.d.ts +22 -0
  224. package/dist/security/authn/oidc/claims.js +71 -0
  225. package/dist/security/authn/oidc/claims.js.map +1 -0
  226. package/dist/security/authn/oidc/identityToken.d.ts +27 -0
  227. package/dist/security/authn/oidc/identityToken.js +111 -0
  228. package/dist/security/authn/oidc/identityToken.js.map +1 -0
  229. package/dist/security/authn/oidc/jwks.d.ts +25 -0
  230. package/dist/security/authn/oidc/jwks.js +261 -0
  231. package/dist/security/authn/oidc/jwks.js.map +1 -0
  232. package/dist/security/authn/oidc/providers/generic.d.ts +13 -0
  233. package/dist/security/authn/oidc/providers/generic.js +34 -0
  234. package/dist/security/authn/oidc/providers/generic.js.map +1 -0
  235. package/dist/security/authn/oidc/providers/githubActions.d.ts +11 -0
  236. package/dist/security/authn/oidc/providers/githubActions.js +129 -0
  237. package/dist/security/authn/oidc/providers/githubActions.js.map +1 -0
  238. package/dist/security/authn/oidc/providers/index.d.ts +37 -0
  239. package/dist/security/authn/oidc/providers/index.js +24 -0
  240. package/dist/security/authn/oidc/providers/index.js.map +1 -0
  241. package/dist/security/authn/oidc/tokenExchange.d.ts +12 -0
  242. package/dist/security/authn/oidc/tokenExchange.js +306 -0
  243. package/dist/security/authn/oidc/tokenExchange.js.map +1 -0
  244. package/dist/security/authn/oidc/trustPolicyOperations.d.ts +49 -0
  245. package/dist/security/authn/oidc/trustPolicyOperations.js +358 -0
  246. package/dist/security/authn/oidc/trustPolicyOperations.js.map +1 -0
  247. package/dist/security/authn/oidc/types.d.ts +38 -0
  248. package/dist/security/authn/oidc/types.js +6 -0
  249. package/dist/security/authn/oidc/types.js.map +1 -0
  250. package/dist/security/certificateVerification/index.js +40 -11
  251. package/dist/security/certificateVerification/index.js.map +1 -1
  252. package/dist/security/certificateVerification/trustedIssuers.d.ts +24 -0
  253. package/dist/security/certificateVerification/trustedIssuers.js +79 -0
  254. package/dist/security/certificateVerification/trustedIssuers.js.map +1 -0
  255. package/dist/security/certificateVerification/types.d.ts +1 -0
  256. package/dist/security/credentialProvenance.d.ts +35 -0
  257. package/dist/security/credentialProvenance.js +51 -0
  258. package/dist/security/credentialProvenance.js.map +1 -0
  259. package/dist/security/credentialRejection.d.ts +4 -0
  260. package/dist/security/credentialRejection.js +24 -0
  261. package/dist/security/credentialRejection.js.map +1 -0
  262. package/dist/security/deferredAuthentication.d.ts +36 -0
  263. package/dist/security/deferredAuthentication.js +70 -0
  264. package/dist/security/deferredAuthentication.js.map +1 -0
  265. package/dist/security/impersonation.d.ts +21 -0
  266. package/dist/security/impersonation.js +108 -9
  267. package/dist/security/impersonation.js.map +1 -1
  268. package/dist/security/jsLoader.d.ts +6 -0
  269. package/dist/security/jsLoader.js +75 -15
  270. package/dist/security/jsLoader.js.map +1 -1
  271. package/dist/security/keys.js +301 -71
  272. package/dist/security/keys.js.map +1 -1
  273. package/dist/security/operationScope.d.ts +21 -0
  274. package/dist/security/operationScope.js +36 -0
  275. package/dist/security/operationScope.js.map +1 -0
  276. package/dist/security/permissionsTranslator.js +21 -0
  277. package/dist/security/permissionsTranslator.js.map +1 -1
  278. package/dist/security/tokenAuthentication.d.ts +19 -1
  279. package/dist/security/tokenAuthentication.js +191 -10
  280. package/dist/security/tokenAuthentication.js.map +1 -1
  281. package/dist/security/user.js +4 -3
  282. package/dist/security/user.js.map +1 -1
  283. package/dist/server/DurableSubscriptionsSession.d.ts +2 -2
  284. package/dist/server/DurableSubscriptionsSession.js +67 -10
  285. package/dist/server/DurableSubscriptionsSession.js.map +1 -1
  286. package/dist/server/REST.js +73 -0
  287. package/dist/server/REST.js.map +1 -1
  288. package/dist/server/graphqlQuerying.js +4 -0
  289. package/dist/server/graphqlQuerying.js.map +1 -1
  290. package/dist/server/http.d.ts +11 -0
  291. package/dist/server/http.js +88 -15
  292. package/dist/server/http.js.map +1 -1
  293. package/dist/server/itc/serverHandlers.js +8 -1
  294. package/dist/server/itc/serverHandlers.js.map +1 -1
  295. package/dist/server/jobs/jobProcess.js +6 -2
  296. package/dist/server/jobs/jobProcess.js.map +1 -1
  297. package/dist/server/jobs/jobs.js +4 -1
  298. package/dist/server/jobs/jobs.js.map +1 -1
  299. package/dist/server/liveSubscriptionAuth.d.ts +26 -4
  300. package/dist/server/liveSubscriptionAuth.js +105 -39
  301. package/dist/server/liveSubscriptionAuth.js.map +1 -1
  302. package/dist/server/loadRootComponents.js +49 -10
  303. package/dist/server/loadRootComponents.js.map +1 -1
  304. package/dist/server/mqtt.d.ts +2 -0
  305. package/dist/server/mqtt.js +165 -30
  306. package/dist/server/mqtt.js.map +1 -1
  307. package/dist/server/nodeName.d.ts +2 -0
  308. package/dist/server/nodeName.js +107 -23
  309. package/dist/server/nodeName.js.map +1 -1
  310. package/dist/server/serverHelpers/Headers.d.ts +25 -0
  311. package/dist/server/serverHelpers/Headers.js +139 -1
  312. package/dist/server/serverHelpers/Headers.js.map +1 -1
  313. package/dist/server/serverHelpers/contentTypes.d.ts +9 -0
  314. package/dist/server/serverHelpers/contentTypes.js +32 -25
  315. package/dist/server/serverHelpers/contentTypes.js.map +1 -1
  316. package/dist/server/serverHelpers/deployValidationState.d.ts +3 -0
  317. package/dist/server/serverHelpers/deployValidationState.js +9 -19
  318. package/dist/server/serverHelpers/deployValidationState.js.map +1 -1
  319. package/dist/server/serverHelpers/operationAuthorizationState.d.ts +12 -0
  320. package/dist/server/serverHelpers/operationAuthorizationState.js +24 -2
  321. package/dist/server/serverHelpers/operationAuthorizationState.js.map +1 -1
  322. package/dist/server/serverHelpers/registeredOperations.d.ts +5 -4
  323. package/dist/server/serverHelpers/registeredOperations.js +74 -21
  324. package/dist/server/serverHelpers/registeredOperations.js.map +1 -1
  325. package/dist/server/serverHelpers/requestSanitization.d.ts +11 -0
  326. package/dist/server/serverHelpers/requestSanitization.js +20 -0
  327. package/dist/server/serverHelpers/requestSanitization.js.map +1 -0
  328. package/dist/server/serverHelpers/serverHandlers.js +6 -3
  329. package/dist/server/serverHelpers/serverHandlers.js.map +1 -1
  330. package/dist/server/serverHelpers/serverUtilities.d.ts +18 -0
  331. package/dist/server/serverHelpers/serverUtilities.js +90 -20
  332. package/dist/server/serverHelpers/serverUtilities.js.map +1 -1
  333. package/dist/server/serverHelpers/sharedMessageEncoding.d.ts +67 -0
  334. package/dist/server/serverHelpers/sharedMessageEncoding.js +280 -0
  335. package/dist/server/serverHelpers/sharedMessageEncoding.js.map +1 -0
  336. package/dist/server/serverHelpers/uwsServer.js +19 -1
  337. package/dist/server/serverHelpers/uwsServer.js.map +1 -1
  338. package/dist/server/static.js +24 -28
  339. package/dist/server/static.js.map +1 -1
  340. package/dist/server/storageReclamation.d.ts +12 -0
  341. package/dist/server/storageReclamation.js +26 -0
  342. package/dist/server/storageReclamation.js.map +1 -1
  343. package/dist/server/threads/itc.d.ts +7 -2
  344. package/dist/server/threads/itc.js +5 -1
  345. package/dist/server/threads/itc.js.map +1 -1
  346. package/dist/server/threads/manageThreads.d.ts +26 -3
  347. package/dist/server/threads/manageThreads.js +517 -65
  348. package/dist/server/threads/manageThreads.js.map +1 -1
  349. package/dist/server/threads/socketRouter.js +89 -26
  350. package/dist/server/threads/socketRouter.js.map +1 -1
  351. package/dist/server/threads/threadHeapMemory.d.ts +2 -0
  352. package/dist/server/threads/threadHeapMemory.js +31 -0
  353. package/dist/server/threads/threadHeapMemory.js.map +1 -0
  354. package/dist/server/threads/threadServer.js +46 -15
  355. package/dist/server/threads/threadServer.js.map +1 -1
  356. package/dist/sqlEngine/config.d.ts +1 -3
  357. package/dist/sqlEngine/config.js +19 -16
  358. package/dist/sqlEngine/config.js.map +1 -1
  359. package/dist/sqlTranslator/index.d.ts +1 -1
  360. package/dist/sqlTranslator/index.js +30 -7
  361. package/dist/sqlTranslator/index.js.map +1 -1
  362. package/dist/upgrade/directives/5-3-0.d.ts +7 -0
  363. package/dist/upgrade/directives/5-3-0.js +148 -0
  364. package/dist/upgrade/directives/5-3-0.js.map +1 -0
  365. package/dist/upgrade/directives/directivesController.js +2 -1
  366. package/dist/upgrade/directives/directivesController.js.map +1 -1
  367. package/dist/utility/OperationFunctionCaller.js +2 -1
  368. package/dist/utility/OperationFunctionCaller.js.map +1 -1
  369. package/dist/utility/common_utils.d.ts +16 -0
  370. package/dist/utility/common_utils.js +32 -6
  371. package/dist/utility/common_utils.js.map +1 -1
  372. package/dist/utility/componentNames.d.ts +8 -0
  373. package/dist/utility/componentNames.js +12 -1
  374. package/dist/utility/componentNames.js.map +1 -1
  375. package/dist/utility/environment/environmentManager.js +3 -6
  376. package/dist/utility/environment/environmentManager.js.map +1 -1
  377. package/dist/utility/environment/systemInformation.d.ts +1 -0
  378. package/dist/utility/environment/systemInformation.js +1 -0
  379. package/dist/utility/environment/systemInformation.js.map +1 -1
  380. package/dist/utility/errors/commonErrors.d.ts +2 -0
  381. package/dist/utility/errors/commonErrors.js +2 -0
  382. package/dist/utility/errors/commonErrors.js.map +1 -1
  383. package/dist/utility/errors/hdbError.d.ts +17 -0
  384. package/dist/utility/errors/hdbError.js +30 -1
  385. package/dist/utility/errors/hdbError.js.map +1 -1
  386. package/dist/utility/globalSchema.d.ts +18 -0
  387. package/dist/utility/hdbTerms.d.ts +16 -0
  388. package/dist/utility/hdbTerms.js +18 -2
  389. package/dist/utility/hdbTerms.js.map +1 -1
  390. package/dist/utility/logging/harper_logger.d.ts +2 -0
  391. package/dist/utility/logging/harper_logger.js +91 -14
  392. package/dist/utility/logging/harper_logger.js.map +1 -1
  393. package/dist/utility/logging/logRotator.js +76 -49
  394. package/dist/utility/logging/logRotator.js.map +1 -1
  395. package/dist/utility/nodeIdentity.d.ts +9 -0
  396. package/dist/utility/nodeIdentity.js +58 -0
  397. package/dist/utility/nodeIdentity.js.map +1 -0
  398. package/dist/utility/npmUtilities.js +9 -7
  399. package/dist/utility/npmUtilities.js.map +1 -1
  400. package/dist/utility/operationPermissions.d.ts +3 -1
  401. package/dist/utility/operationPermissions.js +16 -1
  402. package/dist/utility/operationPermissions.js.map +1 -1
  403. package/dist/utility/operation_authorization.d.ts +10 -7
  404. package/dist/utility/operation_authorization.js +212 -41
  405. package/dist/utility/operation_authorization.js.map +1 -1
  406. package/dist/utility/watchPath.d.ts +29 -0
  407. package/dist/utility/watchPath.js +68 -0
  408. package/dist/utility/watchPath.js.map +1 -0
  409. package/dist/utility/watcherFallback.d.ts +86 -0
  410. package/dist/utility/watcherFallback.js +278 -1
  411. package/dist/utility/watcherFallback.js.map +1 -1
  412. package/dist/validation/configValidator.d.ts +12 -0
  413. package/dist/validation/configValidator.js +199 -72
  414. package/dist/validation/configValidator.js.map +1 -1
  415. package/dist/validation/installValidator.js +12 -0
  416. package/dist/validation/installValidator.js.map +1 -1
  417. package/dist/validation/validationWrapper.d.ts +11 -0
  418. package/dist/validation/validationWrapper.js +16 -3
  419. package/dist/validation/validationWrapper.js.map +1 -1
  420. package/index.ts +2 -0
  421. package/json/systemSchema.json +52 -0
  422. package/npm-shrinkwrap.json +196 -194
  423. package/package.json +7 -6
  424. package/resources/DESIGN.md +114 -52
  425. package/resources/DatabaseTransaction.ts +521 -43
  426. package/resources/LMDBTransaction.ts +26 -4
  427. package/resources/PrimaryRocksDatabase.ts +28 -1
  428. package/resources/RecordEncoder.ts +103 -8
  429. package/resources/RequestTarget.ts +2 -0
  430. package/resources/Resource.ts +17 -9
  431. package/resources/ResourceInterface.ts +23 -0
  432. package/resources/RocksIndexStore.ts +30 -9
  433. package/resources/RocksTransactionLogStore.ts +78 -21
  434. package/resources/Table.ts +1110 -159
  435. package/resources/auditStore.ts +331 -73
  436. package/resources/blob.ts +1029 -110
  437. package/resources/branchDatabase.ts +941 -0
  438. package/resources/crdt.ts +76 -12
  439. package/resources/databases.ts +1310 -252
  440. package/resources/defineTable.ts +18 -2
  441. package/resources/graphql.ts +70 -16
  442. package/resources/indexes/HierarchicalNavigableSmallWorld.ts +6 -2
  443. package/resources/longLivedTransactions.ts +360 -0
  444. package/resources/models/backendRegistry.ts +84 -2
  445. package/resources/models/bootstrap.ts +473 -28
  446. package/resources/recordLock.ts +253 -0
  447. package/resources/replayLogs.ts +147 -26
  448. package/resources/replayLogsGuards.ts +171 -8
  449. package/resources/search.ts +154 -7
  450. package/resources/transactionBroadcast.ts +3 -3
  451. package/security/auth.ts +68 -29
  452. package/security/authn/oidc/claims.ts +72 -0
  453. package/security/authn/oidc/identityToken.ts +129 -0
  454. package/security/authn/oidc/jwks.ts +260 -0
  455. package/security/authn/oidc/providers/generic.ts +40 -0
  456. package/security/authn/oidc/providers/githubActions.ts +137 -0
  457. package/security/authn/oidc/providers/index.ts +52 -0
  458. package/security/authn/oidc/tokenExchange.ts +300 -0
  459. package/security/authn/oidc/trustPolicyOperations.ts +343 -0
  460. package/security/authn/oidc/types.ts +41 -0
  461. package/security/certificateVerification/index.ts +54 -13
  462. package/security/certificateVerification/trustedIssuers.ts +76 -0
  463. package/security/certificateVerification/types.ts +1 -0
  464. package/security/credentialProvenance.ts +47 -0
  465. package/security/credentialRejection.ts +22 -0
  466. package/security/deferredAuthentication.ts +71 -0
  467. package/security/impersonation.ts +117 -12
  468. package/security/jsLoader.ts +81 -18
  469. package/security/keys.ts +298 -72
  470. package/security/operationScope.ts +33 -0
  471. package/security/permissionsTranslator.js +23 -0
  472. package/security/tokenAuthentication.ts +233 -12
  473. package/security/user.ts +4 -3
  474. package/server/DESIGN.md +183 -16
  475. package/server/DurableSubscriptionsSession.ts +71 -11
  476. package/server/REST.ts +79 -1
  477. package/server/graphqlQuerying.ts +4 -0
  478. package/server/http.ts +99 -18
  479. package/server/itc/serverHandlers.js +8 -1
  480. package/server/jobs/jobProcess.ts +8 -2
  481. package/server/jobs/jobs.ts +4 -1
  482. package/server/liveSubscriptionAuth.ts +129 -46
  483. package/server/loadRootComponents.js +50 -8
  484. package/server/mqtt.ts +179 -38
  485. package/server/nodeName.ts +103 -21
  486. package/server/serverHelpers/Headers.ts +131 -0
  487. package/server/serverHelpers/contentTypes.ts +29 -21
  488. package/server/serverHelpers/deployValidationState.ts +24 -13
  489. package/server/serverHelpers/operationAuthorizationState.ts +34 -3
  490. package/server/serverHelpers/registeredOperations.ts +79 -22
  491. package/server/serverHelpers/requestSanitization.ts +15 -0
  492. package/server/serverHelpers/serverHandlers.js +6 -3
  493. package/server/serverHelpers/serverUtilities.ts +104 -31
  494. package/server/serverHelpers/sharedMessageEncoding.ts +307 -0
  495. package/server/serverHelpers/uwsServer.ts +17 -2
  496. package/server/static.ts +23 -29
  497. package/server/storageReclamation.ts +23 -0
  498. package/server/threads/itc.js +11 -1
  499. package/server/threads/manageThreads.js +526 -56
  500. package/server/threads/socketRouter.ts +81 -26
  501. package/server/threads/threadHeapMemory.ts +26 -0
  502. package/server/threads/threadServer.js +43 -15
  503. package/sqlTranslator/index.ts +31 -8
  504. package/studio/web/assets/Chat-BnCBegQz.js +2267 -0
  505. package/studio/web/assets/FloatingChat-CoDW1ySS.js +23 -0
  506. package/studio/web/assets/{abnfDiagram-VCTEODGH-C0_BAZyO.js → abnfDiagram-VCTEODGH-B0BebmD2.js} +1 -1
  507. package/studio/web/assets/{alertDialog-DIHt7Z0r.js → alertDialog-CQyAJJhl.js} +1 -1
  508. package/studio/web/assets/{apiToken-c3Rd-w6g.js → apiToken-Bwk5BLXW.js} +1 -1
  509. package/studio/web/assets/applications-DHxGi7JH.js +296 -0
  510. package/studio/web/assets/architecture-7GRP2DOG-LB-MLAAb.js +1 -0
  511. package/studio/web/assets/{architectureDiagram-5GKGNRK7-BWzrASgm.js → architectureDiagram-5GKGNRK7-7SW3GD-K.js} +1 -1
  512. package/studio/web/assets/authStore-C3Nfubqr.js +3 -0
  513. package/studio/web/assets/{blockDiagram-NRAW4CY4-BdJX9Khj.js → blockDiagram-I7D4REHJ-BqguiadH.js} +2 -2
  514. package/studio/web/assets/{button-DhiX-njv.js → button-BIsUKRZq.js} +2 -2
  515. package/studio/web/assets/{c4Diagram-UCG6FXSJ-CI6MzGmQ.js → c4Diagram-7LVT6UL2-LBNf8t_X.js} +1 -1
  516. package/studio/web/assets/channel-yictG-U-.js +1 -0
  517. package/studio/web/assets/{chevron-up-Df2c8uoU.js → chevron-up-DtKGqDn3.js} +1 -1
  518. package/studio/web/assets/{chunk-TEH6E4GO-P87k5mNi.js → chunk-4HAMMTFA-DWtTut21.js} +1 -1
  519. package/studio/web/assets/{chunk-75Z2AOVW-BT8tVmks.js → chunk-75Z2AOVW-QGQD6th2.js} +1 -1
  520. package/studio/web/assets/{chunk-DU6HZSFF-9kAOOmI6.js → chunk-DU6HZSFF-Chq20Ba5.js} +1 -1
  521. package/studio/web/assets/{chunk-F27PBJKO-BW7ao8AY.js → chunk-F27PBJKO-BVA5EPhV.js} +1 -1
  522. package/studio/web/assets/{chunk-GMAD6QVW-BNyXpoQO.js → chunk-GMAD6QVW-BeS7S07A.js} +1 -1
  523. package/studio/web/assets/{chunk-OBVCFTLP-D4wWOqDQ.js → chunk-GVQU2GXP-sbwVIQ8i.js} +1 -1
  524. package/studio/web/assets/{chunk-G27WJ6UU-COyLMcgK.js → chunk-IMKFNOWR-Bnh3tAVd.js} +1 -1
  525. package/studio/web/assets/{chunk-JQ64N6SF-Cyz1IeLf.js → chunk-L3NEJ4N5-COfUyKII.js} +1 -1
  526. package/studio/web/assets/chunk-OSK3NFVY-ByciRftO.js +10 -0
  527. package/studio/web/assets/{chunk-P2QGCYS3-DmIFY4d7.js → chunk-P2QGCYS3-CP1VhG_c.js} +1 -1
  528. package/studio/web/assets/{chunk-POPQ4Y6H-BPrvMyKz.js → chunk-POPQ4Y6H-ClWhhkwW.js} +1 -1
  529. package/studio/web/assets/{chunk-PWAF6VOD-2zB6IW9i.js → chunk-PWAF6VOD-1z1THyS5.js} +1 -1
  530. package/studio/web/assets/{chunk-RHFEMEQ7-2FgyI8YU.js → chunk-SHT3W25Y-LpQkMsah.js} +2 -2
  531. package/studio/web/assets/{chunk-SVP7TREG-FwtbH2QC.js → chunk-SVP7TREG-jtdAHw0S.js} +1 -1
  532. package/studio/web/assets/{chunk-LCL6LL3I-HOzK_ppE.js → chunk-TICWLB2K-VOwzetX-.js} +1 -1
  533. package/studio/web/assets/classDiagram-ZZMXUADV-VaEwSy_g.js +1 -0
  534. package/studio/web/assets/classDiagram-v2-VYDZK3BY-VaEwSy_g.js +1 -0
  535. package/studio/web/assets/{createLucideIcon-BKGPfjm2.js → createLucideIcon-CzW9508A.js} +1 -1
  536. package/studio/web/assets/{cssMode-CEN2mzSA.js → cssMode-s0cWI-_M.js} +1 -1
  537. package/studio/web/assets/{cynefin-OW5HDTMX-BRkpLFQV.js → cynefin-OW5HDTMX-BbdbCvub.js} +1 -1
  538. package/studio/web/assets/{cynefinDiagram-5FMLGOSQ-CHT1DaX6.js → cynefinDiagram-5FMLGOSQ-TP-aIqbt.js} +1 -1
  539. package/studio/web/assets/{dagre-3AP2YEHR-DpUXBh63.js → dagre-GXQ25YYZ-DShnGpGo.js} +1 -1
  540. package/studio/web/assets/{diagram-S7CK7UJ4-BuymVFZT.js → diagram-S7CK7UJ4-aoCVTtcy.js} +1 -1
  541. package/studio/web/assets/{diagram-UQ7AKVKN-CyP148RM.js → diagram-UQ7AKVKN-DglXtQ6x.js} +1 -1
  542. package/studio/web/assets/{diagram-VSXAHHWV-CoCAg3M9.js → diagram-VSXAHHWV-fhEdmkwM.js} +1 -1
  543. package/studio/web/assets/{diagram-VX7I27RA-BpOqCFca.js → diagram-VX7I27RA-DccVJet6.js} +1 -1
  544. package/studio/web/assets/{diagram-Z3DM3KII-Bfpw7Vbj.js → diagram-Z3DM3KII-D-RyJJb7.js} +1 -1
  545. package/studio/web/assets/{dialog-CBf0Mr1d.js → dialog-Cn2uWgD4.js} +1 -1
  546. package/studio/web/assets/{dist-lkA3O3eM.js → dist-DP8UjMB_.js} +1 -1
  547. package/studio/web/assets/{download-BtTOBem-.js → download-B5T5r7ss.js} +1 -1
  548. package/studio/web/assets/{ebnfDiagram-PWID7BFC-DS_6aWqL.js → ebnfDiagram-PWID7BFC-DJGpIpz_.js} +1 -1
  549. package/studio/web/assets/{editor-D8oDeCTL.js → editor-DNcRHK54.js} +1 -1
  550. package/studio/web/assets/{erDiagram-SSCWMZ5O-DJNk6Fgw.js → erDiagram-RLTQ6QDP-CIfNlgkC.js} +1 -1
  551. package/studio/web/assets/eventmodeling-NTZA5JFV-CLxnp2CR.js +1 -0
  552. package/studio/web/assets/flowDiagram-HODETNUW-BIbhmz9f.js +1 -0
  553. package/studio/web/assets/{ganttDiagram-EL5Y4UJY-2pOExxMY.js → ganttDiagram-EL5Y4UJY-BxToTzzD.js} +1 -1
  554. package/studio/web/assets/{getAnalytics-D4LKGeVy.js → getAnalytics-GHK8ORfM.js} +1 -1
  555. package/studio/web/assets/{gitGraph-4MIJSDKK-CH5ZxwzF.js → gitGraph-4MIJSDKK-D2s2w8lE.js} +1 -1
  556. package/studio/web/assets/{gitGraphDiagram-WWUBYQGX-DVIsIhbO.js → gitGraphDiagram-WWUBYQGX-Dwntd4-x.js} +1 -1
  557. package/studio/web/assets/{html-u3vOg7LJ.js → html-Bdssedlg.js} +1 -1
  558. package/studio/web/assets/{htmlMode-DyO31v-P.js → htmlMode-CoDlJ3fw.js} +1 -1
  559. package/studio/web/assets/{index-Cxj2_wsl.css → index-7RMEgVG1.css} +1 -1
  560. package/studio/web/assets/index-D6sxmFLR.js +824 -0
  561. package/studio/web/assets/index.lazy-tmU5BS8s.js +2 -0
  562. package/studio/web/assets/{info-A6RAGUB7-CPQfTnaG.js → info-A6RAGUB7-DYjkvb0C.js} +1 -1
  563. package/studio/web/assets/{infoDiagram-RXCK75RN-DlwLYlwm.js → infoDiagram-27XIBGKW-Bnp1FJE5.js} +1 -1
  564. package/studio/web/assets/{ishikawaDiagram-5VMMS53U-BRXRp29U.js → ishikawaDiagram-5VMMS53U-D9Xh2r6X.js} +1 -1
  565. package/studio/web/assets/{javascript-CUvxOyTC.js → javascript-B8meVSTH.js} +1 -1
  566. package/studio/web/assets/{journeyDiagram-EYS64GPL-B0ou8k0n.js → journeyDiagram-3NMN7TZE-CokIi6ll.js} +2 -2
  567. package/studio/web/assets/{jsonMode-f_IwbF3D.js → jsonMode-DpIPd35T.js} +1 -1
  568. package/studio/web/assets/{kanban-definition-3QL26DDD-uYg7iYzp.js → kanban-definition-UXKFOSKX-CukSFJfX.js} +1 -1
  569. package/studio/web/assets/{languageServices-DXtZ6rEF.js → languageServices-C_5FMJzQ.js} +1 -1
  570. package/studio/web/assets/{lspLanguageFeatures-B4pCF1zO.js → lspLanguageFeatures-BIzNBzPK.js} +1 -1
  571. package/studio/web/assets/{mermaid-parser.core-Ck-fC8b7.js → mermaid-parser.core-BlEsOWNO.js} +3 -3
  572. package/studio/web/assets/{mermaid.core-CP8aNNYm.js → mermaid.core-BlkGaMIH.js} +5 -5
  573. package/studio/web/assets/{mindmap-definition-FBJOCRG2-CgTZ-rit.js → mindmap-definition-YA3MSWOX-IprMc_0j.js} +1 -1
  574. package/studio/web/assets/{notifications-D3tIQ4sg.js → notifications-CQf18QKb.js} +1 -1
  575. package/studio/web/assets/notifications-CvZivSbh.js +1 -0
  576. package/studio/web/assets/{packet-AYTQ26CC-DEyoPtPb.js → packet-AYTQ26CC-Bi3V04Zi.js} +1 -1
  577. package/studio/web/assets/{pegDiagram-XKGWAZYB-DrD-7sD9.js → pegDiagram-XKGWAZYB-BNuPDLZY.js} +1 -1
  578. package/studio/web/assets/{pie-WAS4IAKB-wjj-EI1d.js → pie-WAS4IAKB-_6DoDbng.js} +1 -1
  579. package/studio/web/assets/{pieDiagram-E7YTZNPT-GntqDCzv.js → pieDiagram-E7YTZNPT-DqNb6Ht2.js} +1 -1
  580. package/studio/web/assets/{profile-DZWU7MgT.js → profile-DdOwtntb.js} +1 -1
  581. package/studio/web/assets/{quadrantDiagram-AXDQQJYC-0UeqXQGd.js → quadrantDiagram-AXDQQJYC-BGH9E2YR.js} +1 -1
  582. package/studio/web/assets/{radar-RG4KPBEZ-DFSA5h7k.js → radar-RG4KPBEZ-DDdVczcL.js} +1 -1
  583. package/studio/web/assets/{railroad-74A4TZTK-CaOUG9wR.js → railroad-74A4TZTK-BJUP4Jds.js} +1 -1
  584. package/studio/web/assets/railroad-abnf-HS5TGJTU-Bm3L1L0h.js +1 -0
  585. package/studio/web/assets/railroad-ebnf-LZEXJU2U-CKzLGlkw.js +1 -0
  586. package/studio/web/assets/railroad-peg-WCYAUIDC-S8xLjslx.js +1 -0
  587. package/studio/web/assets/{railroadDiagram-O6MQD6OU-yHUELZaV.js → railroadDiagram-O6MQD6OU-DGTPh2KZ.js} +1 -1
  588. package/studio/web/assets/{regions-CkyurXzE.js → regions-n69fwagr.js} +1 -1
  589. package/studio/web/assets/{register-BUyhWjBO.js → register-PfWTCXWB.js} +3 -3
  590. package/studio/web/assets/{requirementDiagram-EFPCY7ZU-DNEGFjuW.js → requirementDiagram-BXWQKSXE-BJnO6uLz.js} +1 -1
  591. package/studio/web/assets/{sankeyDiagram-P5KCCOFB-DpyAmSVR.js → sankeyDiagram-P5KCCOFB-0vSOdymH.js} +1 -1
  592. package/studio/web/assets/{sequenceDiagram-WJ2MYXX4-TyaT7xNk.js → sequenceDiagram-WJ2MYXX4-hETizDWE.js} +1 -1
  593. package/studio/web/assets/{setComponentFile-CeyKSZAa.js → setComponentFile-Bg6O7X0S.js} +1 -1
  594. package/studio/web/assets/{setup-D2kn7cAA.js → setup-CUx_aUDl.js} +2 -2
  595. package/studio/web/assets/{stateDiagram-HBIQ2CUA-CeEdTArZ.js → stateDiagram-D77RDMKH-CdYQ_KtC.js} +1 -1
  596. package/studio/web/assets/stateDiagram-v2-MP3YSRHH-CdKuzQMT.js +1 -0
  597. package/studio/web/assets/status-D7BVKqX9.js +61 -0
  598. package/studio/web/assets/{swimlanes-XN3QIQJK-B54FmF46.js → swimlanes-42K2YHIH-B8cHIpU4.js} +1 -1
  599. package/studio/web/assets/swimlanesDiagram-VR7AAH4N-DmOSJwaH.js +8 -0
  600. package/studio/web/assets/{tabs-B_G5zscN.js → tabs-BrHu7gJi.js} +1 -1
  601. package/studio/web/assets/{timeline-definition-24CTP7MA-D-a9ujbo.js → timeline-definition-24CTP7MA-BJWYSXqF.js} +1 -1
  602. package/studio/web/assets/{toggleHighContrast-C0UW6rI2.js → toggleHighContrast-DBSyXzMr.js} +1 -1
  603. package/studio/web/assets/{treeView-Q6P3EWNA-CrW_6JnS.js → treeView-Q6P3EWNA-qxe_v6CQ.js} +1 -1
  604. package/studio/web/assets/{treemap-WGGIJYW6-BxyYLdP_.js → treemap-WGGIJYW6-dDo97XXF.js} +1 -1
  605. package/studio/web/assets/{tsMode-DBC0zmDx.js → tsMode-BByKCjBS.js} +1 -1
  606. package/studio/web/assets/{typescript-DApRQir3.js → typescript-DDLnLpw9.js} +1 -1
  607. package/studio/web/assets/{useEntityRestURL-DB6JStU1.js → useEntityRestURL-31CHGaHk.js} +1 -1
  608. package/studio/web/assets/{useLocalStorage-Dtj1QS8_.js → useLocalStorage-BqMR3D8_.js} +1 -1
  609. package/studio/web/assets/vendor-core-c2JRRJpV.js +58 -0
  610. package/studio/web/assets/vendor-datadog-CLUcJXOo.js +6 -0
  611. package/studio/web/assets/{vendor-react-Dyj4O3HE.js → vendor-react-CJV_K1u4.js} +1 -1
  612. package/studio/web/assets/vendor-tanstack-DxzraizX.js +1 -0
  613. package/studio/web/assets/{vendor-ui-vhu-UHhF.js → vendor-ui-BUjK0h8a.js} +2 -2
  614. package/studio/web/assets/{vennDiagram-4TSXK5OY-Cy7s7Mpy.js → vennDiagram-4TSXK5OY-A3i-lCdl.js} +1 -1
  615. package/studio/web/assets/{wardley-WFR3VGLG-BeBL35g2.js → wardley-WFR3VGLG-B0ik-_6g.js} +1 -1
  616. package/studio/web/assets/{wardleyDiagram-VM6X3IG4-BylmIGSg.js → wardleyDiagram-VM6X3IG4-CjrkKWUR.js} +1 -1
  617. package/studio/web/assets/{workers-C0bFIedw.js → workers-pR3jRY9D.js} +1 -1
  618. package/studio/web/assets/x-DIzaLEdK.js +1 -0
  619. package/studio/web/assets/{xml-HWd01lU-.js → xml-2iRnMhQO.js} +1 -1
  620. package/studio/web/assets/{xychartDiagram-S5SC5T6Z-CoKALMXr.js → xychartDiagram-S5SC5T6Z-Biok4GYV.js} +1 -1
  621. package/studio/web/assets/{yaml-CIH0Nt-h.js → yaml-Bf92gJpd.js} +1 -1
  622. package/studio/web/index.html +14 -14
  623. package/upgrade/directives/5-3-0.ts +132 -0
  624. package/upgrade/directives/directivesController.ts +2 -1
  625. package/utility/OperationFunctionCaller.ts +2 -1
  626. package/utility/common_utils.ts +30 -5
  627. package/utility/componentNames.ts +12 -0
  628. package/utility/environment/environmentManager.ts +3 -7
  629. package/utility/environment/systemInformation.ts +7 -0
  630. package/utility/errors/commonErrors.ts +4 -0
  631. package/utility/errors/hdbError.ts +29 -0
  632. package/utility/hdbTerms.ts +17 -0
  633. package/utility/logging/harper_logger.ts +87 -14
  634. package/utility/logging/logRotator.ts +72 -45
  635. package/utility/nodeIdentity.ts +45 -0
  636. package/utility/npmUtilities.ts +10 -8
  637. package/utility/operationPermissions.ts +18 -1
  638. package/utility/operation_authorization.ts +231 -42
  639. package/utility/watchPath.ts +63 -0
  640. package/utility/watcherFallback.ts +270 -0
  641. package/validation/configValidator.ts +209 -72
  642. package/validation/installValidator.ts +15 -0
  643. package/validation/validationWrapper.ts +18 -4
  644. package/studio/web/assets/Chat-JpO8EtUu.js +0 -2067
  645. package/studio/web/assets/FloatingChat-Bcj3xSZu.js +0 -23
  646. package/studio/web/assets/applications-ByqLRKyZ.js +0 -296
  647. package/studio/web/assets/architecture-7GRP2DOG-DNdx5tEU.js +0 -1
  648. package/studio/web/assets/authStore-qKmCZcaf.js +0 -3
  649. package/studio/web/assets/channel-DtCV8PTL.js +0 -1
  650. package/studio/web/assets/chunk-R7TYR2AO-Irip67yr.js +0 -10
  651. package/studio/web/assets/classDiagram-DTDB5LWJ-DbO_dCNE.js +0 -1
  652. package/studio/web/assets/classDiagram-v2-JRS7N3AN-DbO_dCNE.js +0 -1
  653. package/studio/web/assets/eventmodeling-NTZA5JFV-5jbe4A5P.js +0 -1
  654. package/studio/web/assets/flowDiagram-A5DVABFB-Dp9Ezlow.js +0 -1
  655. package/studio/web/assets/index-aSt5tY-L.js +0 -824
  656. package/studio/web/assets/index.lazy-B9jiPwT8.js +0 -2
  657. package/studio/web/assets/notifications-CUoYgU98.js +0 -1
  658. package/studio/web/assets/railroad-abnf-HS5TGJTU-Bc0Qi0WH.js +0 -1
  659. package/studio/web/assets/railroad-ebnf-LZEXJU2U-G8rVVZ2C.js +0 -1
  660. package/studio/web/assets/railroad-peg-WCYAUIDC-CrehKBhC.js +0 -1
  661. package/studio/web/assets/stateDiagram-v2-4QOOHH4V-D4tuw9Su.js +0 -1
  662. package/studio/web/assets/status-D7Xn5ePA.js +0 -61
  663. package/studio/web/assets/swimlanesDiagram-VK2B7HYN-XOhmNEvq.js +0 -8
  664. package/studio/web/assets/vendor-core-RCcadM3e.js +0 -73
  665. package/studio/web/assets/vendor-datadog-BRv-mOv1.js +0 -6
  666. package/studio/web/assets/vendor-tanstack-BiFWSB3W.js +0 -1
  667. package/studio/web/assets/x-B9o9hsep.js +0 -1
  668. /package/studio/web/assets/{sizeCapture-X5ZJPWSS-B0uUizjq.js → sizeCapture-INFHLROL-B0uUizjq.js} +0 -0
@@ -3,8 +3,13 @@ import { getConfigObj, getConfigValue, getConfigPath } from '../config/configUti
3
3
  import { CONFIG_PARAMS } from '../utility/hdbTerms.ts';
4
4
  import logger, { errorForLog } from '../utility/logging/harper_logger.ts';
5
5
  import { broadcastDeployStart, broadcastDeployEnd } from './deployLifecycle.ts';
6
- import { withComponentPreparationLock } from './componentPreparationLock.ts';
7
- import { isThreadRunning, registerProcessGroup, unregisterProcessGroup } from '../server/threads/manageThreads.js';
6
+ import { ComponentPreparationLockTimeoutError, withComponentPreparationLock } from './componentPreparationLock.ts';
7
+ import {
8
+ isThreadRunning,
9
+ isProcessGroupAlive,
10
+ registerProcessGroup,
11
+ unregisterProcessGroup,
12
+ } from '../server/threads/manageThreads.js';
8
13
  import type { CredentialReference, ResolvedCredential, ResolvedRegistryCredential } from './secretOperations.ts';
9
14
  import {
10
15
  GIT_CREDENTIAL_SOCKET_ENV,
@@ -15,16 +20,21 @@ import {
15
20
  import { getSecretDecryptor } from '../resources/secretDecryptor.ts';
16
21
  import { ENV_ENCRYPTED_PREFIX } from '../utility/envFile.ts';
17
22
 
18
- import { basename, dirname, extname, join } from 'node:path';
23
+ import { basename, dirname, extname, isAbsolute, join, relative, win32 } from 'node:path';
19
24
  import {
20
25
  access,
26
+ chmod,
21
27
  constants,
22
- cp,
28
+ lstat,
23
29
  mkdir,
30
+ link,
24
31
  mkdtemp,
32
+ open,
25
33
  readdir,
26
34
  readFile,
35
+ readlink,
27
36
  rename,
37
+ rmdir,
28
38
  rm,
29
39
  stat,
30
40
  symlink,
@@ -33,7 +43,7 @@ import {
33
43
  import { spawn, type ChildProcess } from 'node:child_process';
34
44
  import { tmpdir } from 'node:os';
35
45
  import { randomUUID } from 'node:crypto';
36
- import { createReadStream, existsSync } from 'node:fs';
46
+ import { chmodSync, createReadStream, existsSync, lstatSync, renameSync } from 'node:fs';
37
47
  import { Readable } from 'node:stream';
38
48
  import { pipeline } from 'node:stream/promises';
39
49
  import { StringDecoder } from 'node:string_decoder';
@@ -55,6 +65,25 @@ interface ApplicationConfig {
55
65
  // Recorded by deploy_component so every (cold) install — reboot, new peer, rollback — re-resolves
56
66
  // the credential from the store rather than needing it re-supplied.
57
67
  credentials?: CredentialReference[];
68
+ /**
69
+ * Databases this application gets a private fork of (harper#642), declared HERE — on the
70
+ * application's root-config entry, alongside `host` and `urlPath` — because which databases an
71
+ * application forks is a deployment decision, not something the application checks in. Declaring
72
+ * it in the application's own config.yaml is refused rather than ignored.
73
+ *
74
+ * `true` forks every database on the instance except `system` -- a snapshot of what exists when
75
+ * this application loads, not a standing subscription: a database created afterward is not
76
+ * retroactively branched.
77
+ *
78
+ * The fork is durable: it lives at a path derived from the application and database names, is
79
+ * adopted again on restart, and is invisible to other applications.
80
+ *
81
+ * The application must reach its data through `import { databases } from 'harper'`. The bare
82
+ * `databases`/`tables` globals are shared process-wide by the default `vm-current-context` loader
83
+ * and cannot be scoped, so an application that uses them reads and writes the BASE — silently.
84
+ * Per-application globals are a property of thread-level isolation, not of branching.
85
+ */
86
+ branchedDatabases?: string[] | true;
58
87
  // an application config can have other arbitrary properties
59
88
  [key: string]: unknown;
60
89
  }
@@ -99,6 +128,12 @@ export class InvalidCredentialsPropertyError extends TypeError {
99
128
  }
100
129
  }
101
130
 
131
+ export class InvalidBranchedDatabasesError extends TypeError {
132
+ constructor(applicationName: string, detail: string) {
133
+ super(`Invalid 'branchedDatabases' for application ${applicationName}: ${detail}`);
134
+ }
135
+ }
136
+
102
137
  export class InvalidCredentialEntryError extends TypeError {
103
138
  constructor(applicationName: string) {
104
139
  super(
@@ -112,6 +147,18 @@ export function assertApplicationConfig(
112
147
  applicationName: string,
113
148
  applicationConfig: Record<'package', unknown> & Record<string, unknown>
114
149
  ): asserts applicationConfig is ApplicationConfig {
150
+ // The deploy staging directory holds a candidate tree under the component's own name beside dot-prefixed
151
+ // control files, so a dot-prefixed component name collides with one of them — and an application named
152
+ // `.activation.json` puts its tree on the journal path, where the journal write takes EEXIST as "a retry
153
+ // of this activation" and the swap proceeds with no journal at all. Rejected HERE rather than tolerated
154
+ // downstream: nothing else validates a root-config application key.
155
+ if (!isJoinableComponentName(applicationName)) {
156
+ throw new Error(
157
+ `Invalid application name '${applicationName}': it must be a single path segment and must not begin ` +
158
+ `with a dot, which is reserved for Harper's own deploy control files`
159
+ );
160
+ }
161
+
115
162
  if (typeof applicationConfig.package !== 'string') {
116
163
  throw new InvalidPackageIdentifierError(applicationName, applicationConfig.package);
117
164
  }
@@ -170,6 +217,38 @@ export function assertApplicationConfig(
170
217
  }
171
218
  }
172
219
  }
220
+ assertBranchedDatabases(applicationName, applicationConfig.branchedDatabases);
221
+ }
222
+
223
+ /**
224
+ * A branch that cannot be honoured fails the application's load: falling back would hand it the
225
+ * shared database it asked not to have, with no signal that it happened.
226
+ */
227
+ export function assertBranchedDatabases(applicationName: string, value: unknown): void {
228
+ if (value === undefined || value === true) return;
229
+ if (!Array.isArray(value)) {
230
+ throw new InvalidBranchedDatabasesError(applicationName, `expected an array or true, got ${typeof value}`);
231
+ }
232
+ const seen = new Set<string>();
233
+ for (const name of value) {
234
+ if (typeof name !== 'string' || name === '') {
235
+ throw new InvalidBranchedDatabasesError(applicationName, `expected database names, got ${typeof name}`);
236
+ }
237
+ // A branch is a directory named by this value; a separator or traversal segment would escape
238
+ // the reserved branch root.
239
+ if (name.includes('/') || name.includes('\\') || name === '.' || name === '..') {
240
+ throw new InvalidBranchedDatabasesError(applicationName, `'${name}' is not a usable database name`);
241
+ }
242
+ // `system` carries the instance's own catalog, users and jobs; a private fork of it would give
243
+ // the application a divergent view of the instance rather than of its data.
244
+ if (name === 'system') {
245
+ throw new InvalidBranchedDatabasesError(applicationName, `the 'system' database cannot be branched`);
246
+ }
247
+ if (seen.has(name)) {
248
+ throw new InvalidBranchedDatabasesError(applicationName, `'${name}' is listed more than once`);
249
+ }
250
+ seen.add(name);
251
+ }
173
252
  }
174
253
 
175
254
  /**
@@ -478,16 +557,51 @@ async function runNpmPack(
478
557
  // during a deploy swap (see extractApplication). The leading dot keeps
479
558
  // loadComponentDirectories from loading its contents as components.
480
559
  export const ASIDE_STAGING_DIR = '.deploy-aside';
560
+ // Hidden directory under the components root holding per-deployment candidate builds. A candidate is
561
+ // extracted, installed AND validated here, and only then renamed into the live path, so the previous
562
+ // version keeps serving through the slow, failure-prone work. Dot-prefixed so the three scans over the
563
+ // components root (componentLoader, componentEnvPrepass, resolvePreload) skip it.
564
+ export const DEPLOY_STAGING_DIR = '.deploy-staging';
565
+ const IN_PROGRESS_ASIDE_PREFIX = '.in-progress-';
566
+ const RETIRED_ASIDE_PREFIX = '.retired-';
567
+ const PRIOR_ABSENT_RECORD_SUFFIX = '-prior-absent';
481
568
  const DEFAULT_COMMAND_TIMEOUT_MS = 60 * 60 * 1000;
482
569
  const COMPONENT_PREPARATION_WAIT_MARGIN_MS = 30000;
570
+ const COMPONENT_RECOVERY_WAIT_TIMEOUT_MS = 30000;
571
+ const COMPONENT_RECOVERY_TRY_TIMEOUT_MS = 250;
572
+
573
+ /**
574
+ * Lock terms for the boot-time activation scan. It runs before every component load on every thread, so it
575
+ * probes rather than queues: the default is a two-hour wait that RENEWS while the holder is alive, which
576
+ * would park a respawning worker behind a deploy's `npm install` and load no components at all until it
577
+ * finished. A held lock means a live deploy, and a live deploy settles its own journal.
578
+ */
579
+ const RECOVERY_LOCK_WAIT = {
580
+ timeoutMs: COMPONENT_RECOVERY_TRY_TIMEOUT_MS,
581
+ renewTimeoutWhileOwnerAlive: false,
582
+ };
583
+ const COMPONENT_RECOVERY_LOCK_PURPOSE = 'component-recovery';
483
584
  const MAX_GIT_EXTRACTION_COMMANDS = 4;
484
585
  const MAX_INSTALL_COMMANDS = 2;
586
+ const PRODUCTION_DEPENDENCY_FIELDS = ['dependencies', 'optionalDependencies', 'peerDependencies'] as const;
587
+ const INSTALL_LIFECYCLE_SCRIPTS = new Set([
588
+ 'preinstall',
589
+ 'install',
590
+ 'postinstall',
591
+ 'prepublish',
592
+ 'preprepare',
593
+ 'prepare',
594
+ 'postprepare',
595
+ 'dependencies',
596
+ ]);
485
597
 
486
598
  type ExtractionTransaction = {
487
599
  commit(): Promise<void>;
488
600
  rollback(): Promise<void>;
489
601
  };
490
602
 
603
+ type ExtractionContext = Pick<Application, 'name' | 'dirPath' | 'logger'>;
604
+
491
605
  // The credential helper git executes for a private git-reference deploy. It ships alongside this
492
606
  // module (both in source and in dist), holds no secret, and is inert without a live session.
493
607
  export const GIT_CREDENTIAL_HELPER_PATH = join(__dirname, 'gitCredentialHelper.js');
@@ -508,6 +622,42 @@ type InstalledPackageMetadata = {
508
622
  hasInstallableDependencies: boolean;
509
623
  };
510
624
 
625
+ function dependencyFieldHasWork(packageJSON: any, field: string): boolean {
626
+ if (!packageJSON || typeof packageJSON !== 'object' || !Object.hasOwn(packageJSON, field)) return false;
627
+ const value = packageJSON[field];
628
+ return !value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length > 0;
629
+ }
630
+
631
+ export function packageHasProductionInstallWork(packageJSON: any): boolean {
632
+ if (packageJSON === undefined) return false;
633
+ if (!packageJSON || typeof packageJSON !== 'object' || Array.isArray(packageJSON)) return true;
634
+ if (PRODUCTION_DEPENDENCY_FIELDS.some((field) => dependencyFieldHasWork(packageJSON, field))) return true;
635
+ if (!Object.hasOwn(packageJSON, 'workspaces')) return false;
636
+ const workspaces = packageJSON.workspaces;
637
+ if (Array.isArray(workspaces)) return workspaces.length > 0;
638
+ if (!workspaces || typeof workspaces !== 'object' || !Object.hasOwn(workspaces, 'packages')) return true;
639
+ return !Array.isArray(workspaces.packages) || workspaces.packages.length > 0;
640
+ }
641
+
642
+ function packageHasExplicitNonNpmManager(packageJSON: any): boolean {
643
+ const packageManager = packageJSON?.devEngines?.packageManager;
644
+ return !!packageManager && packageManager.name !== 'npm';
645
+ }
646
+
647
+ export function packageHasAutomaticInstallWork(packageJSON: any): boolean {
648
+ return packageHasProductionInstallWork(packageJSON) || packageHasExplicitNonNpmManager(packageJSON);
649
+ }
650
+
651
+ function packageHasAllowedInstallLifecycleWork(packageJSON: any): boolean {
652
+ if (!packageJSON || typeof packageJSON !== 'object' || !Object.hasOwn(packageJSON, 'scripts')) return false;
653
+ const scripts = packageJSON.scripts;
654
+ if (!scripts || typeof scripts !== 'object' || Array.isArray(scripts)) return true;
655
+ return [...INSTALL_LIFECYCLE_SCRIPTS].some((name) => {
656
+ if (!Object.hasOwn(scripts, name)) return false;
657
+ return typeof scripts[name] !== 'string' || scripts[name].trim().length > 0;
658
+ });
659
+ }
660
+
511
661
  export async function readInstalledPackageMetadata(directory: string): Promise<InstalledPackageMetadata> {
512
662
  const files = new Map<string, Buffer>();
513
663
  let readable = true;
@@ -538,17 +688,7 @@ export async function readInstalledPackageMetadata(directory: string): Promise<I
538
688
  files,
539
689
  readable,
540
690
  hasLockfile: PACKAGE_LOCK_FILES.some((filename) => files.has(filename)),
541
- hasInstallableDependencies: ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'].some(
542
- (field) => {
543
- const dependencies = packageJSON?.[field];
544
- return (
545
- typeof dependencies === 'object' &&
546
- dependencies !== null &&
547
- !Array.isArray(dependencies) &&
548
- Object.keys(dependencies).length > 0
549
- );
550
- }
551
- ),
691
+ hasInstallableDependencies: packageHasAutomaticInstallWork(packageJSON),
552
692
  };
553
693
  }
554
694
 
@@ -584,30 +724,17 @@ function canonicalizeJSON(value: any): any {
584
724
  }
585
725
 
586
726
  /**
587
- * Extract an application given payload (content of the application) or package (npm-compatible identifier to the application).
588
- *
589
- * Only one of `application.payload` or `application.package` should be specified; otherwise, an error is thrown.
590
- *
591
- * Writes the application to the configured components root directory using the `application.name` and overwrites any existing directory.
592
- *
593
- * This method may be called from any Harper thread. Same-component calls are serialized across
594
- * threads by the preparation lock below.
727
+ * Either a tarball to extract, or — for `file:<directory>` — an instruction to link that directory. The link
728
+ * case is a RESULT rather than an action so the caller decides where it lands: a candidate build must link
729
+ * at the candidate path, or the deploy is published without validation.
595
730
  */
596
- export async function extractApplication(
597
- application: Application,
598
- deferCommit = false
599
- ): Promise<ExtractionTransaction | undefined> {
600
- // Can't specify neither
601
- if (!application.payload && !application.packageIdentifier) {
602
- throw new Error('Either payload or package must be provided');
603
- }
731
+ type ResolvedTarball =
732
+ | { kind: 'tarball'; tarball: Readable; tarballPath?: string; shouldDeleteTarball: boolean }
733
+ | { kind: 'link'; sourceDirPath: string };
604
734
 
605
- // Can't specify both
606
- if (application.payload && application.packageIdentifier) {
607
- throw new Error('Both payload and package cannot be provided');
608
- }
609
- // Resolve the tarball from the input
610
- let tarballPath: string;
735
+ /** Resolve `payload` or `package` into a tarball stream. Touches neither the live tree nor staging. */
736
+ async function resolveApplicationTarball(application: Application): Promise<ResolvedTarball> {
737
+ let tarballPath: string | undefined;
611
738
  let tarball: Readable;
612
739
  let shouldDeleteTarball = false;
613
740
 
@@ -626,6 +753,8 @@ export async function extractApplication(
626
753
  } else {
627
754
  // Given a package, there are a a couple options
628
755
  const parentDirPath = dirname(application.dirPath);
756
+ let packageIdentifierForPack = application.packageIdentifier;
757
+ let packageNeedsPacking = true;
629
758
 
630
759
  // If the package identifier is a file path we need to check if its a tarball or a directory
631
760
  if (application.packageIdentifier.startsWith('file:')) {
@@ -635,22 +764,29 @@ export async function extractApplication(
635
764
  const stats = await stat(packagePath);
636
765
 
637
766
  if (stats.isDirectory()) {
638
- // If its a directory, symlink
639
- await symlink(packagePath, application.dirPath, 'dir');
640
- // And return early since we're done; no extraction needed
641
- return;
642
- }
767
+ if (!application.packLocalDirectory) {
768
+ // Reported, not performed — the caller decides where the link goes, so a candidate build
769
+ // links at the candidate path rather than onto the live one.
770
+ return { kind: 'link', sourceDirPath: packagePath };
771
+ }
772
+ // Bare absolute Windows directory inputs historically materialize a copy through npm pack;
773
+ // explicit file: and relative directories remain live links.
774
+ packageIdentifierForPack = packagePath;
775
+ application.logger.debug?.('Packaging local component directory instead of linking it on Windows');
776
+ } else {
777
+ if (!stats.isFile()) {
778
+ throw new Error(`File path specified in package identifier is not a file or directory: ${packagePath}`);
779
+ }
643
780
 
644
- if (!stats.isFile()) {
645
- throw new Error(`File path specified in package identifier is not a file or directory: ${packagePath}`);
781
+ // If its a file, we assume it can be unzipped and extracted.
782
+ // We are using maybe-gunzip to handle both gzipped and non-gzipped tarballs
783
+ // And then we are happy to let the `tar-fs` library handle the extraction.
784
+ // Maybe worth adding some detection or at least some error handling if that step below fails.
785
+ tarballPath = packagePath;
786
+ tarball = createReadStream(tarballPath);
787
+ packageNeedsPacking = false;
788
+ application.logger.debug?.('Using local component archive directly without npm pack');
646
789
  }
647
-
648
- // If its a file, we assume it can be unzipped and extracted.
649
- // We are using maybe-gunzip to handle both gzipped and non-gzipped tarballs
650
- // And then we are happy to let the `tar-fs` library handle the extraction.
651
- // Maybe worth adding some detection or at least some error handling if that step below fails.
652
- tarballPath = packagePath;
653
- tarball = createReadStream(tarballPath);
654
790
  } catch (err) {
655
791
  if (err.code === 'ENOENT') {
656
792
  throw new Error(`File path specified in package identifier does not exist: ${packagePath}`);
@@ -658,7 +794,8 @@ export async function extractApplication(
658
794
  throw err;
659
795
  }
660
796
  }
661
- } else {
797
+ }
798
+ if (packageNeedsPacking) {
662
799
  // `npm pack --json` writes a JSON array describing the packed tarball(s). This is also the
663
800
  // spawn that clones a git-reference package, so it is the only one given the git credential
664
801
  // environment.
@@ -680,195 +817,2156 @@ export async function extractApplication(
680
817
  // packGitReferenceWithoutScripts), which is exactly what Node 22's bundled npm ships. For a
681
818
  // recognized git-reference identifier, clone and pack it ourselves with scripts stripped
682
819
  // instead, sidestepping that npm code path entirely.
683
- const gitRef = allowScripts ? null : parseGitReference(application.packageIdentifier);
820
+ const gitRef = allowScripts ? null : parseGitReference(packageIdentifierForPack);
821
+
822
+ if (!allowScripts && !gitRef && looksLikeGitReference(packageIdentifierForPack)) {
823
+ // Recognized as git, but a form the reclone-and-strip-scripts path above can't safely
824
+ // handle (a `#path:` committish, or a hosted shorthand other than a plain `owner/repo`) —
825
+ // fail loudly rather than silently falling through to the unreliable `npm pack
826
+ // --ignore-scripts` below.
827
+ throw new Error(
828
+ `Cannot deploy git-reference package '${packageIdentifierForPack}' with install scripts disallowed: this identifier's form (e.g. a '#path:' committish, or a hosted shorthand other than a plain 'owner/repo') isn't one this repo's script-suppression handling supports. Set install.allowInstallScripts to true, or use a plain git URL with a branch/tag/commit committish instead.`
829
+ );
830
+ }
831
+
832
+ if (gitRef) {
833
+ tarballPath = await packGitReferenceWithoutScripts(application, gitRef, parentDirPath);
834
+ } else {
835
+ const packArgs = ['pack', '--json', packageIdentifierForPack];
836
+ if (!allowScripts) {
837
+ packArgs.push('--ignore-scripts');
838
+ } else if (application.gitCredentialEnv) {
839
+ application.logger.warn(
840
+ `Deploying ${application.name} from a git reference with install scripts enabled: the repository's ` +
841
+ `prepare/build scripts and its dependencies' install scripts run on this node during the clone and ` +
842
+ `can read the git credential. Unset install_allow_scripts to keep the credential out of their reach.`
843
+ );
844
+ }
845
+ tarballPath = await runNpmPack(application, packArgs, parentDirPath, application.gitCredentialEnv);
846
+ }
847
+ shouldDeleteTarball = true;
848
+ tarball = createReadStream(tarballPath);
849
+ }
850
+ }
851
+
852
+ return { kind: 'tarball', tarball, tarballPath, shouldDeleteTarball };
853
+ }
854
+
855
+ /**
856
+ * Extract a tarball into `targetDirPath`, flattening the single wrapping directory npm pack produces.
857
+ * `scratchDirPath` must be on the same filesystem as the target: the flatten is done by renaming the
858
+ * wrapper out and back rather than copying, so it stays atomic per entry. Windows moves the children
859
+ * individually because renaming a directory over its own parent's path fails there.
860
+ */
861
+ async function extractTarballInto(
862
+ tarball: Readable,
863
+ targetDirPath: string,
864
+ scratchDirPath: string
865
+ ): Promise<string | undefined> {
866
+ await mkdir(targetDirPath, { recursive: true });
867
+ await pipeline(tarball, gunzip(), extract(targetDirPath));
868
+
869
+ const extracted = await readdir(targetDirPath, { withFileTypes: true });
870
+ if (extracted.length === 1 && extracted[0].isDirectory()) {
871
+ const topLevelDirPath = join(targetDirPath, extracted[0].name);
872
+ if (process.platform === 'win32') {
873
+ for (const childName of await readdir(topLevelDirPath)) {
874
+ await rename(join(topLevelDirPath, childName), join(targetDirPath, childName));
875
+ }
876
+ await rmdir(topLevelDirPath);
877
+ } else {
878
+ const tempDirPath = join(scratchDirPath, `.normalize-${process.pid}-${Date.now()}-${randomUUID()}`);
879
+ await rename(topLevelDirPath, tempDirPath);
880
+ await rmdir(targetDirPath);
881
+ await rename(tempDirPath, targetDirPath);
882
+ return tempDirPath;
883
+ }
884
+ }
885
+ return undefined;
886
+ }
887
+
888
+ /**
889
+ * Extract an application given payload (content of the application) or package (npm-compatible identifier to the application).
890
+ *
891
+ * Only one of `application.payload` or `application.package` should be specified; otherwise, an error is thrown.
892
+ *
893
+ * Writes the application to the configured components root directory using the `application.name` and overwrites any existing directory.
894
+ *
895
+ * This method may be called from any Harper thread. Same-component calls are serialized across
896
+ * threads by the preparation lock below.
897
+ */
898
+ export async function extractApplication(
899
+ application: Application,
900
+ deferCommit = false
901
+ ): Promise<ExtractionTransaction | undefined> {
902
+ // Can't specify neither
903
+ if (!application.payload && !application.packageIdentifier) {
904
+ throw new Error('Either payload or package must be provided');
905
+ }
906
+
907
+ // Can't specify both
908
+ if (application.payload && application.packageIdentifier) {
909
+ throw new Error('Both payload and package cannot be provided');
910
+ }
911
+ // Resolve the tarball from the input
912
+ const resolved = await resolveApplicationTarball(application);
913
+ if (resolved.kind === 'link') {
914
+ // Unchanged behavior for this path: a `file:` directory is linked in place, no extraction.
915
+ await symlink(resolved.sourceDirPath, application.dirPath, 'dir');
916
+ return;
917
+ }
918
+ const { tarball, tarballPath, shouldDeleteTarball } = resolved;
919
+ // Replace any existing component directory atomically instead of clearing it in
920
+ // place. A previous version's worker can still be running and actively writing
921
+ // into this directory — e.g. a live Next.js app writing into `.next/cache` — and
922
+ // an in-place recursive rm races that writer: rm empties `.next`, then its leaf
923
+ // `rmdir('.next')` fails with ENOTEMPTY because the worker just re-created a cache
924
+ // entry. (`force: true` only suppresses ENOENT; ENOTEMPTY is not retried unless
925
+ // `maxRetries` is set, and a continuously-writing app would outlast retries
926
+ // anyway.) Renaming the old directory aside is atomic and immune to the race: the
927
+ // still-running worker keeps writing into the renamed inode harmlessly until it's
928
+ // replaced on restart. The aside remains the rollback/recovery record until commit
929
+ // marks it retired and cleanup removes it.
930
+ //
931
+ // The aside lives under a hidden, component-scoped staging directory inside the
932
+ // components root: same filesystem as the source so the rename stays atomic, the
933
+ // leading dot keeps loadComponentDirectories from picking it up as a phantom
934
+ // component, and the per-component path means a sibling component never collides
935
+ // with (or sweeps) another's aside.
936
+ const asideStagingDir = extractionStagingDirectory(application.dirPath);
937
+ const transactionPaths = new Set<string>();
938
+ let asidePath: string | undefined;
939
+ let recoveryRecordPath: string;
940
+ try {
941
+ await ensureExtractionStagingDirectory(asideStagingDir);
942
+ await recoverOrCleanupStaleExtractionPaths(application, asideStagingDir);
943
+ let componentExists = true;
944
+ try {
945
+ await lstat(application.dirPath);
946
+ } catch (error) {
947
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
948
+ componentExists = false;
949
+ }
950
+ if (componentExists) {
951
+ await ensureExtractionStagingDirectory(asideStagingDir);
952
+ asidePath = join(asideStagingDir, `${IN_PROGRESS_ASIDE_PREFIX}${Date.now()}-${process.pid}-${randomUUID()}`);
953
+ await rename(application.dirPath, asidePath);
954
+ transactionPaths.add(asidePath);
955
+ recoveryRecordPath = asidePath;
956
+ } else {
957
+ await ensureExtractionStagingDirectory(asideStagingDir);
958
+ recoveryRecordPath = join(
959
+ asideStagingDir,
960
+ `${IN_PROGRESS_ASIDE_PREFIX}${Date.now()}-${process.pid}-${randomUUID()}${PRIOR_ABSENT_RECORD_SUFFIX}`
961
+ );
962
+ await writeFile(recoveryRecordPath, '', { flag: 'wx', mode: 0o600 });
963
+ transactionPaths.add(recoveryRecordPath);
964
+ }
965
+ if (asidePath) application.isNewComponent = false;
966
+
967
+ try {
968
+ // The scratch dir for the pack-wrapper flatten has to be on the component root's filesystem, and
969
+ // is a transaction path so a crash mid-flatten is cleaned up with the rest.
970
+ await ensureExtractionStagingDirectory(asideStagingDir);
971
+ const normalizeTempPath = await extractTarballInto(tarball, application.dirPath, asideStagingDir);
972
+ if (normalizeTempPath) transactionPaths.add(normalizeTempPath);
973
+ } catch (error) {
974
+ try {
975
+ await rollbackExtractedDirectory(application, asideStagingDir, asidePath, transactionPaths, false);
976
+ } catch (rollbackError) {
977
+ throw new AggregateError(
978
+ [error, rollbackError],
979
+ `Failed to extract ${application.name}: ${errorMessage(error)}; ` +
980
+ `also failed to restore its previous component directory: ${errorMessage(rollbackError)}`
981
+ );
982
+ }
983
+ throw error;
984
+ }
985
+ } finally {
986
+ if (!tarball.destroyed) tarball.destroy();
987
+ if (shouldDeleteTarball && tarballPath) {
988
+ await rm(tarballPath, { force: true }).catch((error) =>
989
+ application.logger.warn(`Failed to remove temporary package ${tarballPath}:`, error)
990
+ );
991
+ }
992
+ }
993
+
994
+ let settled = false;
995
+ const transaction: ExtractionTransaction = {
996
+ async commit() {
997
+ if (settled) return;
998
+ const retiredMarkerPath = await retireExtractionAside(recoveryRecordPath);
999
+ transactionPaths.add(retiredMarkerPath);
1000
+ settled = true;
1001
+ await cleanupExtractionPaths(application, asideStagingDir, transactionPaths);
1002
+ },
1003
+ async rollback() {
1004
+ if (settled) return;
1005
+ await rollbackExtractedDirectory(application, asideStagingDir, asidePath, transactionPaths, true);
1006
+ settled = true;
1007
+ },
1008
+ };
1009
+ if (deferCommit) return transaction;
1010
+ await transaction.commit();
1011
+ }
1012
+
1013
+ // Written into a candidate's deployment directory once its build and its validation have BOTH succeeded.
1014
+ // Roll-forward authority: recovery may activate an interrupted candidate only if this is present.
1015
+ // Every control file is dot-prefixed, and `isJoinableComponentName` rejects a leading dot: a deployment
1016
+ // directory holds the candidate tree under the COMPONENT'S name beside these, so an undotted name would
1017
+ // share that namespace. A component named `activation.json` would put its tree on the journal path, the
1018
+ // journal write would take EEXIST as "a retry of this activation", and the swap would proceed with no
1019
+ // journal to hold the legacy pass back; one named `unsettled` would make every settle throw on a
1020
+ // non-recursive `rm` of a directory. `assertApplicationConfig` rejects any name `isJoinableComponentName`
1021
+ // rejects, so the collision is unreachable from a root-config key as well as from a deploy.
1022
+ const CANDIDATE_COMPLETE_MARKER = '.complete';
1023
+ // Records activation intent beside the candidate, so recovery finishes or undoes the whole transaction —
1024
+ // tree and configuration together — instead of inferring intent from filesystem shape alone.
1025
+ const ACTIVATION_JOURNAL = '.activation.json';
1026
+ // The component this deployment directory belongs to, as plain text in its own file. Redundant with the
1027
+ // journal on purpose: after the swap the candidate has moved to the live path, so a journal that cannot be
1028
+ // parsed leaves nothing to infer the component from — and a failure keyed by deployment id fails NOTHING
1029
+ // closed, letting the component load over state nobody reconciled.
1030
+ const CANDIDATE_COMPONENT_FILE = '.component';
1031
+ // Written by main-thread recovery when it could not settle an activation whose journal is otherwise
1032
+ // well-formed. Workers cannot infer that case: a well-formed journal is indistinguishable from one belonging
1033
+ // to a deploy in flight, so without a record they would treat an unsettled component as healthy and load it.
1034
+ const UNSETTLED_MARKER = '.unsettled';
1035
+ const ACTIVATION_JOURNAL_VERSION = 1;
1036
+
1037
+ /**
1038
+ * Best-effort fsync of a directory. Best-effort by necessity — Node cannot fsync a directory on Windows —
1039
+ * which is why roll-forward requires journal + candidate + complete marker to all be observable: a lost
1040
+ * directory update then degrades to a roll back, never to a wrong decision. See DESIGN.md.
1041
+ */
1042
+ async function syncDirectory(dirPath: string): Promise<void> {
1043
+ let handle;
1044
+ try {
1045
+ handle = await open(dirPath, 'r');
1046
+ } catch (error) {
1047
+ // Same split as `sync` below and as the file path: Windows cannot open a directory for fsync at all,
1048
+ // and a directory removed by cleanup is not a fault either — but an EIO opening it is.
1049
+ if (!isUnsupportedSync(error) && (error as NodeJS.ErrnoException)?.code !== 'ENOENT') throw error;
1050
+ logger.trace?.(`Directory sync of ${dirPath} unavailable: ${errorMessage(error)}`);
1051
+ return;
1052
+ }
1053
+ try {
1054
+ await handle.sync();
1055
+ } catch (error) {
1056
+ // A platform that will not sync directories is tolerated, a storage failure is not: suppressing
1057
+ // EIO/ENOSPC here would let a lost directory entry look durable. The `finally` closes the handle.
1058
+ if (!isUnsupportedSync(error)) throw error;
1059
+ logger.trace?.(`Directory sync of ${dirPath} unsupported: ${errorMessage(error)}`);
1060
+ } finally {
1061
+ // Swallowed: this runs outside any compensation block, so a rejecting close would surface as an
1062
+ // activation failure for something already best-effort.
1063
+ await handle.close().catch((error) => logger.trace?.(`Closing ${dirPath} failed: ${errorMessage(error)}`));
1064
+ }
1065
+ }
1066
+
1067
+ /**
1068
+ * A rename changes an entry in BOTH parents, so both are synced: a surviving source entry reads as
1069
+ * "candidate still there" and would roll an already-completed activation forward twice.
1070
+ */
1071
+ async function syncRenameParents(fromPath: string, toPath: string): Promise<void> {
1072
+ const parents = new Set([dirname(fromPath), dirname(toPath)]);
1073
+ for (const parent of parents) await syncDirectory(parent);
1074
+ }
1075
+
1076
+ /**
1077
+ * Write a control file so its final name NEVER exists with partial contents. Opening the final path with
1078
+ * `wx` publishes the directory entry before anything is written, so a crash in between leaves a zero-byte
1079
+ * file — which for the journal means "unreadable", failing a component closed over a deploy that had not
1080
+ * actually started. Contents land in a temp name, are fsynced, and only then renamed into place.
1081
+ */
1082
+ async function writeControlFileDurably(filePath: string, contents: string): Promise<void> {
1083
+ const tempPath = `${filePath}.partial-${process.pid}-${randomUUID()}`;
1084
+ const handle = await open(tempPath, 'wx', 0o600);
1085
+ try {
1086
+ await handle.writeFile(contents, 'utf8');
1087
+ await handle.sync();
1088
+ } finally {
1089
+ await handle.close();
1090
+ }
1091
+ try {
1092
+ // `wx` on the temp name plus this rename keeps the EEXIST semantics callers rely on to detect a
1093
+ // retry of the same activation.
1094
+ await link(tempPath, filePath);
1095
+ } finally {
1096
+ await rm(tempPath, { force: true });
1097
+ }
1098
+ await syncDirectory(dirname(filePath));
1099
+ }
1100
+
1101
+ type ActivationJournal = {
1102
+ v: number;
1103
+ component: string;
1104
+ candidateId: string;
1105
+ };
1106
+
1107
+ function candidateCompleteMarkerPath(componentDirPath: string, deploymentId: string): string {
1108
+ return join(candidateDeploymentDirPath(componentDirPath, deploymentId), CANDIDATE_COMPLETE_MARKER);
1109
+ }
1110
+
1111
+ function candidateComponentFilePath(componentDirPath: string, deploymentId: string): string {
1112
+ return join(candidateDeploymentDirPath(componentDirPath, deploymentId), CANDIDATE_COMPONENT_FILE);
1113
+ }
1114
+
1115
+ function activationJournalPath(componentDirPath: string, deploymentId: string): string {
1116
+ return join(candidateDeploymentDirPath(componentDirPath, deploymentId), ACTIVATION_JOURNAL);
1117
+ }
1118
+
1119
+ /**
1120
+ * A component name safe to join onto the components root: no separator, no traversal, not dot-prefixed.
1121
+ * Applied to EVERY source of the name — the journal and the sidecar — because validating one and trusting
1122
+ * the other is how a corrupt record reaches an unrelated directory.
1123
+ */
1124
+ function isJoinableComponentName(name: unknown): name is string {
1125
+ return (
1126
+ typeof name === 'string' &&
1127
+ name.length > 0 &&
1128
+ name === basename(name) &&
1129
+ name !== '.' &&
1130
+ name !== '..' &&
1131
+ !name.startsWith('.')
1132
+ );
1133
+ }
1134
+
1135
+ /**
1136
+ * Read an activation journal. Absent is `undefined` — no activation was attempted. Anything else THROWS:
1137
+ * a truncated or unknown-version journal is an interrupted activation whose intent cannot be read, and
1138
+ * both guesses are destructive (publish a rejected release, or discard a good one), so the component is
1139
+ * failed closed instead.
1140
+ */
1141
+ async function readActivationJournal(journalPath: string): Promise<ActivationJournal | undefined> {
1142
+ let raw: string;
1143
+ try {
1144
+ raw = await readFile(journalPath, 'utf8');
1145
+ } catch (error) {
1146
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
1147
+ throw error;
1148
+ }
1149
+ let parsed: any;
1150
+ try {
1151
+ parsed = JSON.parse(raw);
1152
+ } catch (error) {
1153
+ throw new Error(`Activation journal ${journalPath} could not be parsed: ${errorMessage(error)}`);
1154
+ }
1155
+ if (parsed?.v !== ACTIVATION_JOURNAL_VERSION) {
1156
+ throw new Error(
1157
+ `Activation journal ${journalPath} has version ${JSON.stringify(parsed?.v)}, expected ${ACTIVATION_JOURNAL_VERSION}`
1158
+ );
1159
+ }
1160
+ if (!isJoinableComponentName(parsed.component) || typeof parsed.candidateId !== 'string') {
1161
+ throw new Error(`Activation journal ${journalPath} does not identify its component and candidate`);
1162
+ }
1163
+ // The journal must describe the directory it sits in. A syntactically valid journal naming someone
1164
+ // else's deployment would otherwise let recovery act on a component from the wrong record.
1165
+ if (parsed.candidateId !== basename(dirname(journalPath))) {
1166
+ throw new Error(
1167
+ `Activation journal ${journalPath} names candidate '${parsed.candidateId}', which is not its own deployment`
1168
+ );
1169
+ }
1170
+ return parsed as ActivationJournal;
1171
+ }
1172
+
1173
+ /** The deployment directory holding one candidate build: `<root>/.deploy-staging/<deploymentId>`. */
1174
+ function candidateDeploymentDirPath(componentDirPath: string, deploymentId: string): string {
1175
+ return join(dirname(componentDirPath), DEPLOY_STAGING_DIR, deploymentId);
1176
+ }
1177
+
1178
+ /** Where a candidate build lives: `<root>/.deploy-staging/<deploymentId>/<component>`. */
1179
+ export function candidateApplicationPath(componentDirPath: string, deploymentId: string): string {
1180
+ return join(candidateDeploymentDirPath(componentDirPath, deploymentId), basename(componentDirPath));
1181
+ }
1182
+
1183
+ function extractionStagingDirectory(componentDirPath: string): string {
1184
+ return join(dirname(componentDirPath), ASIDE_STAGING_DIR, basename(componentDirPath));
1185
+ }
1186
+
1187
+ function retiredMarkerForAside(asidePath: string): string {
1188
+ return join(
1189
+ dirname(asidePath),
1190
+ `${RETIRED_ASIDE_PREFIX}${basename(asidePath).slice(IN_PROGRESS_ASIDE_PREFIX.length)}`
1191
+ );
1192
+ }
1193
+
1194
+ async function retireExtractionAside(asidePath: string): Promise<string> {
1195
+ const retiredMarkerPath = retiredMarkerForAside(asidePath);
1196
+ try {
1197
+ await writeFile(retiredMarkerPath, '', { flag: 'wx', mode: 0o600 });
1198
+ } catch (error) {
1199
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
1200
+ }
1201
+ return retiredMarkerPath;
1202
+ }
1203
+
1204
+ function errorMessage(error: unknown): string {
1205
+ return error instanceof Error ? error.message : String(error);
1206
+ }
1207
+
1208
+ export async function makeRollbackPlaceholderMovable(
1209
+ applicationDirPath: string,
1210
+ placeholderIdentity: { dev: bigint; ino: bigint } | undefined
1211
+ ): Promise<void> {
1212
+ if (!placeholderIdentity) return;
1213
+ try {
1214
+ const current = await lstat(applicationDirPath, { bigint: true });
1215
+ if (current.dev === placeholderIdentity.dev && current.ino === placeholderIdentity.ino) {
1216
+ await chmod(applicationDirPath, 0o700);
1217
+ }
1218
+ } catch (error) {
1219
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
1220
+ }
1221
+ }
1222
+
1223
+ async function identifyRollbackPlaceholder(
1224
+ applicationDirPath: string
1225
+ ): Promise<{ dev: bigint; ino: bigint } | undefined> {
1226
+ const userId = process.getuid?.();
1227
+ if (process.platform === 'win32' || userId === undefined || userId === 0) return undefined;
1228
+ try {
1229
+ const current = await lstat(applicationDirPath, { bigint: true });
1230
+ const permissions = Number(current.mode) & 0o777;
1231
+ if (
1232
+ (current.isDirectory() || current.isFile()) &&
1233
+ current.uid === BigInt(userId) &&
1234
+ (permissions === 0 || (current.isDirectory() && (permissions === 0o100 || permissions === 0o300)))
1235
+ ) {
1236
+ return { dev: current.dev, ino: current.ino };
1237
+ }
1238
+ } catch (error) {
1239
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
1240
+ }
1241
+ return undefined;
1242
+ }
1243
+
1244
+ /**
1245
+ * Create a hidden staging directory and confirm it is the one we created: a real directory rather than a
1246
+ * symlink or junction substituted underneath us, restricted to the owner. Re-checked at every use rather
1247
+ * than once per deploy, because the gap between checking and writing is the exploitable part.
1248
+ */
1249
+ async function ensureSecureStagingDirectory(stagingDir: string): Promise<void> {
1250
+ await mkdir(stagingDir, { recursive: true, mode: 0o700 });
1251
+ const stagingStat = await lstat(stagingDir);
1252
+ if (!stagingStat.isDirectory() || stagingStat.isSymbolicLink()) {
1253
+ throw new Error(`Component deploy staging path is not a directory: ${stagingDir}`);
1254
+ }
1255
+ if (process.platform !== 'win32' && (stagingStat.mode & 0o777) !== 0o700) {
1256
+ await chmod(stagingDir, 0o700).catch((error) =>
1257
+ logger.warn(`Could not restrict component deploy staging permissions for ${stagingDir}:`, errorForLog(error))
1258
+ );
1259
+ }
1260
+ }
1261
+
1262
+ async function ensureExtractionStagingDirectory(asideStagingDir: string): Promise<void> {
1263
+ for (const stagingDir of [dirname(asideStagingDir), asideStagingDir]) {
1264
+ await ensureSecureStagingDirectory(stagingDir);
1265
+ }
1266
+ }
1267
+
1268
+ /** The single component directory inside a candidate deployment directory, when there is exactly one. */
1269
+ async function candidateComponentName(deploymentDirPath: string): Promise<string | undefined> {
1270
+ // The sidecar first: it is the only source that still works once the candidate has been renamed to the
1271
+ // live path, which is exactly when an unreadable journal would otherwise be unattributable.
1272
+ // Only ENOENT is absence. Swallowing every error here reported "unowned", which is a licence to act:
1273
+ // the worker verdict dropped a failure and loaded a component main had failed closed, and a deploy
1274
+ // skipped a journaled activation it owns and stalled the component in the legacy pass instead.
1275
+ const recorded = await readFile(join(deploymentDirPath, CANDIDATE_COMPONENT_FILE), 'utf8').catch(
1276
+ (error: NodeJS.ErrnoException) => {
1277
+ if (error?.code === 'ENOENT') return '';
1278
+ throw error;
1279
+ }
1280
+ );
1281
+ const named = recorded.trim();
1282
+ if (isJoinableComponentName(named)) return named;
1283
+ const entries = await readdir(deploymentDirPath, { withFileTypes: true }).catch((error: NodeJS.ErrnoException) => {
1284
+ if (error?.code === 'ENOENT') return [];
1285
+ throw error;
1286
+ });
1287
+ // Symlinks count: a `file:<directory>` candidate is deliberately a link, and activation already accepts
1288
+ // one. Filtering to real directories here left those candidates with no owner, so residue removal took
1289
+ // no lock and could delete a build in flight.
1290
+ //
1291
+ // Validated, because this infers an owner from a NAME. A directory-shaped control file — a corrupt
1292
+ // `.activation.json` that is a directory — would otherwise be returned as the owning component, which
1293
+ // both licenses a restore against it and is a name no component can have.
1294
+ const components = entries.filter(
1295
+ (entry) => (entry.isDirectory() || entry.isSymbolicLink()) && isJoinableComponentName(entry.name)
1296
+ );
1297
+ return components.length === 1 ? components[0].name : undefined;
1298
+ }
1299
+
1300
+ /**
1301
+ * The deployment directory of an activation journal this component still owns, if any.
1302
+ *
1303
+ * The journal is the authority for an interrupted activation. The legacy `.deploy-aside` pass would
1304
+ * otherwise restore the displaced tree over a candidate a completed activation already renamed live, so it
1305
+ * consults this before restoring rather than relying on being sequenced after settlement: a worker
1306
+ * auto-restarted mid-activation reaches it with no settlement in front of it, and settlement that FAILS
1307
+ * deliberately keeps the journal for the next start while the same boot carries on into the legacy pass.
1308
+ */
1309
+ async function journaledDeploymentForComponent(
1310
+ componentsRootDirPath: string,
1311
+ componentName: string
1312
+ ): Promise<string | undefined> {
1313
+ const stagingRoot = join(componentsRootDirPath, DEPLOY_STAGING_DIR);
1314
+ let deployments;
1315
+ try {
1316
+ deployments = await readdir(stagingRoot, { withFileTypes: true });
1317
+ } catch (error) {
1318
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
1319
+ throw error;
1320
+ }
1321
+ for (const deployment of deployments) {
1322
+ if (!deployment.isDirectory()) continue;
1323
+ const deploymentDirPath = join(stagingRoot, deployment.name);
1324
+ const journalPath = join(deploymentDirPath, ACTIVATION_JOURNAL);
1325
+ // NOTHING is swallowed here. This is the gate that authorizes restoring an old tree over what may be
1326
+ // a committed candidate, so "could not tell" has to fail closed — treating an unreadable deployment
1327
+ // as "no journal for this component" is exactly the clobber the journal exists to prevent. The blast
1328
+ // radius is narrow because the gate is only consulted where a restorable record already exists.
1329
+ //
1330
+ // Presence, not parseability: an unreadable journal is precisely the ambiguous case.
1331
+ const journaled = await lstat(journalPath).then(
1332
+ () => true,
1333
+ (error) => {
1334
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false;
1335
+ throw error;
1336
+ }
1337
+ );
1338
+ if (!journaled) continue;
1339
+ // EITHER name blocks, while settlement acts only when both agree. The destructive step takes the
1340
+ // conservative union; the corrective one takes the precise intersection, so a journal whose two
1341
+ // attributions disagree stalls the restore instead of licensing it, and startup recovery — which
1342
+ // keys on the journal — is what clears it. The sidecar also covers a component legitimately named
1343
+ // `component`, whose candidate path collides with the sidecar's and makes every sidecar read fail.
1344
+ const journalOwner = (await readActivationJournal(journalPath).catch(() => undefined))?.component;
1345
+ if (journalOwner === componentName) return deploymentDirPath;
1346
+ const sidecarOwner = await candidateComponentName(deploymentDirPath);
1347
+ if (sidecarOwner === componentName) return deploymentDirPath;
1348
+ // A journal nobody can attribute blocks EVERY component. It is a rare, genuinely broken state — an
1349
+ // unparseable journal whose deployment no longer holds a tree to infer from — and the alternative is
1350
+ // letting the restore proceed against a candidate this journal may well have committed. There is no
1351
+ // automated way out of it, by construction: nothing on disk says which component it belongs to. The
1352
+ // error the caller raises names the directory an operator has to resolve.
1353
+ if (journalOwner === undefined && sidecarOwner === undefined) return deploymentDirPath;
1354
+ }
1355
+ return undefined;
1356
+ }
1357
+
1358
+ /**
1359
+ * The components a deployment's evidence implicates, when its journal and its ownership sidecar disagree
1360
+ * about whose it is — or `undefined` when there is no disagreement to act on.
1361
+ *
1362
+ * A sidecar that cannot answer is NOT disagreement: the journal names its own component and can settle it,
1363
+ * so an unreadable sidecar must not block that. Only a sidecar that reads and names something else is the
1364
+ * wedge, and then BOTH names are returned, sidecar first, because both are stuck — see
1365
+ * `splitAttributionError`.
1366
+ *
1367
+ * Separated from either call site so the decision is testable on its own: the paths that reach it include
1368
+ * one that requires a journal to appear between two reads under a lock, which no test can stage.
1369
+ */
1370
+ export function splitAttributionOwners(
1371
+ journalOwner: string,
1372
+ sidecarOwner: string | undefined
1373
+ ): [string, string] | undefined {
1374
+ if (sidecarOwner === undefined || sidecarOwner === journalOwner) return undefined;
1375
+ return [sidecarOwner, journalOwner];
1376
+ }
1377
+
1378
+ /**
1379
+ * The error for a deployment its journal and its ownership sidecar attribute to different components.
1380
+ *
1381
+ * Both names are wedged, so both are failed: the restore gate takes the union and blocks the sidecar's
1382
+ * component, while settlement needs the intersection and so can never clear the journal owner's. Neither
1383
+ * component's own deploy can resolve it, which is why the message names the directory to remove.
1384
+ */
1385
+ function splitAttributionError(deploymentDirPath: string, journalOwner: string, sidecarOwner: string): Error {
1386
+ return new Error(
1387
+ `Deploy staging ${deploymentDirPath} is attributed to two different components: its journal names ` +
1388
+ `'${journalOwner}' and its sidecar names '${sidecarOwner}'. Neither can settle it; remove that ` +
1389
+ `directory once you have determined which tree is current.`
1390
+ );
1391
+ }
1392
+
1393
+ /** In-progress rollback records in a component's aside directory, newest first. */
1394
+ async function inProgressAsideRecords(asideStagingDir: string): Promise<string[]> {
1395
+ // ENOENT is "no aside directory yet"; anything else would report "no records" and let roll-forward
1396
+ // remove the journal while the records it should have retired are still there and still authoritative.
1397
+ const entries = await readdir(asideStagingDir, { withFileTypes: true }).catch((error: NodeJS.ErrnoException) => {
1398
+ if (error?.code === 'ENOENT') return [];
1399
+ throw error;
1400
+ });
1401
+ // Retired records excluded, the same rule `recoverOrCleanupStaleExtractionPaths` applies. A record whose
1402
+ // retire succeeded but whose best-effort sweep did not is settled, not displaced — counting it would let
1403
+ // an ordinary pre-swap state look like the "live path recreated" ambiguity and fail a healthy component
1404
+ // closed with an operator-only exit.
1405
+ const entryNames = new Set(entries.map((entry) => entry.name));
1406
+ return entries
1407
+ .filter(
1408
+ (entry) =>
1409
+ entry.name.startsWith(IN_PROGRESS_ASIDE_PREFIX) &&
1410
+ !entryNames.has(`${RETIRED_ASIDE_PREFIX}${entry.name.slice(IN_PROGRESS_ASIDE_PREFIX.length)}`)
1411
+ )
1412
+ .map((entry) => join(asideStagingDir, entry.name))
1413
+ .sort()
1414
+ .reverse();
1415
+ }
1416
+
1417
+ /**
1418
+ * Settle journaled activations for ONE component, assuming the caller already holds its preparation lock.
1419
+ *
1420
+ * Exists because the journal-first rule has to hold at every entry point, not just startup. A deploy runs
1421
+ * `recoverOrCleanupStaleExtractionPaths` first. After an activation whose retirement failed, the aside
1422
+ * still names the DISPLACED tree, so restoring it would put the old version back over the new one. That
1423
+ * pass refuses to restore against a surviving journal, but refusing is a stalled component; settling first
1424
+ * is what lets the deploy proceed.
1425
+ */
1426
+ async function settleJournaledActivationsForComponent(
1427
+ componentsRootDirPath: string,
1428
+ componentName: string
1429
+ ): Promise<void> {
1430
+ const stagingRoot = join(componentsRootDirPath, DEPLOY_STAGING_DIR);
1431
+ let deployments;
1432
+ try {
1433
+ deployments = await readdir(stagingRoot, { withFileTypes: true });
1434
+ } catch (error) {
1435
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return;
1436
+ throw error;
1437
+ }
1438
+ for (const deployment of deployments) {
1439
+ if (!deployment.isDirectory()) continue;
1440
+ const deploymentDirPath = join(stagingRoot, deployment.name);
1441
+ // Ownership BEFORE parsing. Reading every journal first meant a truncated journal belonging to another
1442
+ // component threw here — blocking the deploy of a healthy component because an unrelated one is
1443
+ // broken. The sidecar names the owner without parsing anything, and a deployment that does not name
1444
+ // this component is none of this deploy's business; startup recovery reports it instead.
1445
+ //
1446
+ // An ownership read that FAILS is the same situation, and skipping is safe here specifically because
1447
+ // settlement is the corrective half: if that entry does turn out to be this component's, the restore
1448
+ // gate — which takes the union and fails closed on anything it cannot attribute — is what stops the
1449
+ // legacy pass acting on it. Failing the deploy instead lets one unreadable sibling block every
1450
+ // neighbour's deploys, which is the outage this ordering exists to prevent.
1451
+ let owner: string | undefined;
1452
+ let ownerUnreadable = false;
1453
+ try {
1454
+ owner = await candidateComponentName(deploymentDirPath);
1455
+ } catch (error) {
1456
+ ownerUnreadable = true;
1457
+ logger.trace?.(
1458
+ `Ownership of ${deploymentDirPath} is unreadable while settling ${componentName}, falling back to ` +
1459
+ `its journal: ${errorMessage(error)}`
1460
+ );
1461
+ }
1462
+ if (!ownerUnreadable && owner !== componentName) continue;
1463
+ const journal = await readActivationJournal(join(deploymentDirPath, ACTIVATION_JOURNAL));
1464
+ // A journal-less directory is what a SUCCESSFUL settlement leaves when its best-effort sweep fails, so
1465
+ // this must not fail closed. An unattributable *activation* still does: `readActivationJournal` throws
1466
+ // on a journal that exists but cannot be read.
1467
+ if (!journal) continue;
1468
+ // The journal decides, not the sidecar. Skipping on an unreadable sidecar alone would leave this
1469
+ // component's own unsettled activation in place while a new deploy proceeded over it, and an
1470
+ // activation interrupted before B1 has no rollback record for the restore gate to catch.
1471
+ if (journal.component !== componentName) continue;
1472
+ await settleInterruptedActivation(componentsRootDirPath, deploymentDirPath, journal);
1473
+ }
1474
+ }
1475
+
1476
+ /**
1477
+ * Components that on-disk evidence says were left in a state nobody settled — determined READ-ONLY, so any
1478
+ * thread can reach the same verdict.
1479
+ *
1480
+ * Recovery runs on the main thread only, but the components it could not settle still have to be failed
1481
+ * closed on the workers that actually serve them, and a worker cannot be handed main's verdict: it boots
1482
+ * through its own `loadRootComponents(true)`, potentially before main finished. Recovery deliberately KEEPS
1483
+ * the evidence for anything it could not settle, so a worker can read it instead of being told.
1484
+ *
1485
+ * Only unambiguous evidence counts. A well-formed journal is NOT evidence — every healthy deploy has one
1486
+ * in flight — so this reports a journal that cannot be read at all (corrupt, unknown version, or naming
1487
+ * something other than its own deployment), which no in-flight deploy ever produces.
1488
+ */
1489
+ export async function unsettleableComponentsFromDisk(componentsRootDirPath: string): Promise<Map<string, Error>> {
1490
+ const unsettleable = new Map<string, Error>();
1491
+ const stagingRoot = join(componentsRootDirPath, DEPLOY_STAGING_DIR);
1492
+ let deployments;
1493
+ try {
1494
+ deployments = await readdir(stagingRoot, { withFileTypes: true });
1495
+ } catch (error) {
1496
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return unsettleable;
1497
+ throw error;
1498
+ }
1499
+ for (const deployment of deployments) {
1500
+ if (!deployment.isDirectory()) continue;
1501
+ const deploymentDirPath = join(stagingRoot, deployment.name);
1502
+ // Per deployment. `candidateComponentName` propagates every non-ENOENT error now, and this pass runs
1503
+ // where the CALLER only warns — so one unreadable deployment escaping here would drop the verdict for
1504
+ // every other component and let each of them load with no evidence checked at all.
1505
+ try {
1506
+ await verdictFor(deploymentDirPath, deployment.name, unsettleable);
1507
+ } catch (error) {
1508
+ const failure = error instanceof Error ? error : new Error(String(error));
1509
+ if (!unsettleable.has(deployment.name)) unsettleable.set(deployment.name, failure);
1510
+ }
1511
+ }
1512
+ return unsettleable;
1513
+ }
1514
+
1515
+ /** One deployment's read-only verdict. Throws rather than guessing; the caller scopes that to this entry. */
1516
+ async function verdictFor(
1517
+ deploymentDirPath: string,
1518
+ deploymentName: string,
1519
+ unsettleable: Map<string, Error>
1520
+ ): Promise<void> {
1521
+ // Recorded by main when it failed to settle a well-formed journal. Checked first, because that case is
1522
+ // invisible to a worker otherwise.
1523
+ //
1524
+ // Only ENOENT is absence. A marker that exists but cannot be read (EIO, EACCES) must not be taken as
1525
+ // "no verdict" — that classifies the well-formed journal beside it as a healthy in-flight deploy and
1526
+ // lets the worker load a component main failed closed.
1527
+ let recorded: string | undefined;
1528
+ try {
1529
+ recorded = await readFile(join(deploymentDirPath, UNSETTLED_MARKER), 'utf8');
1530
+ } catch (error) {
1531
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
1532
+ const failure = error instanceof Error ? error : new Error(String(error));
1533
+ return record(unsettleable, await attribute(deploymentDirPath, deploymentName), failure);
1534
+ }
1535
+ }
1536
+ if (recorded !== undefined) {
1537
+ const component = await attribute(deploymentDirPath, deploymentName);
1538
+ return record(
1539
+ unsettleable,
1540
+ component,
1541
+ new Error(recorded.trim() || `Activation of ${component} could not be settled`)
1542
+ );
1543
+ }
1544
+ try {
1545
+ await readActivationJournal(join(deploymentDirPath, ACTIVATION_JOURNAL));
1546
+ } catch (error) {
1547
+ const failure = error instanceof Error ? error : new Error(String(error));
1548
+ record(unsettleable, await attribute(deploymentDirPath, deploymentName), failure);
1549
+ }
1550
+ }
1551
+
1552
+ /**
1553
+ * Who a deployment's evidence belongs to. Falls back to the deployment id when nothing names it: a verdict
1554
+ * attributed to nothing is a verdict nobody acts on, and the id is at least something an operator can find
1555
+ * on disk. A read that FAILS is not "nothing names it" — that propagates, and the caller records it against
1556
+ * the id, so an unreadable deployment is reported rather than dropped.
1557
+ */
1558
+ async function attribute(deploymentDirPath: string, deploymentName: string): Promise<string> {
1559
+ return (await candidateComponentName(deploymentDirPath)) ?? deploymentName;
1560
+ }
1561
+
1562
+ function record(unsettleable: Map<string, Error>, component: string, failure: Error): void {
1563
+ if (!unsettleable.has(component)) unsettleable.set(component, failure);
1564
+ }
1565
+
1566
+ /**
1567
+ * Settle activations a crash interrupted, before anything loads. Runs at startup on main and on every
1568
+ * worker — a worker can be respawned mid-activation, long after main's pass.
1569
+ *
1570
+ * Returns failures keyed by COMPONENT so the caller can fail exactly those closed and still load every
1571
+ * healthy sibling — a single unreadable journal must not take down the whole node, and must not let a
1572
+ * component load over state nobody reconciled.
1573
+ *
1574
+ */
1575
+ export async function recoverInterruptedActivations(componentsRootDirPath: string): Promise<Map<string, Error>> {
1576
+ const failures = new Map<string, Error>();
1577
+ const stagingRoot = join(componentsRootDirPath, DEPLOY_STAGING_DIR);
1578
+ let deployments;
1579
+ try {
1580
+ deployments = await readdir(stagingRoot, { withFileTypes: true });
1581
+ } catch (error) {
1582
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return failures;
1583
+ throw error;
1584
+ }
1585
+
1586
+ for (const deployment of deployments) {
1587
+ if (!deployment.isDirectory()) continue;
1588
+ const deploymentDirPath = join(stagingRoot, deployment.name);
1589
+ 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
+ };
1609
+
1610
+ let journal: ActivationJournal | undefined;
1611
+ try {
1612
+ journal = await readActivationJournal(journalPath);
1613
+ } catch (error) {
1614
+ // The journal itself is unreadable, so its component has to be inferred from the tree it was
1615
+ // going to activate. A deployment directory holding no component tree leaves only its id.
1616
+ const attributed = await candidateComponentName(deploymentDirPath).catch(() => undefined);
1617
+ await fail(attributed ?? deployment.name, error);
1618
+ continue;
1619
+ }
1620
+ if (!journal) {
1621
+ // No activation was attempted: build residue, or a candidate abandoned mid-build. The legacy
1622
+ // in-place recovery owns any aside it left, so there is nothing to settle.
1623
+ //
1624
+ // Removed UNDER the component's lock, and only after re-checking that no journal appeared in the
1625
+ // meantime. A reload cycle can run this pass while another deploy is mid-build — its candidate
1626
+ // has no journal yet, because the journal is written after build and validation — so an unlocked
1627
+ // delete here removes a live build out from under it.
1628
+ let owner: string | undefined;
1629
+ // Who to fail, if what went wrong under the lock turns out to concern an ACTIVATION rather than
1630
+ // this branch's opportunistic cleanup. Left unset for a sweep that could not remove a settled
1631
+ // directory and for a lock a live deploy is holding: neither is an unsettled activation.
1632
+ let activationToFail: string | undefined;
1633
+ // Set when the journal that appears under the lock names someone other than the sidecar owner
1634
+ // whose lock we took. Both names are failed, exactly as on the journaled path.
1635
+ let splitOwners: [string, string] | undefined;
1636
+ const removeResidue = async () => {
1637
+ // Re-read UNDER the lock, and do not swallow: the first scan raced a deploy that can publish a
1638
+ // journal before releasing the lock, so a journal found now must be settled rather than deleted.
1639
+ // Treating a read error as "no journal" would delete the evidence instead.
1640
+ let appeared;
1641
+ try {
1642
+ appeared = await readActivationJournal(join(deploymentDirPath, ACTIVATION_JOURNAL));
1643
+ } catch (error) {
1644
+ // A journal published between the scan's read and this one, which then cannot be READ, is an
1645
+ // activation whose intent is unknowable — the one thing in this branch that has to fail
1646
+ // closed. Attributed to the sidecar's owner, since the journal cannot name itself.
1647
+ activationToFail = owner ?? deployment.name;
1648
+ throw error;
1649
+ }
1650
+ if (appeared) {
1651
+ // The lock held here was taken on the SIDECAR's owner. A journal naming someone else must not
1652
+ // be settled under it: `settleInterruptedActivation` renames and removes that other
1653
+ // component's trees, whose own deploy may hold its own lock and be mid-flight. It is also the
1654
+ // same split evidence the journaled path fails closed for both names, so it takes the same
1655
+ // route rather than a second opinion here.
1656
+ const splitNames = splitAttributionOwners(appeared.component, owner);
1657
+ if (splitNames) {
1658
+ splitOwners = splitNames;
1659
+ throw splitAttributionError(deploymentDirPath, appeared.component, splitNames[0]);
1660
+ }
1661
+ activationToFail = appeared.component;
1662
+ await settleInterruptedActivation(componentsRootDirPath, deploymentDirPath, appeared);
1663
+ // Settled. Anything that fails after this — releasing the lock, say — is not this
1664
+ // activation's, and attributing it here would fail a correctly settled component closed.
1665
+ activationToFail = undefined;
1666
+ return;
1667
+ }
1668
+ // Cleanup, not settlement. There was no activation here — this is most often the residue a
1669
+ // SUCCESSFUL settlement leaves when its own sweep failed — so a sweep that fails again cannot
1670
+ // make anything unsettled, and recording it would refuse a live component on every worker
1671
+ // until the filesystem fault cleared.
1672
+ await rm(deploymentDirPath, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }).catch((error) =>
1673
+ logger.warn(`Could not clean up deploy staging ${deploymentDirPath}:`, errorForLog(error))
1674
+ );
1675
+ };
1676
+ // Attribution FIRST, in its own scope, and its failure is not a verdict. A journal-less directory
1677
+ // is what a successful settlement leaves when its best-effort sweep fails, so a `fail()` here
1678
+ // wrote `.unsettled` keyed by the deployment id for a deployment that never held an unsettled
1679
+ // activation — and nothing ever cleared it, because settlement never runs for a journal-less
1680
+ // directory. If that sidecar later became readable as `web`, the stale marker was attributed to
1681
+ // the live `web` and every worker refused it permanently. This is the same shape the deploy path
1682
+ // skips; the two paths now agree.
1683
+ try {
1684
+ owner = await candidateComponentName(deploymentDirPath);
1685
+ } catch (error) {
1686
+ logger.trace?.(
1687
+ `Leaving deploy staging ${deploymentDirPath} in place: it holds no activation journal and its ` +
1688
+ `ownership cannot be read: ${errorMessage(error)}`
1689
+ );
1690
+ continue;
1691
+ }
1692
+ // Scoped to THIS deployment, like the journaled branch below: a lock timeout or an EIO here used to
1693
+ // abort the entire scan, leaving every later deployment unsettled and unmarked.
1694
+ try {
1695
+ if (owner) {
1696
+ await withComponentPreparationLock(join(componentsRootDirPath, owner), removeResidue, {
1697
+ purpose: 'activation-recovery',
1698
+ ...RECOVERY_LOCK_WAIT,
1699
+ // Without this a ticket left by a CRASHED worker looks live — same pid, same process
1700
+ // instance — so recovery waits out the multi-hour default instead of reclaiming it.
1701
+ isOwnerAlive: (lockOwner) => lockOwner.pid !== process.pid || isThreadRunning(lockOwner.threadId),
1702
+ });
1703
+ } else {
1704
+ // NOT removed. `buildCandidateApplication` creates the deployment directory and can then spend
1705
+ // minutes resolving or packing before the candidate tree and its sidecar exist, so "no owner"
1706
+ // includes "a live build that has not got that far yet" — and deleting it races the extraction
1707
+ // and fails a valid deploy. Unowned residue is left for a later pass, once an owner is knowable.
1708
+ logger.trace?.(`Leaving unowned deploy staging ${deploymentDirPath} in place: no component names it`);
1709
+ }
1710
+ } catch (error) {
1711
+ // A verdict only when an activation was actually involved; a directory that cannot be swept is
1712
+ // not one. A lock TIMEOUT is still recorded, because it is the signal `componentLoader` uses
1713
+ // to defer this component until the deploy holding that lock finishes — and it leaves nothing
1714
+ // durable behind, since `fail()` writes no marker for a deferral.
1715
+ if (splitOwners) {
1716
+ for (const name of splitOwners) await fail(name, error);
1717
+ } else if (activationToFail) await fail(activationToFail, error);
1718
+ else if (error instanceof ComponentPreparationLockTimeoutError) await fail(owner ?? deployment.name, error);
1719
+ else
1720
+ logger.warn(
1721
+ `Could not settle deploy staging ${deploymentDirPath}, which holds no activation journal:`,
1722
+ errorForLog(error instanceof Error ? error : new Error(String(error)))
1723
+ );
1724
+ }
1725
+ continue;
1726
+ }
1727
+ try {
1728
+ // Disagreeing attributions are the one case with no automated way out: the restore gate blocks the
1729
+ // SIDECAR's component (it takes the union, because restoring is destructive) while settlement
1730
+ // keys the journal, so neither name's deploy could ever clear it. Reported as unsettleable
1731
+ // against the sidecar's name, which is the component actually stalled, instead of stalling it
1732
+ // silently on every boot.
1733
+ // Unreadable is not disagreement. The journal is the authority on this path — it named its
1734
+ // component and can settle it — so a sidecar that cannot be read must not block that; only one
1735
+ // that CAN be read and names something else is the wedge below.
1736
+ const sidecarOwner = await candidateComponentName(deploymentDirPath).catch(() => undefined);
1737
+ const splitNames = splitAttributionOwners(journal.component, sidecarOwner);
1738
+ if (splitNames) {
1739
+ const split = splitAttributionError(deploymentDirPath, journal.component, splitNames[0]);
1740
+ for (const name of splitNames) await fail(name, split);
1741
+ continue;
1742
+ }
1743
+ const settling = journal;
1744
+ await withComponentPreparationLock(
1745
+ join(componentsRootDirPath, settling.component),
1746
+ () => settleInterruptedActivation(componentsRootDirPath, deploymentDirPath, settling),
1747
+ {
1748
+ purpose: 'activation-recovery',
1749
+ ...RECOVERY_LOCK_WAIT,
1750
+ isOwnerAlive: (lockOwner) => lockOwner.pid !== process.pid || isThreadRunning(lockOwner.threadId),
1751
+ }
1752
+ );
1753
+ } catch (error) {
1754
+ // The journal named its component, so attribution is exact however the settle failed.
1755
+ await fail(journal.component, error);
1756
+ }
1757
+ }
1758
+ return failures;
1759
+ }
1760
+
1761
+ /**
1762
+ * Retire and sweep the rollback records a settled activation leaves. Retiring throws — it is what makes a
1763
+ * record non-authoritative, so a caller that removed the journal without it re-creates the inversion the
1764
+ * journal prevents. Sweeping the displaced tree only costs disk, so it is logged.
1765
+ */
1766
+ async function sweepAsideRecords(
1767
+ records: string[],
1768
+ componentName: string,
1769
+ liveDirPath: string,
1770
+ asideStagingDir: string
1771
+ ): Promise<void> {
1772
+ for (const record of records) {
1773
+ // RETIRING IS CORRECTNESS, not hygiene: the retired marker is what stops the legacy pass treating this
1774
+ // record as authoritative and restoring the displaced tree over the candidate that was just rolled
1775
+ // forward, once the journal that would otherwise hold it back is gone. A record left un-retired while the journal is removed re-creates exactly
1776
+ // the inversion this protocol exists to prevent, so a failure here PROPAGATES — the caller keeps the
1777
+ // journal and the next start retries.
1778
+ const retiredMarkerPath = await retireExtractionAside(record);
1779
+ // Sweeping the displaced tree is hygiene: it bounds disk, and a failure costs space rather than
1780
+ // correctness, so it is logged. The retired marker above already makes the record non-authoritative.
1781
+ await cleanupExtractionPaths(
1782
+ { name: componentName, dirPath: liveDirPath, logger },
1783
+ asideStagingDir,
1784
+ new Set([record, retiredMarkerPath])
1785
+ ).catch((error) => logger.warn(`Settled ${componentName} but could not sweep ${record}:`, errorForLog(error)));
1786
+ }
1787
+ }
1788
+
1789
+ /**
1790
+ * One interrupted activation, under the component preparation lock. Ambiguity exists only while the live
1791
+ * path is absent, and there the `complete` marker is the roll-forward authority: without it the candidate
1792
+ * was never validated, so the committed tree in the aside wins. Every branch is idempotent, so a crash
1793
+ * during recovery is settled by the next run.
1794
+ */
1795
+ async function settleInterruptedActivation(
1796
+ componentsRootDirPath: string,
1797
+ deploymentDirPath: string,
1798
+ journal: ActivationJournal
1799
+ ): Promise<void> {
1800
+ const liveDirPath = join(componentsRootDirPath, journal.component);
1801
+ const candidateDirPath = join(deploymentDirPath, journal.component);
1802
+ const asideStagingDir = extractionStagingDirectory(liveDirPath);
1803
+ const journalPath = join(deploymentDirPath, ACTIVATION_JOURNAL);
1804
+
1805
+ const exists = async (path: string) =>
1806
+ lstat(path).then(
1807
+ () => true,
1808
+ (error) => {
1809
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false;
1810
+ throw error;
1811
+ }
1812
+ );
1813
+ const liveExists = await exists(liveDirPath);
1814
+ const candidateExists = await exists(candidateDirPath);
1815
+ const candidateComplete = await exists(join(deploymentDirPath, CANDIDATE_COMPLETE_MARKER));
1816
+ const asideRecords = await inProgressAsideRecords(asideStagingDir);
1817
+
1818
+ const rollForward = async () => {
1819
+ if (!liveExists) await rename(candidateDirPath, liveDirPath);
1820
+ // Unconditional, not only when THIS pass performed the rename: a crash after normal activation
1821
+ // renamed the candidate but before it repaired the links leaves live present with stale targets, and
1822
+ // gating the repair on the rename would skip exactly that case. Idempotent when there is nothing to
1823
+ // re-point.
1824
+ await repairRelocatedDependencyLinks(liveDirPath, candidateDirPath);
1825
+ await syncRenameParents(candidateDirPath, liveDirPath);
1826
+ // Retiring PROPAGATES from here: the retired marker is what stops the legacy pass restoring the tree
1827
+ // this roll-forward just displaced. Failing the component closed and retrying at the next start is
1828
+ // the cheaper mistake — the journal survives, so the verdict is re-derivable. Only the disk sweep
1829
+ // inside is best-effort.
1830
+ await sweepAsideRecords(asideRecords, journal.component, liveDirPath, asideStagingDir);
1831
+ };
1832
+ const rollBack = async (restoreFrom?: string) => {
1833
+ if (restoreFrom) {
1834
+ await rename(restoreFrom, liveDirPath);
1835
+ await syncRenameParents(restoreFrom, liveDirPath);
1836
+ }
1837
+ for (const record of asideRecords) {
1838
+ try {
1839
+ await rm(record, { recursive: true, force: true });
1840
+ } catch (error) {
1841
+ // A record that survives removal is still AUTHORITATIVE to the legacy pass, and settlement is
1842
+ // about to remove the journal that holds that pass back — so it would restore this older tree
1843
+ // over the component just rolled back. Retiring is what makes a record non-authoritative, so
1844
+ // do that instead of logging and moving on. A retire that ALSO fails propagates: the journal
1845
+ // then survives and the next start retries, which is the same contract roll-forward uses.
1846
+ logger.warn(`Rolled ${journal.component} back but could not remove ${record}:`, errorForLog(error));
1847
+ await retireExtractionAside(record);
1848
+ }
1849
+ }
1850
+ };
1851
+
1852
+ if (!liveExists) {
1853
+ if (candidateExists && candidateComplete) {
1854
+ await rollForward();
1855
+ } else {
1856
+ // The tree that was moved aside is the last committed one. A `-prior-absent` record means there
1857
+ // was nothing live to begin with, so rolling back means leaving the component absent.
1858
+ const restorable = asideRecords.find((record) => !record.endsWith(PRIOR_ABSENT_RECORD_SUFFIX));
1859
+ if (!restorable && !asideRecords.length) {
1860
+ throw new Error(
1861
+ `Cannot settle the interrupted activation of ${journal.component}: it has neither a live tree, ` +
1862
+ `a complete candidate, nor a rollback record, so no version of it can be recovered`
1863
+ );
1864
+ }
1865
+ await rollBack(restorable);
1866
+ }
1867
+ } else if (candidateExists) {
1868
+ // Live and candidate both present normally means B1 never ran: the swap had not started, so the live
1869
+ // tree stands and the candidate goes.
1870
+ //
1871
+ // Unless a rollback record says B1 DID run. Then the committed tree is the one in the aside, and
1872
+ // whatever sits at the live path was put there afterwards — a previous-version worker recreating its
1873
+ // own directory, the case the extraction path guards with `identifyRollbackPlaceholder`. Rolling back
1874
+ // there deletes the committed tree AND the validated candidate and leaves that stub serving, so this
1875
+ // fails closed instead: both trees stay on disk for an operator to choose between.
1876
+ // NOT conditioned on the candidate being complete. `rollBack()` below removes every aside record, and
1877
+ // with a record present that tree is the last committed one — so deleting it destroys the only
1878
+ // surviving copy of the previous release whether or not the candidate was ever validated. What
1879
+ // `.complete` decides is which tree we would prefer, not whether discarding the other is safe.
1880
+ const displaced = asideRecords.find((record) => !record.endsWith(PRIOR_ABSENT_RECORD_SUFFIX));
1881
+ if (displaced) {
1882
+ throw new Error(
1883
+ `Cannot settle the interrupted activation of ${journal.component}: its previous tree was moved to ` +
1884
+ `${displaced}, but ${liveDirPath} exists again — something recreated it after the deploy moved ` +
1885
+ `it aside, so which tree is current cannot be determined without losing one of them. Remove ` +
1886
+ `whichever of the two is not the release you want once you have determined which that is.`
1887
+ );
1888
+ }
1889
+ await rollBack();
1890
+ } else {
1891
+ // The candidate is already live; only the tail of the transaction was lost.
1892
+ await rollForward();
1893
+ }
1894
+
1895
+ // The same ordering barrier normal activation uses, and for the same reason: the journal is the only
1896
+ // thing left telling the legacy pass not to restore an aside. Removing it while an
1897
+ // `.in-progress-*` record still names the displaced tree — because the retire or the sweep did not
1898
+ // reach storage — lets that pass put the old version back over the new one at the next start. Flush the
1899
+ // aside directory first, and leave the journal in place if that cannot be confirmed; recovery is
1900
+ // idempotent, so the next run settles it again.
1901
+ try {
1902
+ await syncDirectory(asideStagingDir);
1903
+ await syncDirectory(dirname(asideStagingDir));
1904
+ } catch (error) {
1905
+ logger.warn(
1906
+ `Settled the interrupted activation of ${journal.component} but could not flush its rollback record; ` +
1907
+ `leaving the journal for the next start: ${errorMessage(error)}`
1908
+ );
1909
+ return;
1910
+ }
1911
+ // Best-effort, matching the activation path: the activation is settled by this point, so a transient
1912
+ // EBUSY removing staging must not throw out of the recovery pass and take the other components with it.
1913
+ // An earlier failed recovery may have left an unsettled marker here. Cleared BEFORE the journal and
1914
+ // treated as correctness: main would report this component settled and load it, while every worker read
1915
+ // 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
+ }
1930
+ await rm(journalPath, { force: true }).catch((error) =>
1931
+ logger.warn(`Settled ${journal.component} but could not remove its activation journal:`, errorForLog(error))
1932
+ );
1933
+ await rm(deploymentDirPath, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }).catch((error) =>
1934
+ logger.warn(`Settled ${journal.component} but could not clean up its staging directory:`, errorForLog(error))
1935
+ );
1936
+ await rmdir(dirname(deploymentDirPath)).catch(() => {});
1937
+ }
1938
+
1939
+ /** Mark a candidate build+validation complete. Idempotent, so a retried activation is not a failure. */
1940
+ /**
1941
+ * fsync the candidate's contents before `.complete` vouches for them — otherwise the control files can
1942
+ * outlive the tree after a power loss and recovery rolls forward onto a truncated one.
1943
+ */
1944
+ // Codes that mean "this platform or filesystem will not fsync this handle", as opposed to "the write did
1945
+ // not reach storage". Windows raises EPERM fsyncing perfectly healthy files, and network/overlay mounts
1946
+ // return EINVAL or ENOTSUP — none of which say anything about durability, and all of which would otherwise
1947
+ // fail every deploy on those platforms.
1948
+ const UNSUPPORTED_SYNC_CODES = new Set(['EPERM', 'EINVAL', 'ENOTSUP', 'EOPNOTSUPP', 'EBADF', 'EISDIR']);
1949
+
1950
+ function isUnsupportedSync(error: unknown): boolean {
1951
+ return UNSUPPORTED_SYNC_CODES.has((error as NodeJS.ErrnoException)?.code ?? '');
1952
+ }
1953
+
1954
+ // How many file syncs run at once while flushing a candidate. Serial open/sync/close over a large
1955
+ // dependency tree adds seconds to every activation, all of it under the component preparation lock; a small
1956
+ // fan-out keeps the ordering guarantee (everything is synced before `.complete` is written) without paying
1957
+ // per-file latency one file at a time.
1958
+ const CANDIDATE_SYNC_CONCURRENCY = 16;
1959
+ // Directories walked at once when re-pointing dependency links after a swap; a pnpm or monorepo tree is
1960
+ // thousands of directories and a serial depth-first walk after every activation is a real cost.
1961
+ const LINK_REPAIR_CONCURRENCY = 8;
1962
+
1963
+ async function syncTreeContents(rootPath: string, foreignTree = false): Promise<void> {
1964
+ // Real durability failures propagate: the deploy fails, which is safe because the live tree is
1965
+ // untouched. Platform "cannot fsync this handle" codes do not — treating those as durability failures
1966
+ // fails every deploy on Windows.
1967
+ const entries = await readdir(rootPath, { withFileTypes: true }).catch((error: NodeJS.ErrnoException) => {
1968
+ // Same reasoning as the per-file tolerance below: a directory inside a foreign tree that this uid
1969
+ // cannot list is not ours to make durable, and failing here fails a deploy over a directory the
1970
+ // deploy never wrote.
1971
+ if (foreignTree && error?.code === 'EACCES') {
1972
+ logger.trace?.(`Sync of ${rootPath} unavailable: ${errorMessage(error)}`);
1973
+ return undefined;
1974
+ }
1975
+ throw error;
1976
+ });
1977
+ if (!entries) return;
1978
+ const syncFile = async (entryPath: string) => {
1979
+ let handle;
1980
+ try {
1981
+ handle = await open(entryPath, 'r');
1982
+ } catch (error) {
1983
+ // `foreignTree`: a `file:<directory>` candidate is a symlink to a tree this deploy does not own,
1984
+ // so it can hold files the Harper uid cannot open. Those are not ours to make durable and their
1985
+ // EACCES says nothing about whether the install output beside them reached storage — while
1986
+ // failing here would fail an otherwise valid deploy over a file the deploy never touched. The
1987
+ // install output itself is written as this uid, so it is readable and still fsynced.
1988
+ //
1989
+ // The limit of that: this cannot tell a developer's unreadable source file from an install
1990
+ // script that deliberately made its OWN output unreadable, so `.complete` could certify output
1991
+ // that was never synced. Only for a `file:` candidate, and only for a script that chmods its
1992
+ // own artifacts away from the uid that has to run them.
1993
+ if (isUnsupportedSync(error) || (foreignTree && (error as NodeJS.ErrnoException)?.code === 'EACCES')) {
1994
+ logger.trace?.(`Sync of ${entryPath} unavailable: ${errorMessage(error)}`);
1995
+ return;
1996
+ }
1997
+ throw error;
1998
+ }
1999
+ try {
2000
+ await handle.sync();
2001
+ } catch (error) {
2002
+ if (!isUnsupportedSync(error)) throw error;
2003
+ logger.trace?.(`Sync of ${entryPath} unsupported: ${errorMessage(error)}`);
2004
+ } finally {
2005
+ await handle.close().catch(() => {});
2006
+ }
2007
+ };
2008
+ const pending: Promise<void>[] = [];
2009
+ for (const entry of entries) {
2010
+ const entryPath = join(rootPath, entry.name);
2011
+ if (entry.isDirectory()) {
2012
+ await syncTreeContents(entryPath, foreignTree);
2013
+ } else if (entry.isFile()) {
2014
+ pending.push(syncFile(entryPath));
2015
+ if (pending.length >= CANDIDATE_SYNC_CONCURRENCY) {
2016
+ await Promise.all(pending.splice(0));
2017
+ }
2018
+ }
2019
+ }
2020
+ await Promise.all(pending);
2021
+ await syncDirectory(rootPath);
2022
+ }
2023
+
2024
+ export async function markCandidateComplete(
2025
+ componentDirPath: string,
2026
+ deploymentId: string,
2027
+ componentName: string
2028
+ ): Promise<void> {
2029
+ // Contents first: `.complete` is roll-forward AUTHORITY, so it must not be durable before the tree it
2030
+ // vouches for.
2031
+ //
2032
+ // A `file:<directory>` candidate IS a symlink to a tree this deploy does not own, but the dependency
2033
+ // install writes THROUGH it — so the tree still has to be walked, or the install output `.complete`
2034
+ // vouches for is never made durable. Only the foreign files alongside it are tolerated: see
2035
+ // `syncTreeContents`.
2036
+ const candidatePath = candidateApplicationPath(componentDirPath, deploymentId);
2037
+ const candidateIsLink = await lstat(candidatePath).then(
2038
+ (stats) => stats.isSymbolicLink(),
2039
+ (error) => {
2040
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false;
2041
+ throw error;
2042
+ }
2043
+ );
2044
+ await syncTreeContents(candidatePath, candidateIsLink);
2045
+ try {
2046
+ await writeControlFileDurably(candidateComponentFilePath(componentDirPath, deploymentId), componentName);
2047
+ } catch (error) {
2048
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
2049
+ }
2050
+ try {
2051
+ await writeControlFileDurably(candidateCompleteMarkerPath(componentDirPath, deploymentId), '');
2052
+ } catch (error) {
2053
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
2054
+ }
2055
+ }
2056
+
2057
+ /**
2058
+ * 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).
2061
+ *
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.
2066
+ */
2067
+ export async function activateCandidateApplication(application: Application, deploymentId: string): Promise<void> {
2068
+ const liveDirPath = application.dirPath;
2069
+ const candidateDirPath = candidateApplicationPath(liveDirPath, deploymentId);
2070
+ const deploymentDirPath = candidateDeploymentDirPath(liveDirPath, deploymentId);
2071
+ const asideStagingDir = extractionStagingDirectory(liveDirPath);
2072
+
2073
+ // A symlink counts: a `file:<directory>` deploy links the source rather than extracting it, and that
2074
+ // link is what gets swapped into the live path.
2075
+ const candidateStat = await lstat(candidateDirPath).catch((error) => {
2076
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
2077
+ throw error;
2078
+ });
2079
+ if (!candidateStat || !(candidateStat.isDirectory() || candidateStat.isSymbolicLink())) {
2080
+ throw new Error(`Cannot activate ${application.name}: no candidate build at ${candidateDirPath}`);
2081
+ }
2082
+
2083
+ await markCandidateComplete(liveDirPath, deploymentId, application.name);
2084
+ 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
+
2109
+ // B1 — the live tree moves aside. It stays the rollback source until B4 retires it.
2110
+ let asidePath: string | undefined;
2111
+ 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
+ }
2122
+ 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);
2126
+ };
2127
+
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.
2132
+ try {
2133
+ await syncRenameParents(liveDirPath, asidePath ?? priorAbsentRecordPath!);
2134
+ } catch (error) {
2135
+ await compensate(error, 'record the displaced component directory', restoreLive, application);
2136
+ throw error;
2137
+ }
2138
+
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);
2144
+ } catch (error) {
2145
+ await compensate(error, 'move the candidate into place', restoreLive, application);
2146
+ throw error;
2147
+ }
2148
+
2149
+ // Past the point of no return: each failure below leaves a state recovery settles forward, so they are
2150
+ // logged, not thrown.
2151
+ let swapDurable = true;
2152
+ try {
2153
+ await syncRenameParents(candidateDirPath, liveDirPath);
2154
+ } catch (error) {
2155
+ // The rename may not have reached storage. Retiring the record and removing the journal WOULD reach
2156
+ // it, and a power loss then leaves no live entry, no rollback record, and nothing saying to roll
2157
+ // forward. Both are skipped so the journal carries the activation to the next start.
2158
+ swapDurable = false;
2159
+ application.logger.warn(`Deployed ${application.name} but could not flush the swap to storage:`, error);
2160
+ }
2161
+ // The tree moved, so any dependency link that named its build path is now dangling.
2162
+ await repairRelocatedDependencyLinks(liveDirPath, candidateDirPath);
2163
+ const settledRecord = asidePath ?? priorAbsentRecordPath!;
2164
+ let retired = false;
2165
+ // Skipped entirely when the swap is not known to be on storage, so the journal below survives.
2166
+ if (swapDurable) {
2167
+ try {
2168
+ const retiredMarkerPath = await retireExtractionAside(settledRecord);
2169
+ // Retiring only MARKS the displaced tree disposable. Without this sweep the tree every deploy
2170
+ // displaces stays under `.deploy-aside/<component>` forever, so the components root grows by a
2171
+ // whole component version per deploy.
2172
+ await cleanupExtractionPaths(application, asideStagingDir, new Set([settledRecord, retiredMarkerPath]));
2173
+ // Before the journal goes: if the journal's removal persists but the record's does not, startup sees
2174
+ // an in-progress aside with no journal and the legacy pass restores the old tree over the new one.
2175
+ await syncDirectory(asideStagingDir);
2176
+ await syncDirectory(dirname(asideStagingDir));
2177
+ retired = true;
2178
+ } catch (error) {
2179
+ application.logger.warn(`Deployed ${application.name} but could not retire its rollback record:`, error);
2180
+ }
2181
+ }
2182
+ // The journal goes LAST, and only once the rollback record is settled: removing it while an
2183
+ // `.in-progress-*` record still names the displaced tree lets the legacy pass restore the old tree.
2184
+ if (retired) {
2185
+ await rm(journalPath, { force: true }).catch((error) =>
2186
+ application.logger.warn(`Deployed ${application.name} but could not remove its activation journal:`, error)
2187
+ );
2188
+ await rm(deploymentDirPath, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }).catch((error) =>
2189
+ application.logger.warn(`Deployed ${application.name} but could not clean up its staging directory:`, error)
2190
+ );
2191
+ await rmdir(dirname(deploymentDirPath)).catch(() => {});
2192
+ }
2193
+ }
2194
+
2195
+ /**
2196
+ * Marks a failure where compensation ITSELF failed, so the previous version is not back and the live path
2197
+ * may be absent. The candidate, its `.complete` marker and its journal are then the only way back — recovery
2198
+ * rolls that state forward — so they must survive, and the caller keys on this to skip discarding them.
2199
+ */
2200
+ const COMPENSATION_INCOMPLETE = Symbol('compensationIncomplete');
2201
+
2202
+ function compensationIncomplete(error: unknown): boolean {
2203
+ return Boolean((error as any)?.[COMPENSATION_INCOMPLETE]);
2204
+ }
2205
+
2206
+ /**
2207
+ * Undo an activation effect, folding a compensation failure into the original error rather than replacing
2208
+ * it — the first error is what the operator needs, the second is why the node still needs attention.
2209
+ */
2210
+ async function compensate(
2211
+ error: unknown,
2212
+ what: string,
2213
+ undo: () => Promise<void>,
2214
+ application: Application
2215
+ ): Promise<void> {
2216
+ try {
2217
+ await undo();
2218
+ } catch (undoError) {
2219
+ // Whatever blocked the original operation plausibly blocks its undo too — a rename into a path
2220
+ // something else holds open fails the same way twice.
2221
+ const failure = new AggregateError(
2222
+ [error, undoError],
2223
+ `Failed to ${what} for ${application.name}: ${errorMessage(error)}; ` +
2224
+ `also failed to restore the previous version: ${errorMessage(undoError)}`
2225
+ );
2226
+ (failure as any)[COMPENSATION_INCOMPLETE] = true;
2227
+ throw failure;
2228
+ }
2229
+ }
2230
+
2231
+ /**
2232
+ * Re-point dependency links that name the candidate's build path, now that it has become live. npm links a
2233
+ * `file:` dependency relatively on POSIX (survives the rename) but as an ABSOLUTE junction on Windows, which
2234
+ * then names a staging path that no longer exists. Rewriting beats `--install-links`, which would change
2235
+ * dependency semantics on every platform to fix one.
2236
+ */
2237
+ async function repairRelocatedDependencyLinks(liveDirPath: string, builtAtPath: string): Promise<void> {
2238
+ const relinkOne = async (entryPath: string) => {
2239
+ let target: string;
2240
+ try {
2241
+ target = await readlink(entryPath);
2242
+ } catch {
2243
+ return;
2244
+ }
2245
+ const normalized = stripExtendedLengthPrefix(target);
2246
+ // Containment, not a prefix match: `startsWith` classifies `<build>-shared` as inside `<build>` and
2247
+ // would rewrite it to an unrelated live path.
2248
+ const within = relative(builtAtPath, normalized);
2249
+ if (within.startsWith('..') || isAbsolute(within)) return;
2250
+ const repaired = join(liveDirPath, within);
2251
+ // The replacement is created BEFORE the old link is dropped, and swapped in by rename. A
2252
+ // remove-then-create loses the dependency outright when the create fails.
2253
+ const stagedLink = `${entryPath}.relink-${process.pid}-${randomUUID()}`;
2254
+ try {
2255
+ await symlink(repaired, stagedLink, 'junction');
2256
+ try {
2257
+ await rename(stagedLink, entryPath);
2258
+ } catch (renameError) {
2259
+ // Windows cannot rename over an existing junction, so the old one has to go first — and if the
2260
+ // second rename then fails the same way, the original target is put back rather than leaving
2261
+ // nothing behind.
2262
+ if (process.platform !== 'win32') throw renameError;
2263
+ await rm(entryPath, { recursive: true, force: true });
2264
+ try {
2265
+ await rename(stagedLink, entryPath);
2266
+ } catch (secondError) {
2267
+ await symlink(normalized, entryPath, 'junction').catch(() => {});
2268
+ throw secondError;
2269
+ }
2270
+ }
2271
+ } catch (error) {
2272
+ await rm(stagedLink, { recursive: true, force: true }).catch(() => {});
2273
+ logger.warn(`Could not re-point ${entryPath} after activation: ${errorMessage(error)}`);
2274
+ }
2275
+ };
684
2276
 
685
- if (!allowScripts && !gitRef && looksLikeGitReference(application.packageIdentifier)) {
686
- // Recognized as git, but a form the reclone-and-strip-scripts path above can't safely
687
- // handle (a `#path:` committish, or a hosted shorthand other than a plain `owner/repo`) —
688
- // fail loudly rather than silently falling through to the unreliable `npm pack
689
- // --ignore-scripts` below.
690
- throw new Error(
691
- `Cannot deploy git-reference package '${application.packageIdentifier}' with install scripts disallowed: this identifier's form (e.g. a '#path:' committish, or a hosted shorthand other than a plain 'owner/repo') isn't one this repo's script-suppression handling supports. Set install.allowInstallScripts to true, or use a plain git URL with a branch/tag/commit committish instead.`
692
- );
2277
+ // ONE bounded pool over a shared queue, not per-directory concurrency: bounding each parent
2278
+ // independently let every one of N workers start N more, so a deep pnpm or monorepo tree fanned out to
2279
+ // thousands of simultaneous opens. An EMFILE there would surface as skipped subtrees and dangling links.
2280
+ const pending: string[] = [join(liveDirPath, 'node_modules')];
2281
+ let active = 0;
2282
+ const visit = async (dirPath: string): Promise<void> => {
2283
+ let entries;
2284
+ try {
2285
+ entries = await readdir(dirPath, { withFileTypes: true });
2286
+ } catch (error) {
2287
+ // Not silent. A missing directory is ordinary — the tree has no `node_modules`, or an install
2288
+ // removed one — but an EACCES or EMFILE here means links under it were never examined, which is
2289
+ // exactly the state that leaves a component running against a dangling dependency.
2290
+ if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') {
2291
+ logger.warn(`Could not scan ${dirPath} for links to re-point after activation:`, errorForLog(error));
2292
+ }
2293
+ return;
2294
+ }
2295
+ // Every directory, not just `@scope` containers and nested `node_modules`: a dependency installed from
2296
+ // outside the tree can be linked from deeper in.
2297
+ for (const entry of entries) {
2298
+ const entryPath = join(dirPath, entry.name);
2299
+ if (entry.isSymbolicLink()) await relinkOne(entryPath);
2300
+ else if (entry.isDirectory()) pending.push(entryPath);
2301
+ }
2302
+ };
2303
+ const worker = async (): Promise<void> => {
2304
+ for (;;) {
2305
+ const next = pending.pop();
2306
+ if (next === undefined) {
2307
+ // Another worker may still be about to enqueue children, so only stop once nothing is in flight.
2308
+ if (active === 0) return;
2309
+ await new Promise((resolve) => setImmediate(resolve));
2310
+ continue;
693
2311
  }
2312
+ active++;
2313
+ try {
2314
+ await visit(next);
2315
+ } finally {
2316
+ active--;
2317
+ }
2318
+ }
2319
+ };
2320
+ await Promise.all(Array.from({ length: LINK_REPAIR_CONCURRENCY }, worker));
2321
+ }
694
2322
 
695
- if (gitRef) {
696
- tarballPath = await packGitReferenceWithoutScripts(application, gitRef, parentDirPath);
697
- } else {
698
- const packArgs = ['pack', '--json', application.packageIdentifier];
699
- if (!allowScripts) {
700
- packArgs.push('--ignore-scripts');
701
- } else if (application.gitCredentialEnv) {
702
- application.logger.warn(
703
- `Deploying ${application.name} from a git reference with install scripts enabled: the repository's ` +
704
- `prepare/build scripts and its dependencies' install scripts run on this node during the clone and ` +
705
- `can read the git credential. Unset install_allow_scripts to keep the credential out of their reach.`
2323
+ /** Windows junction targets come back with an extended-length `\\?\` prefix that plain paths never have. */
2324
+ function stripExtendedLengthPrefix(target: string): string {
2325
+ return target.startsWith('\\\\?\\') ? target.slice(4) : target;
2326
+ }
2327
+
2328
+ /** Remove a candidate's whole deployment directory, best-effort — it is never the last good copy. */
2329
+ async function discardCandidate(application: Application, deploymentId: string): Promise<void> {
2330
+ const deploymentDirPath = candidateDeploymentDirPath(application.dirPath, deploymentId);
2331
+ await rm(deploymentDirPath, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }).catch((error) =>
2332
+ application.logger.warn(`Failed to remove the abandoned deploy candidate at ${deploymentDirPath}:`, error)
2333
+ );
2334
+ // And the staging root itself once nothing is in it, so an idle install leaves the components root as
2335
+ // it found it. ENOTEMPTY just means a concurrent deploy still owns a candidate.
2336
+ await rmdir(dirname(deploymentDirPath)).catch(() => {});
2337
+ }
2338
+
2339
+ /**
2340
+ * Build a deploy candidate at `.deploy-staging/<deploymentId>/<component>`, leaving the live tree
2341
+ * completely untouched — this is what lets the previous version keep serving through the clone, the
2342
+ * extraction and the dependency install.
2343
+ *
2344
+ * Failure needs no compensation, which is the whole point: nothing about the live component was modified,
2345
+ * so the abandoned candidate is simply removed and the error propagates.
2346
+ */
2347
+ export async function buildCandidateApplication(application: Application, deploymentId: string): Promise<string> {
2348
+ const deploymentDirPath = candidateDeploymentDirPath(application.dirPath, deploymentId);
2349
+ const candidateDirPath = candidateApplicationPath(application.dirPath, deploymentId);
2350
+ await ensureSecureStagingDirectory(dirname(deploymentDirPath));
2351
+ await ensureSecureStagingDirectory(deploymentDirPath);
2352
+ try {
2353
+ // Replaced, not extracted into: a prior attempt on this id may have left a partial tree.
2354
+ await rm(candidateDirPath, { recursive: true, force: true });
2355
+ const resolved = await resolveApplicationTarball(application);
2356
+ if (resolved.kind === 'link') {
2357
+ // A `file:` directory becomes a symlink AT THE CANDIDATE PATH, so it is validated and swapped in
2358
+ // like any other candidate instead of appearing at the live path unvalidated.
2359
+ await symlink(resolved.sourceDirPath, candidateDirPath, 'dir');
2360
+ } else {
2361
+ const { tarball, tarballPath, shouldDeleteTarball } = resolved;
2362
+ try {
2363
+ await extractTarballInto(tarball, candidateDirPath, deploymentDirPath);
2364
+ } finally {
2365
+ if (!tarball.destroyed) tarball.destroy();
2366
+ if (shouldDeleteTarball && tarballPath) {
2367
+ await rm(tarballPath, { force: true }).catch((error) =>
2368
+ application.logger.warn(`Failed to remove temporary package ${tarballPath}:`, error)
706
2369
  );
707
2370
  }
708
- tarballPath = await runNpmPack(application, packArgs, parentDirPath, application.gitCredentialEnv);
709
2371
  }
710
- shouldDeleteTarball = true;
711
- tarball = createReadStream(tarballPath);
712
2372
  }
2373
+ // The credential socket only has to be up for extraction — that is where npm resolves and clones a
2374
+ // git-reference package. Closed BEFORE the install so the dependency tree's own install scripts,
2375
+ // which are arbitrary code from the registry running as this uid, cannot ask the helper for the
2376
+ // deployer's git token. `prepareApplication`'s finally still calls this; it is idempotent.
2377
+ await application.cleanupGitCredentialSession();
2378
+ await installApplication(application, candidateDirPath);
2379
+ return candidateDirPath;
2380
+ } catch (error) {
2381
+ await discardCandidate(application, deploymentId);
2382
+ throw error;
713
2383
  }
2384
+ }
714
2385
 
715
- // Replace any existing component directory atomically instead of clearing it in
716
- // place. A previous version's worker can still be running and actively writing
717
- // into this directory — e.g. a live Next.js app writing into `.next/cache` — and
718
- // an in-place recursive rm races that writer: rm empties `.next`, then its leaf
719
- // `rmdir('.next')` fails with ENOTEMPTY because the worker just re-created a cache
720
- // entry. (`force: true` only suppresses ENOENT; ENOTEMPTY is not retried unless
721
- // `maxRetries` is set, and a continuously-writing app would outlast retries
722
- // anyway.) Renaming the old directory aside is atomic and immune to the race: the
723
- // still-running worker keeps writing into the renamed inode harmlessly until it's
724
- // replaced on restart, and the aside copy is removed best-effort below.
725
- //
726
- // The aside lives under a hidden, component-scoped staging directory inside the
727
- // components root: same filesystem as the source so the rename stays atomic, the
728
- // leading dot keeps loadComponentDirectories from picking it up as a phantom
729
- // component, and the per-component path means a sibling component never collides
730
- // with (or sweeps) another's aside.
731
- const asideStagingDir = join(dirname(application.dirPath), ASIDE_STAGING_DIR, basename(application.dirPath));
732
- let asidePath: string | undefined;
733
- try {
734
- await access(application.dirPath, constants.F_OK);
735
- await mkdir(asideStagingDir, { recursive: true });
736
- const candidateAsidePath = join(asideStagingDir, `${process.pid}-${Date.now()}-${randomUUID()}`);
737
- await rename(application.dirPath, candidateAsidePath);
738
- asidePath = candidateAsidePath;
739
- } catch (err) {
740
- // Ignore does not exist error
741
- if (err.code !== 'ENOENT') {
742
- throw err;
2386
+ async function recoverOrCleanupStaleExtractionPaths(
2387
+ application: ExtractionContext,
2388
+ asideStagingDir: string
2389
+ ): Promise<void> {
2390
+ const entries = await readdir(asideStagingDir, { withFileTypes: true });
2391
+ const entryNames = new Set(entries.map((entry) => entry.name));
2392
+ const paths = new Set<string>(entries.map((entry) => join(asideStagingDir, entry.name)));
2393
+ const recoveryRecords = entries
2394
+ .filter(
2395
+ (entry) =>
2396
+ isExtractionRecoveryRecord(entry) &&
2397
+ entry.name.startsWith(IN_PROGRESS_ASIDE_PREFIX) &&
2398
+ !entryNames.has(`${RETIRED_ASIDE_PREFIX}${entry.name.slice(IN_PROGRESS_ASIDE_PREFIX.length)}`)
2399
+ )
2400
+ .map((entry) => ({
2401
+ entry,
2402
+ priorStateAbsent: isPriorAbsentRecoveryRecord(entry),
2403
+ timestamp: extractionAsideTimestamp(entry.name),
2404
+ }))
2405
+ .filter(({ timestamp }) => Number.isFinite(timestamp))
2406
+ .sort((left, right) => right.timestamp - left.timestamp);
2407
+ const recoveryRecord =
2408
+ recoveryRecords.find(({ priorStateAbsent }) => !priorStateAbsent) ??
2409
+ recoveryRecords.find(({ priorStateAbsent }) => priorStateAbsent);
2410
+ if (recoveryRecord) {
2411
+ // A journal outranks the record. Without this the pass restores the tree a completed activation
2412
+ // displaced, back over the candidate it committed — the inversion the journal exists to prevent, and
2413
+ // reachable on any thread whose settlement did not run or did not succeed. Enforced HERE, at the one
2414
+ // place a tree is restored, so every entry point is covered by construction rather than by each
2415
+ // caller remembering to settle first — and so a component with nothing left to restore still loads.
2416
+ const journaled = await journaledDeploymentForComponent(dirname(application.dirPath), application.name);
2417
+ if (journaled) {
2418
+ throw new Error(
2419
+ `Refusing to restore ${application.name} from ${recoveryRecord.entry.name}: the interrupted ` +
2420
+ `activation in ${journaled} is not settled, and its journal is the only record of which tree ` +
2421
+ `is current. If that journal names no component at all it cannot settle itself; remove that ` +
2422
+ `directory once you have determined which tree is current.`
2423
+ );
2424
+ }
2425
+ const recoveryPath = join(asideStagingDir, recoveryRecord.entry.name);
2426
+ // Retire the losing candidates durably; a cleanup that fails must not let a later
2427
+ // pass adopt one of them and restore an older tree over the one recovered here.
2428
+ for (const { entry } of recoveryRecords) {
2429
+ if (entry === recoveryRecord.entry) continue;
2430
+ paths.add(await retireExtractionAside(join(asideStagingDir, entry.name)));
743
2431
  }
2432
+ await rollbackExtractedDirectory(
2433
+ application,
2434
+ asideStagingDir,
2435
+ recoveryRecord.priorStateAbsent ? undefined : recoveryPath,
2436
+ paths,
2437
+ false
2438
+ );
2439
+ application.logger.warn(
2440
+ (recoveryRecord.priorStateAbsent
2441
+ ? `Removed the partial ${application.name} component directory after an interrupted first deploy`
2442
+ : `Recovered the previous ${application.name} component directory after an interrupted deploy`) +
2443
+ (recoveryRecords.length > 1 ? `; discarded ${recoveryRecords.length - 1} older recovery candidates` : '')
2444
+ );
2445
+ return;
744
2446
  }
745
- // A directory existed for this component name prior to this deploy, so this is a redeploy of
746
- // an already-active component rather than a first-time deploy. See `isNewComponent` above.
747
- if (asidePath) application.isNewComponent = false;
2447
+ await cleanupExtractionPaths(application, asideStagingDir, paths);
2448
+ }
2449
+
2450
+ function isPriorAbsentRecoveryRecord(entry: { isFile(): boolean; name: string }): boolean {
2451
+ return entry.isFile() && entry.name.endsWith(PRIOR_ABSENT_RECORD_SUFFIX);
2452
+ }
2453
+
2454
+ function isExtractionRecoveryRecord(entry: {
2455
+ isDirectory(): boolean;
2456
+ isFile(): boolean;
2457
+ isSymbolicLink(): boolean;
2458
+ name: string;
2459
+ }): boolean {
2460
+ return entry.isDirectory() || entry.isSymbolicLink() || isPriorAbsentRecoveryRecord(entry);
2461
+ }
2462
+
2463
+ function extractionAsideTimestamp(name: string): number {
2464
+ const timestampEnd = name.indexOf('-', IN_PROGRESS_ASIDE_PREFIX.length);
2465
+ if (timestampEnd < 0) return Number.NaN;
2466
+ return Number(name.slice(IN_PROGRESS_ASIDE_PREFIX.length, timestampEnd));
2467
+ }
2468
+
2469
+ export async function recoverInterruptedComponentExtractions(
2470
+ componentsRootDirPath: string
2471
+ ): Promise<Map<string, Error>> {
2472
+ const stagingRoot = join(componentsRootDirPath, ASIDE_STAGING_DIR);
2473
+ let entries;
748
2474
  try {
749
- await mkdir(application.dirPath, { recursive: true });
750
- await pipeline(tarball, gunzip(), extract(application.dirPath));
751
-
752
- const extracted = await readdir(application.dirPath, { withFileTypes: true });
753
- if (extracted.length === 1 && extracted[0].isDirectory()) {
754
- const topLevelDirPath = join(application.dirPath, extracted[0].name);
755
- await mkdir(asideStagingDir, { recursive: true });
756
- const tempDirPath = await mkdtemp(join(asideStagingDir, '.normalize-'));
757
- await cp(topLevelDirPath, tempDirPath, { recursive: true });
758
- await rm(topLevelDirPath, { recursive: true, force: true });
759
- await cp(tempDirPath, application.dirPath, { recursive: true });
760
- await rm(tempDirPath, { recursive: true, force: true });
2475
+ const stagingStat = await lstat(stagingRoot);
2476
+ if (!stagingStat.isDirectory() || stagingStat.isSymbolicLink()) {
2477
+ throw new Error(`Component deploy staging path is not a directory: ${stagingRoot}`);
761
2478
  }
2479
+ entries = await readdir(stagingRoot, { withFileTypes: true });
762
2480
  } catch (error) {
763
- try {
764
- await rollbackExtractedDirectory(application, asideStagingDir, asidePath);
765
- } catch (rollbackError) {
766
- throw new AggregateError(
767
- [error, rollbackError],
768
- `Failed to extract ${application.name} and restore its previous component directory`
2481
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return new Map();
2482
+ throw error;
2483
+ }
2484
+ const failedComponents = new Map<string, Error>();
2485
+ await Promise.all(
2486
+ entries
2487
+ .filter((entry) => entry.isDirectory())
2488
+ .map(async (entry) => {
2489
+ try {
2490
+ await recoverInterruptedComponentExtraction(componentsRootDirPath, entry.name, false);
2491
+ } catch (error) {
2492
+ const recoveryError = error instanceof Error ? error : new Error(String(error));
2493
+ failedComponents.set(entry.name, recoveryError);
2494
+ const deferred = recoveryError instanceof ComponentPreparationLockTimeoutError;
2495
+ logger[deferred ? 'warn' : 'error'](
2496
+ `${deferred ? 'Deferring' : 'Not loading'} ${entry.name} because its interrupted component deployment ` +
2497
+ `${deferred ? 'is still being prepared' : 'could not be recovered'}:`,
2498
+ errorForLog(recoveryError)
2499
+ );
2500
+ }
2501
+ })
2502
+ );
2503
+ return failedComponents;
2504
+ }
2505
+
2506
+ export async function recoverInterruptedComponentExtraction(
2507
+ componentsRootDirPath: string,
2508
+ componentName: string,
2509
+ waitForPreparation = true,
2510
+ waitTimeoutMs = COMPONENT_RECOVERY_WAIT_TIMEOUT_MS
2511
+ ): Promise<void> {
2512
+ const componentDirPath = join(componentsRootDirPath, componentName);
2513
+ await withComponentPreparationLock(
2514
+ componentDirPath,
2515
+ async () => {
2516
+ const asideStagingDir = extractionStagingDirectory(componentDirPath);
2517
+ try {
2518
+ await lstat(asideStagingDir);
2519
+ } catch (error) {
2520
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return;
2521
+ throw error;
2522
+ }
2523
+ await ensureExtractionStagingDirectory(asideStagingDir);
2524
+ await recoverOrCleanupStaleExtractionPaths(
2525
+ { name: componentName, dirPath: componentDirPath, logger },
2526
+ asideStagingDir
769
2527
  );
2528
+ },
2529
+ {
2530
+ timeoutMs: waitForPreparation ? waitTimeoutMs : COMPONENT_RECOVERY_TRY_TIMEOUT_MS,
2531
+ purpose: COMPONENT_RECOVERY_LOCK_PURPOSE,
2532
+ renewTimeoutWhileOwnerAlive: waitForPreparation,
2533
+ onWait: (owner) => {
2534
+ logger.info(
2535
+ `Waiting to settle component deployment state for ${componentName}` +
2536
+ (owner ? ` held by process ${owner.pid}, thread ${owner.threadId}` : '')
2537
+ );
2538
+ },
2539
+ isOwnerAlive: (owner) => owner.pid !== process.pid || isThreadRunning(owner.threadId),
770
2540
  }
2541
+ );
2542
+ }
2543
+
2544
+ export async function retireComponentExtractionStaging(
2545
+ componentDirPath: string,
2546
+ componentName = basename(componentDirPath),
2547
+ componentLogger: Logger = logger
2548
+ ): Promise<void> {
2549
+ const asideStagingDir = extractionStagingDirectory(componentDirPath);
2550
+ let entries;
2551
+ try {
2552
+ await lstat(asideStagingDir);
2553
+ await ensureExtractionStagingDirectory(asideStagingDir);
2554
+ entries = await readdir(asideStagingDir, { withFileTypes: true });
2555
+ } catch (error) {
2556
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return;
771
2557
  throw error;
772
2558
  }
773
- // Clean up the original tarball
774
- if (shouldDeleteTarball && tarballPath) {
775
- await rm(tarballPath, { force: true }).catch((error) =>
776
- application.logger.warn(`Failed to remove temporary package ${tarballPath}:`, error)
777
- );
2559
+ const paths = new Set<string>(entries.map((entry) => join(asideStagingDir, entry.name)));
2560
+ for (const entry of entries) {
2561
+ if (!isExtractionRecoveryRecord(entry) || !entry.name.startsWith(IN_PROGRESS_ASIDE_PREFIX)) continue;
2562
+ const markerPath = retiredMarkerForAside(join(asideStagingDir, entry.name));
2563
+ try {
2564
+ await writeFile(markerPath, '', { flag: 'wx', mode: 0o600 });
2565
+ } catch (error) {
2566
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
2567
+ }
2568
+ paths.add(markerPath);
778
2569
  }
779
-
780
- // Remove this component's aside copies. The old worker may still hold files open
781
- // in the just-renamed copy (the live writer that motivated the rename), so this is
782
- // best-effort: removing the whole staging subdirectory also clears leftovers from
783
- // earlier deploys whose workers have since exited, and a copy that survives because
784
- // its worker is still live is swept by the next deploy. The failure is expected in
785
- // the live-worker case, so it's logged at trace rather than as a warning.
786
- let settled = false;
787
- const transaction: ExtractionTransaction = {
788
- async commit() {
789
- if (settled) return;
790
- settled = true;
791
- await cleanupExtractionStaging(application, asideStagingDir);
792
- },
793
- async rollback() {
794
- if (settled) return;
795
- settled = true;
796
- await rollbackExtractedDirectory(application, asideStagingDir, asidePath);
797
- },
798
- };
799
- if (deferCommit) return transaction;
800
- await transaction.commit();
2570
+ await cleanupExtractionPaths(
2571
+ { name: componentName, dirPath: componentDirPath, logger: componentLogger },
2572
+ asideStagingDir,
2573
+ paths
2574
+ );
801
2575
  }
802
2576
 
803
- async function cleanupExtractionStaging(application: Application, asideStagingDir: string): Promise<void> {
2577
+ export async function dropComponentDirectory(
2578
+ componentDirPath: string,
2579
+ componentName = basename(componentDirPath),
2580
+ componentLogger: Logger = logger
2581
+ ): Promise<void> {
2582
+ await retireComponentExtractionStaging(componentDirPath, componentName, componentLogger);
2583
+ const asideStagingDir = extractionStagingDirectory(componentDirPath);
2584
+ await ensureExtractionStagingDirectory(asideStagingDir);
2585
+ const droppedPath = join(asideStagingDir, `.dropped-${process.pid}-${Date.now()}-${randomUUID()}`);
804
2586
  try {
805
- await rm(asideStagingDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
2587
+ await rename(componentDirPath, droppedPath);
806
2588
  } catch (error) {
807
- logger.trace?.(`Cleanup of previous ${application.name} component directory deferred: ${error.message}`);
2589
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
2590
+ }
2591
+ await cleanupExtractionPaths(
2592
+ { name: componentName, dirPath: componentDirPath, logger: componentLogger },
2593
+ asideStagingDir,
2594
+ new Set([droppedPath])
2595
+ );
2596
+ }
2597
+
2598
+ async function cleanupExtractionPaths(
2599
+ application: ExtractionContext,
2600
+ asideStagingDir: string,
2601
+ paths: Set<string>
2602
+ ): Promise<void> {
2603
+ const retiredMarkers: string[] = [];
2604
+ for (const path of paths) {
2605
+ if (basename(path).startsWith(RETIRED_ASIDE_PREFIX)) {
2606
+ retiredMarkers.push(path);
2607
+ continue;
2608
+ }
2609
+ try {
2610
+ await rm(path, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
2611
+ } catch (error) {
2612
+ application.logger.trace?.(
2613
+ `Cleanup of previous ${application.name} component directory deferred: ${errorMessage(error)}`
2614
+ );
2615
+ }
2616
+ }
2617
+ for (const markerPath of retiredMarkers) {
2618
+ const asidePath = join(
2619
+ asideStagingDir,
2620
+ `${IN_PROGRESS_ASIDE_PREFIX}${basename(markerPath).slice(RETIRED_ASIDE_PREFIX.length)}`
2621
+ );
2622
+ try {
2623
+ await access(asidePath, constants.F_OK);
2624
+ continue;
2625
+ } catch (error) {
2626
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') continue;
2627
+ }
2628
+ await rm(markerPath, { force: true }).catch((error) =>
2629
+ application.logger.trace?.(
2630
+ `Cleanup of previous ${application.name} component directory deferred: ${errorMessage(error)}`
2631
+ )
2632
+ );
808
2633
  }
2634
+ await rmdir(asideStagingDir).catch((error) => {
2635
+ if (!['ENOENT', 'ENOTEMPTY'].includes((error as NodeJS.ErrnoException).code ?? '')) {
2636
+ application.logger.trace?.(
2637
+ `Cleanup of ${application.name} deploy staging directory deferred: ${errorMessage(error)}`
2638
+ );
2639
+ }
2640
+ });
809
2641
  }
810
2642
 
811
2643
  async function rollbackExtractedDirectory(
812
- application: Application,
2644
+ application: ExtractionContext,
813
2645
  asideStagingDir: string,
814
- asidePath: string | undefined
2646
+ asidePath: string | undefined,
2647
+ transactionPaths: Set<string>,
2648
+ retainReplacement: boolean
815
2649
  ): Promise<void> {
816
- await mkdir(asideStagingDir, { recursive: true });
817
- const retryableRenameCodes = new Set(['EEXIST', 'ENOTEMPTY', 'EPERM', 'EACCES', 'EBUSY']);
818
- const displaceCurrentDirectory = async () => {
2650
+ await ensureExtractionStagingDirectory(asideStagingDir);
2651
+ const retryableRenameCodes = new Set(['EEXIST', 'ENOTEMPTY', 'ENOTDIR', 'EISDIR', 'EPERM', 'EACCES', 'EBUSY']);
2652
+ const displaceCurrentDirectorySync = (): string | undefined => {
819
2653
  const displacedPath = join(asideStagingDir, `.failed-${process.pid}-${Date.now()}-${randomUUID()}`);
2654
+ try {
2655
+ renameSync(application.dirPath, displacedPath);
2656
+ transactionPaths.add(displacedPath);
2657
+ return displacedPath;
2658
+ } catch (error) {
2659
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
2660
+ throw error;
2661
+ }
2662
+ };
2663
+ const displaceCurrentDirectory = async (): Promise<string | undefined> => {
2664
+ const retryDeadline = Date.now() + 5000;
820
2665
  let lastError: unknown;
821
- for (let attempt = 0; attempt < 100; attempt++) {
2666
+ do {
2667
+ const displacedPath = join(asideStagingDir, `.failed-${process.pid}-${Date.now()}-${randomUUID()}`);
822
2668
  try {
823
2669
  await rename(application.dirPath, displacedPath);
824
- return;
2670
+ transactionPaths.add(displacedPath);
2671
+ return displacedPath;
825
2672
  } catch (error) {
826
2673
  const code = (error as NodeJS.ErrnoException).code;
827
- if (code === 'ENOENT') return;
2674
+ if (code === 'ENOENT') return undefined;
828
2675
  if (!retryableRenameCodes.has(code ?? '')) throw error;
829
2676
  lastError = error;
830
2677
  await delay(10);
831
2678
  }
832
- }
2679
+ } while (Date.now() < retryDeadline);
833
2680
  throw lastError;
834
2681
  };
835
2682
 
836
- await displaceCurrentDirectory();
837
2683
  if (asidePath) {
838
- let restored = false;
2684
+ const asideIsSymbolicLink = (await lstat(asidePath)).isSymbolicLink();
839
2685
  let restoreError: unknown;
840
- for (let attempt = 0; attempt < 100; attempt++) {
2686
+ let restoreRetryDeadline: number | undefined;
2687
+ let fallbackDisplacedPath: string | undefined;
2688
+ let placeholderIdentity = await identifyRollbackPlaceholder(application.dirPath);
2689
+ const failRestore = async (error: unknown): Promise<never> => {
2690
+ try {
2691
+ await makeRollbackPlaceholderMovable(application.dirPath, placeholderIdentity);
2692
+ if (retainReplacement && fallbackDisplacedPath) {
2693
+ const fallbackRetryDeadline = Date.now() + 5000;
2694
+ let fallbackRestoreError: unknown;
2695
+ do {
2696
+ let writerDisplacedPath: string | undefined;
2697
+ try {
2698
+ writerDisplacedPath = displaceCurrentDirectorySync();
2699
+ placeholderIdentity = undefined;
2700
+ renameSync(fallbackDisplacedPath, application.dirPath);
2701
+ fallbackRestoreError = undefined;
2702
+ } catch (restoreFallbackError) {
2703
+ fallbackRestoreError = restoreFallbackError;
2704
+ }
2705
+ if (writerDisplacedPath) {
2706
+ await rm(writerDisplacedPath, {
2707
+ recursive: true,
2708
+ force: true,
2709
+ maxRetries: 3,
2710
+ retryDelay: 100,
2711
+ });
2712
+ transactionPaths.delete(writerDisplacedPath);
2713
+ }
2714
+ if (
2715
+ fallbackRestoreError &&
2716
+ !retryableRenameCodes.has((fallbackRestoreError as NodeJS.ErrnoException).code ?? '')
2717
+ ) {
2718
+ throw fallbackRestoreError;
2719
+ }
2720
+ if (fallbackRestoreError) {
2721
+ await delay(10);
2722
+ } else {
2723
+ break;
2724
+ }
2725
+ } while (Date.now() < fallbackRetryDeadline);
2726
+ if (fallbackRestoreError) throw fallbackRestoreError;
2727
+ transactionPaths.delete(fallbackDisplacedPath);
2728
+ transactionPaths.add(await retireExtractionAside(asidePath));
2729
+ }
2730
+ if (placeholderIdentity) {
2731
+ try {
2732
+ const current = await lstat(application.dirPath, { bigint: true });
2733
+ if (current.dev === placeholderIdentity.dev && current.ino === placeholderIdentity.ino) {
2734
+ await rm(application.dirPath, {
2735
+ recursive: true,
2736
+ force: true,
2737
+ maxRetries: 3,
2738
+ retryDelay: 100,
2739
+ });
2740
+ placeholderIdentity = undefined;
2741
+ }
2742
+ } catch (placeholderError) {
2743
+ if ((placeholderError as NodeJS.ErrnoException).code !== 'ENOENT') throw placeholderError;
2744
+ }
2745
+ }
2746
+ const disposablePaths = new Set(transactionPaths);
2747
+ disposablePaths.delete(asidePath);
2748
+ await cleanupExtractionPaths(application, asideStagingDir, disposablePaths);
2749
+ } catch (fallbackError) {
2750
+ throw new AggregateError(
2751
+ [error, fallbackError],
2752
+ `Failed to restore either the previous or replacement ${application.name} component directory`
2753
+ );
2754
+ }
2755
+ throw new Error(
2756
+ `Failed to restore ${asidePath} to the live component directory ${application.dirPath}: ${errorMessage(error)}`,
2757
+ { cause: error }
2758
+ );
2759
+ };
2760
+ do {
841
2761
  try {
842
- await rename(asidePath, application.dirPath);
843
- restored = true;
844
- break;
2762
+ if (placeholderIdentity) {
2763
+ const current = lstatSync(application.dirPath, { bigint: true });
2764
+ if (current.dev === placeholderIdentity.dev && current.ino === placeholderIdentity.ino) {
2765
+ chmodSync(application.dirPath, 0o700);
2766
+ renameSync(asidePath, application.dirPath);
2767
+ } else {
2768
+ placeholderIdentity = undefined;
2769
+ await rename(asidePath, application.dirPath);
2770
+ }
2771
+ } else {
2772
+ await rename(asidePath, application.dirPath);
2773
+ }
2774
+ transactionPaths.delete(asidePath);
2775
+ await cleanupExtractionPaths(application, asideStagingDir, transactionPaths);
2776
+ return;
845
2777
  } catch (error) {
846
2778
  restoreError = error;
847
- if (!retryableRenameCodes.has((error as NodeJS.ErrnoException).code ?? '')) break;
848
- await displaceCurrentDirectory();
2779
+ if (!retryableRenameCodes.has((error as NodeJS.ErrnoException).code ?? '')) {
2780
+ return failRestore(error);
2781
+ }
2782
+ let displacedPath: string | undefined;
2783
+ const displacedPlaceholderIdentity = placeholderIdentity;
2784
+ try {
2785
+ await makeRollbackPlaceholderMovable(application.dirPath, placeholderIdentity);
2786
+ displacedPath = await displaceCurrentDirectory();
2787
+ placeholderIdentity = undefined;
2788
+ if (displacedPath && displacedPlaceholderIdentity) {
2789
+ try {
2790
+ const displaced = await lstat(displacedPath, { bigint: true });
2791
+ if (
2792
+ displaced.dev === displacedPlaceholderIdentity.dev &&
2793
+ displaced.ino === displacedPlaceholderIdentity.ino
2794
+ ) {
2795
+ const displacedPlaceholderPath = displacedPath;
2796
+ displacedPath = undefined;
2797
+ await rm(displacedPlaceholderPath, {
2798
+ recursive: true,
2799
+ force: true,
2800
+ maxRetries: 3,
2801
+ retryDelay: 100,
2802
+ });
2803
+ transactionPaths.delete(displacedPlaceholderPath);
2804
+ }
2805
+ } catch (placeholderCleanupError) {
2806
+ application.logger.trace?.(
2807
+ `Cleanup of the ${application.name} rollback placeholder deferred: ${errorMessage(placeholderCleanupError)}`
2808
+ );
2809
+ }
2810
+ }
2811
+ } catch (displaceError) {
2812
+ return failRestore(
2813
+ new AggregateError(
2814
+ [error, displaceError],
2815
+ `Failed to clear the live ${application.name} component directory for rollback`
2816
+ )
2817
+ );
2818
+ }
2819
+ if (displacedPath) {
2820
+ if (fallbackDisplacedPath) {
2821
+ try {
2822
+ await rm(displacedPath, {
2823
+ recursive: true,
2824
+ force: true,
2825
+ maxRetries: 3,
2826
+ retryDelay: 100,
2827
+ });
2828
+ transactionPaths.delete(displacedPath);
2829
+ } catch (cleanupError) {
2830
+ return failRestore(
2831
+ new AggregateError(
2832
+ [error, cleanupError],
2833
+ `Failed to discard a displaced ${application.name} writer directory during rollback`
2834
+ )
2835
+ );
2836
+ }
2837
+ } else {
2838
+ fallbackDisplacedPath = displacedPath;
2839
+ }
2840
+ }
2841
+ restoreRetryDeadline ??= Date.now() + 5000;
2842
+ if (process.platform !== 'win32' && process.getuid?.() !== 0) {
2843
+ const stagedPlaceholderPath = join(
2844
+ asideStagingDir,
2845
+ `.rollback-placeholder-${process.pid}-${Date.now()}-${randomUUID()}`
2846
+ );
2847
+ try {
2848
+ if (asideIsSymbolicLink) {
2849
+ await writeFile(stagedPlaceholderPath, '', { flag: 'wx', mode: 0o000 });
2850
+ } else {
2851
+ await mkdir(stagedPlaceholderPath, { mode: 0o300 });
2852
+ }
2853
+ transactionPaths.add(stagedPlaceholderPath);
2854
+ let placeholderPlacementError: unknown;
2855
+ do {
2856
+ let writerDisplacedPath: string | undefined;
2857
+ try {
2858
+ writerDisplacedPath = displaceCurrentDirectorySync();
2859
+ renameSync(stagedPlaceholderPath, application.dirPath);
2860
+ if (!asideIsSymbolicLink) {
2861
+ try {
2862
+ chmodSync(application.dirPath, 0o100);
2863
+ } catch (chmodError) {
2864
+ try {
2865
+ renameSync(application.dirPath, stagedPlaceholderPath);
2866
+ } catch (compensationError) {
2867
+ throw new AggregateError(
2868
+ [chmodError, compensationError],
2869
+ `Failed to restrict and then restore the ${application.name} rollback placeholder`
2870
+ );
2871
+ }
2872
+ throw chmodError;
2873
+ }
2874
+ }
2875
+ transactionPaths.delete(stagedPlaceholderPath);
2876
+ placeholderPlacementError = undefined;
2877
+ } catch (placeholderError) {
2878
+ placeholderPlacementError = placeholderError;
2879
+ }
2880
+ if (writerDisplacedPath) {
2881
+ await rm(writerDisplacedPath, {
2882
+ recursive: true,
2883
+ force: true,
2884
+ maxRetries: 3,
2885
+ retryDelay: 100,
2886
+ });
2887
+ transactionPaths.delete(writerDisplacedPath);
2888
+ }
2889
+ if (
2890
+ placeholderPlacementError &&
2891
+ !retryableRenameCodes.has((placeholderPlacementError as NodeJS.ErrnoException).code ?? '')
2892
+ ) {
2893
+ throw placeholderPlacementError;
2894
+ }
2895
+ if (!placeholderPlacementError) break;
2896
+ await delay(10);
2897
+ } while (Date.now() < restoreRetryDeadline);
2898
+ if (transactionPaths.has(stagedPlaceholderPath)) {
2899
+ throw new Error(
2900
+ `Failed to place the ${application.name} rollback placeholder before the deadline: ${errorMessage(placeholderPlacementError)}`,
2901
+ { cause: placeholderPlacementError }
2902
+ );
2903
+ }
2904
+ const placeholder = await lstat(application.dirPath, { bigint: true });
2905
+ placeholderIdentity = { dev: placeholder.dev, ino: placeholder.ino };
2906
+ } catch (placeholderError) {
2907
+ return failRestore(
2908
+ new AggregateError(
2909
+ [error, placeholderError],
2910
+ `Failed to block a live ${application.name} writer during rollback`
2911
+ )
2912
+ );
2913
+ }
2914
+ }
849
2915
  await delay(10);
850
2916
  }
2917
+ } while (restoreRetryDeadline !== undefined && Date.now() < restoreRetryDeadline);
2918
+ return failRestore(restoreError);
2919
+ }
2920
+ try {
2921
+ await displaceCurrentDirectory();
2922
+ } catch (displaceError) {
2923
+ try {
2924
+ await rm(application.dirPath, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
2925
+ await cleanupExtractionPaths(application, asideStagingDir, transactionPaths);
2926
+ return;
2927
+ } catch (removeError) {
2928
+ throw new AggregateError(
2929
+ [displaceError, removeError],
2930
+ `Failed to remove the partial ${application.name} component directory after extraction failed`
2931
+ );
851
2932
  }
852
- if (!restored) throw restoreError;
853
2933
  }
2934
+ await cleanupExtractionPaths(application, asideStagingDir, transactionPaths);
2935
+ }
854
2936
 
855
- await cleanupExtractionStaging(application, asideStagingDir);
2937
+ /**
2938
+ * The one definition of how Harper invokes a package manager to install dependencies; every npm
2939
+ * entry point composes its arguments here rather than assembling its own.
2940
+ */
2941
+ export function packageManagerInstallArguments(
2942
+ packageManagerName: string,
2943
+ allowInstallScripts: boolean,
2944
+ force = false
2945
+ ): string[] {
2946
+ const args = ['install'];
2947
+ if (force) args.push('--force');
2948
+ if (packageManagerName === 'npm') args.push('--omit=dev', '--no-audit', '--no-fund');
2949
+ if (!allowInstallScripts) args.push('--ignore-scripts');
2950
+ return args;
856
2951
  }
857
2952
 
858
2953
  /**
859
- * Install an application to its relative `application.dirPath` using either a
860
- * configured `application.install` command, a derived package manager from the
861
- * application's `package.json#devEngines`, or falling back to the default
862
- * package manager, `npm`.
2954
+ * Install a component's dependencies into `buildDirPath` — the live path, or a candidate under
2955
+ * `.deploy-staging`. Explicit rather than repointing `application.dirPath`, which is read after preparation
2956
+ * too and would name a vanished directory if any failure path skipped the restore.
863
2957
  *
864
- * Will return early if `node_modules` already exists within the `application.dirPath`
2958
+ * Uses a configured `application.install` command, a package manager derived from the application's
2959
+ * `package.json#devEngines`, or the default, `npm`. Returns early when `node_modules` already exists or
2960
+ * when the manifest has no automatic install work. An explicitly selected non-npm manager is always
2961
+ * allowed to inspect its own workspace configuration, even when the root manifest has no production
2962
+ * dependencies.
865
2963
  *
866
- * This method may be called from any Harper thread as part of a serialized preparation.
2964
+ * May be called from any Harper thread as part of a serialized preparation.
867
2965
  */
868
- export async function installApplication(application: Application) {
2966
+ export async function installApplication(application: Application, buildDirPath = application.dirPath) {
869
2967
  let packageJSON: any;
870
2968
  try {
871
- packageJSON = JSON.parse(await readFile(join(application.dirPath, 'package.json'), 'utf8'));
2969
+ packageJSON = JSON.parse(await readFile(join(buildDirPath, 'package.json'), 'utf8'));
872
2970
  } catch (err) {
873
2971
  if (err.code !== 'ENOENT') throw err;
874
2972
  // If no package.json, nothing to install
@@ -877,7 +2975,7 @@ export async function installApplication(application: Application) {
877
2975
  }
878
2976
  try {
879
2977
  // Does node_modules exist?
880
- await access(join(application.dirPath, 'node_modules'), constants.F_OK);
2978
+ await access(join(buildDirPath, 'node_modules'), constants.F_OK);
881
2979
  application.logger.info(
882
2980
  `Application ${application.name} already has node_modules; skipping install and treating the runtime as opaque for redeploy comparison`
883
2981
  );
@@ -898,7 +2996,7 @@ export async function installApplication(application: Application) {
898
2996
  application.name,
899
2997
  command,
900
2998
  args,
901
- application.dirPath,
2999
+ buildDirPath,
902
3000
  application.install?.timeout,
903
3001
  customOnLine,
904
3002
  application.npmUserconfigPath
@@ -921,9 +3019,22 @@ export async function installApplication(application: Application) {
921
3019
  );
922
3020
  }
923
3021
 
924
- // Next, try package.json devEngines field
3022
+ const allowInstallScripts = !!application.install?.allowInstallScripts;
925
3023
  const { packageManager } = packageJSON.devEngines || {};
3024
+ if (dependencyFieldHasWork(packageJSON, 'devDependencies')) {
3025
+ application.logger.warn(
3026
+ `Application ${application.name} declares devDependencies; automatic npm installation omits them, while explicitly selected non-npm package managers retain their own install defaults. Use install_command when deployment requires custom behavior`
3027
+ );
3028
+ }
3029
+ if (
3030
+ !packageHasAutomaticInstallWork(packageJSON) &&
3031
+ !(allowInstallScripts && packageHasAllowedInstallLifecycleWork(packageJSON))
3032
+ ) {
3033
+ application.logger.info(`Application ${application.name} has no production package work; skipping install`);
3034
+ return;
3035
+ }
926
3036
 
3037
+ // Next, try package.json devEngines field
927
3038
  // Custom package manager specified
928
3039
  if (packageManager) {
929
3040
  // On any given system we want to leverage the `name` to match the package manager executable
@@ -959,8 +3070,8 @@ export async function installApplication(application: Application) {
959
3070
  const { stdout, stderr, code } = await nonInteractiveSpawn(
960
3071
  application.name,
961
3072
  (application.packageManagerPrefix ? application.packageManagerPrefix + ' ' : '') + packageManager.name,
962
- application.install?.allowInstallScripts ? ['install'] : ['install', '--ignore-scripts'], // All of `npm`, `yarn`, and `pnpm` support the `install` command. If we need to configure options here we may have to use some other defaults though
963
- application.dirPath,
3073
+ packageManagerInstallArguments(packageManager.name, allowInstallScripts),
3074
+ buildDirPath,
964
3075
  application.install?.timeout,
965
3076
  pmOnLine,
966
3077
  application.npmUserconfigPath
@@ -1008,9 +3119,13 @@ export async function installApplication(application: Application) {
1008
3119
  }
1009
3120
 
1010
3121
  // Finally, default to running `npm install`
1011
- const npmInstallArgs = application.install?.allowInstallScripts
1012
- ? ['install', '--force']
1013
- : ['install', '--force', '--ignore-scripts'];
3122
+ const npmInstallArgs = packageManagerInstallArguments('npm', allowInstallScripts, true);
3123
+ // A candidate build is installed at a staging path and then RENAMED to the live path, so nothing npm
3124
+ // writes may depend on the build location. npm links a `file:` dependency relatively on POSIX, which
3125
+ // survives the move — but as an absolute junction on Windows, which does not, so the dependency stops
3126
+ // resolving once the tree moves. `--install-links` copies instead of linking, leaving no path to break.
3127
+ // win32 and candidate builds only, since it does change how `file:` dependencies behave.
3128
+ if (process.platform === 'win32' && buildDirPath !== application.dirPath) npmInstallArgs.push('--install-links');
1014
3129
  const npmOnLine = application.onInstallLine
1015
3130
  ? (stream: 'stdout' | 'stderr', line: string) => application.onInstallLine!('npm', stream, line)
1016
3131
  : undefined;
@@ -1018,7 +3133,7 @@ export async function installApplication(application: Application) {
1018
3133
  application.name,
1019
3134
  (application.packageManagerPrefix ? application.packageManagerPrefix + ' ' : '') + 'npm',
1020
3135
  npmInstallArgs,
1021
- application.dirPath,
3136
+ buildDirPath,
1022
3137
  application.install?.timeout,
1023
3138
  npmOnLine,
1024
3139
  application.npmUserconfigPath
@@ -1067,6 +3182,7 @@ export class Application {
1067
3182
  name: string;
1068
3183
  payload?: Buffer | string | Readable;
1069
3184
  packageIdentifier?: string;
3185
+ readonly packLocalDirectory: boolean;
1070
3186
  install?: { command?: string; timeout?: number; allowInstallScripts?: boolean };
1071
3187
  onInstallLine?: OnInstallLine;
1072
3188
  dirPath: string;
@@ -1092,6 +3208,7 @@ export class Application {
1092
3208
  constructor({ name, payload, packageIdentifier, install, onInstallLine, credentials }: ApplicationOptions) {
1093
3209
  this.name = name;
1094
3210
  this.payload = payload;
3211
+ this.packLocalDirectory = shouldPackLocalDirectory(packageIdentifier);
1095
3212
  this.packageIdentifier = packageIdentifier && derivePackageIdentifier(packageIdentifier);
1096
3213
  this.install = install;
1097
3214
  this.onInstallLine = onInstallLine;
@@ -1202,7 +3319,12 @@ export class Application {
1202
3319
  * during the installation process in order to actually resolve what the user specifies for a
1203
3320
  * component matching some of npm's package resolution rules.
1204
3321
  */
3322
+ function isBareAbsolutePackagePath(packageIdentifier: string) {
3323
+ return isAbsolute(packageIdentifier) || win32.isAbsolute(packageIdentifier);
3324
+ }
3325
+
1205
3326
  export function derivePackageIdentifier(packageIdentifier: string) {
3327
+ if (isBareAbsolutePackagePath(packageIdentifier)) return `file:${packageIdentifier}`;
1206
3328
  if (packageIdentifier.includes(':')) {
1207
3329
  return packageIdentifier;
1208
3330
  }
@@ -1216,6 +3338,10 @@ export function derivePackageIdentifier(packageIdentifier: string) {
1216
3338
  return `github:${packageIdentifier}`;
1217
3339
  }
1218
3340
 
3341
+ export function shouldPackLocalDirectory(packageIdentifier: string | undefined, platform = process.platform) {
3342
+ return platform === 'win32' && !!packageIdentifier && isBareAbsolutePackagePath(packageIdentifier);
3343
+ }
3344
+
1219
3345
  /**
1220
3346
  * Extract and install the specified application.
1221
3347
  *
@@ -1232,49 +3358,86 @@ export function derivePackageIdentifier(packageIdentifier: string) {
1232
3358
  * @param application The application to prepare.
1233
3359
  * @returns A promise that resolves when all preparation steps complete.
1234
3360
  */
1235
- export async function prepareApplication(application: Application) {
3361
+ export type PrepareApplicationOptions = {
3362
+ /**
3363
+ * Runs against the built candidate while the live version is still serving, and BEFORE the swap. A
3364
+ * throw here means the candidate never goes live — which is the whole difference from the previous
3365
+ * behavior, where the swap committed first and a load failure was reported over an already-live
3366
+ * broken release.
3367
+ */
3368
+ validateCandidate?: (candidateDirPath: string) => Promise<void>;
3369
+ };
3370
+
3371
+ export async function prepareApplication(application: Application, options: PrepareApplicationOptions = {}) {
1236
3372
  const deploymentId = await broadcastDeployStart(application.name);
1237
3373
  try {
1238
3374
  const commandTimeoutMs = application.install?.timeout ?? DEFAULT_COMMAND_TIMEOUT_MS;
1239
3375
  await withComponentPreparationLock(
1240
3376
  application.dirPath,
1241
3377
  async () => {
3378
+ const asideStagingDir = extractionStagingDirectory(application.dirPath);
3379
+ let recoveryPending = true;
3380
+ try {
3381
+ await lstat(asideStagingDir);
3382
+ } catch (error) {
3383
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
3384
+ recoveryPending = false;
3385
+ }
3386
+ // BEFORE the legacy pass. That pass refuses to restore while a journal survives, so skipping
3387
+ // this would not lose data — it would just stall the deploy behind its own unsettled state.
3388
+ await settleJournaledActivationsForComponent(dirname(application.dirPath), application.name);
3389
+ if (recoveryPending) {
3390
+ await ensureExtractionStagingDirectory(asideStagingDir);
3391
+ await recoverOrCleanupStaleExtractionPaths(application, asideStagingDir);
3392
+ }
1242
3393
  const previousPackageMetadata = await readInstalledPackageMetadata(application.dirPath);
1243
- let extraction: ExtractionTransaction | undefined;
3394
+ // Determined before the swap, because both trees exist then: the runtime comparison below
3395
+ // wants the live version and the candidate side by side.
3396
+ application.isNewComponent = !(await lstat(application.dirPath).then(
3397
+ () => true,
3398
+ (error) => {
3399
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false;
3400
+ throw error;
3401
+ }
3402
+ ));
1244
3403
  try {
1245
- // Materialize the per-deploy `.npmrc` before extraction so both `npm pack` (extract) and
1246
- // `npm install` authenticate against the private registry; always remove it afterward.
3404
+ // Materialize the per-deploy `.npmrc` before the build so both `npm pack` and `npm install`
3405
+ // authenticate against the private registry; always remove it afterward.
1247
3406
  await application.writeTransientNpmrc();
3407
+ let candidateDirPath: string;
1248
3408
  try {
1249
- // The git credential socket only has to be up for extraction — that is where npm resolves and
1250
- // clones a git-reference package. Closing it before installApplication means the credential is
1251
- // already gone by the time the component's dependency tree (and any install script it is
1252
- // allowed to run) executes.
3409
+ // Backstop only: the builder closes the session as soon as extraction is done, so the
3410
+ // credential is already gone before any install script runs. This finally covers the paths
3411
+ // that fail before it gets there.
1253
3412
  await application.startGitCredentialSession();
1254
- extraction = await extractApplication(application, true);
3413
+ candidateDirPath = await buildCandidateApplication(application, deploymentId);
1255
3414
  } finally {
1256
3415
  await application.cleanupGitCredentialSession();
1257
3416
  }
1258
- await installApplication(application);
1259
- if (!application.isNewComponent) {
1260
- const currentPackageMetadata = await readInstalledPackageMetadata(application.dirPath);
1261
- application.packageMetadataChanged = installedRuntimeChanged(
1262
- previousPackageMetadata,
1263
- currentPackageMetadata,
1264
- application.installationIsOpaque
1265
- );
1266
- }
1267
- await extraction?.commit();
1268
- } catch (error) {
1269
3417
  try {
1270
- await extraction?.rollback();
1271
- } catch (rollbackError) {
1272
- throw new AggregateError(
1273
- [error, rollbackError],
1274
- `Failed to prepare ${application.name} and restore its previous component directory`
1275
- );
3418
+ // Validated while the previous version is still the one serving, so a candidate that
3419
+ // installs cleanly but throws at load is rejected without ever having been live.
3420
+ await options.validateCandidate?.(candidateDirPath);
3421
+ if (!application.isNewComponent) {
3422
+ application.packageMetadataChanged = installedRuntimeChanged(
3423
+ previousPackageMetadata,
3424
+ await readInstalledPackageMetadata(candidateDirPath),
3425
+ application.installationIsOpaque
3426
+ );
3427
+ }
3428
+ await activateCandidateApplication(application, deploymentId);
3429
+ } catch (error) {
3430
+ // The builder's own cleanup only covers a failed BUILD. A rejected validation, or an
3431
+ // activation that was cleanly compensated, would otherwise leave a whole installed
3432
+ // dependency tree under this deployment id — repeated rejections fill the volume.
3433
+ //
3434
+ // NOT when compensation itself failed. There the previous version is not back and the live
3435
+ // path may be absent, and the candidate plus its `.complete` marker and journal are exactly
3436
+ // what recovery needs to roll the validated deploy forward at the next start. Discarding
3437
+ // them there trades a bounded disk cost for a component with no version at all.
3438
+ if (!compensationIncomplete(error)) await discardCandidate(application, deploymentId);
3439
+ throw error;
1276
3440
  }
1277
- throw error;
1278
3441
  } finally {
1279
3442
  await application.cleanupTransientNpmrc();
1280
3443
  }
@@ -1629,19 +3792,6 @@ export function buildNpmrcContent(registryCredentials: ResolvedRegistryCredentia
1629
3792
  return lines.join('\n') + '\n';
1630
3793
  }
1631
3794
 
1632
- /**
1633
- * Execute a command (using `spawn`) with stdin ignored.
1634
- *
1635
- * Stdout is logged chunk-by-chunk. Stderr is buffered and then logged line-by-line.
1636
- *
1637
- * Rejects with an error if the command fails or times out.
1638
- *
1639
- * @param command The command to run.
1640
- * @param args The arguments to pass to the command.
1641
- * @param cwd The working directory for the command.
1642
- * @param timeoutMs The timeout for the command in milliseconds. Defaults to 5 minutes.
1643
- * @returns A promise that resolves when the command completes.
1644
- */
1645
3795
  /**
1646
3796
  * Line-buffered split that emits complete `\n`-terminated lines as they
1647
3797
  * arrive, holding any partial trailing fragment until the next chunk or `flush()`.
@@ -1683,9 +3833,20 @@ function createLineSplitter(onLine: (line: string) => void): {
1683
3833
  }
1684
3834
 
1685
3835
  /**
1686
- * Run a command with the deploy's SSH key material materialized only for the duration of the
1687
- * spawn. The keys are decrypted to a transient 0700 dir (see `materializeGitSSH`) and removed as
1688
- * soon as the process settles — on success, failure, and timeout alike.
3836
+ * Execute a command (using `spawn`) with stdin ignored, with the deploy's SSH key material
3837
+ * materialized only for the duration of the spawn. The keys are decrypted to a transient 0700 dir
3838
+ * (see `materializeGitSSH`) and removed as soon as the process settles — on success, failure, and
3839
+ * timeout alike.
3840
+ *
3841
+ * Stdout is logged chunk-by-chunk. Stderr is buffered and then logged line-by-line.
3842
+ *
3843
+ * Rejects with an error if the command fails or times out.
3844
+ *
3845
+ * @param command The command to run.
3846
+ * @param args The arguments to pass to the command.
3847
+ * @param cwd The working directory for the command.
3848
+ * @param timeoutMs The timeout for the command in milliseconds. Defaults to DEFAULT_COMMAND_TIMEOUT_MS.
3849
+ * @returns A promise that resolves when the command completes.
1689
3850
  */
1690
3851
  export async function nonInteractiveSpawn(
1691
3852
  applicationName: string,
@@ -1764,6 +3925,7 @@ function spawnWithEnv(
1764
3925
  if (process.platform === 'win32' && command === 'npm') {
1765
3926
  command = 'npm.cmd';
1766
3927
  }
3928
+ const spawnLogger = logger.loggerWithTag(`${applicationName}:spawn:${command}`);
1767
3929
 
1768
3930
  const childProcess = spawn(command, args, {
1769
3931
  shell: true,
@@ -1857,7 +4019,22 @@ function spawnWithEnv(
1857
4019
  }
1858
4020
  });
1859
4021
 
1860
- childProcess.on('close', async (code) => {
4022
+ childProcess.on('exit', (code, signal) => {
4023
+ spawnLogger.debug?.(`Direct child exited with code ${code}, signal ${signal}; awaiting stdio close`);
4024
+ });
4025
+
4026
+ childProcess.on('close', async (code, signal) => {
4027
+ if (didTimeout) {
4028
+ spawnLogger.debug?.(
4029
+ `Child stdio closed with code ${code}, signal ${signal}; timeout path owns process-tree confirmation`
4030
+ );
4031
+ } else if (trackedProcessId) {
4032
+ spawnLogger.debug?.(
4033
+ `Child stdio closed with code ${code}, signal ${signal}; confirming process-tree termination`
4034
+ );
4035
+ } else {
4036
+ spawnLogger.debug?.(`Child stdio closed with code ${code}, signal ${signal}; no process tree was tracked`);
4037
+ }
1861
4038
  resolveClose();
1862
4039
  clearTimeout(timeout);
1863
4040
  // A successful direct-child exit does not prove the process group is empty: a custom
@@ -1878,6 +4055,7 @@ function spawnWithEnv(
1878
4055
  }
1879
4056
  return;
1880
4057
  }
4058
+ spawnLogger.debug?.(`Process tree termination confirmed after command close with code ${code}`);
1881
4059
  untrackProcessGroup();
1882
4060
  }
1883
4061
  // When didTimeout is true, the timeout path's own terminateProcessTree(...).then(...) owns
@@ -1889,7 +4067,6 @@ function spawnWithEnv(
1889
4067
  if (stderr) {
1890
4068
  printStd(applicationName, command, stderr, 'stderr');
1891
4069
  }
1892
- logger.loggerWithTag(`${applicationName}:spawn:${command}`).debug?.(`Process exited with code ${code}`);
1893
4070
  if (didTimeout || didSettle) return;
1894
4071
  didSettle = true;
1895
4072
  resolve({
@@ -1914,12 +4091,7 @@ class CommandTimeoutError extends Error {
1914
4091
  }
1915
4092
 
1916
4093
  function processGroupIsAlive(processGroupId: number): boolean {
1917
- try {
1918
- process.kill(-processGroupId, 0);
1919
- return true;
1920
- } catch (error: any) {
1921
- return error.code === 'EPERM';
1922
- }
4094
+ return isProcessGroupAlive(processGroupId);
1923
4095
  }
1924
4096
 
1925
4097
  async function waitForProcessGroupExit(processGroupId: number, timeoutMs: number): Promise<boolean> {