@omnitron-dev/omnitron 0.2.0 → 0.3.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 (1299) hide show
  1. package/README.md +4 -0
  2. package/dist/BUILD.json +5 -0
  3. package/dist/cli/omnitron.js +375 -29
  4. package/dist/cli/omnitron.js.map +1 -1
  5. package/dist/cluster/cluster.rpc-service.d.ts +40 -0
  6. package/dist/cluster/cluster.rpc-service.d.ts.map +1 -1
  7. package/dist/cluster/cluster.rpc-service.js +49 -3
  8. package/dist/cluster/cluster.rpc-service.js.map +1 -1
  9. package/dist/cluster/config-sync.d.ts.map +1 -1
  10. package/dist/cluster/config-sync.js.map +1 -1
  11. package/dist/cluster/leader-election.d.ts +90 -2
  12. package/dist/cluster/leader-election.d.ts.map +1 -1
  13. package/dist/cluster/leader-election.js +176 -22
  14. package/dist/cluster/leader-election.js.map +1 -1
  15. package/dist/cluster/mesh-link.d.ts +136 -0
  16. package/dist/cluster/mesh-link.d.ts.map +1 -0
  17. package/dist/cluster/mesh-link.js +212 -0
  18. package/dist/cluster/mesh-link.js.map +1 -0
  19. package/dist/cluster/mesh.d.ts +59 -0
  20. package/dist/cluster/mesh.d.ts.map +1 -0
  21. package/dist/cluster/mesh.js +117 -0
  22. package/dist/cluster/mesh.js.map +1 -0
  23. package/dist/cluster/slave-connector.d.ts +193 -9
  24. package/dist/cluster/slave-connector.d.ts.map +1 -1
  25. package/dist/cluster/slave-connector.js +409 -37
  26. package/dist/cluster/slave-connector.js.map +1 -1
  27. package/dist/commands/audit.d.ts +65 -0
  28. package/dist/commands/audit.d.ts.map +1 -0
  29. package/dist/commands/audit.js +233 -0
  30. package/dist/commands/audit.js.map +1 -0
  31. package/dist/commands/backup.d.ts +4 -0
  32. package/dist/commands/backup.d.ts.map +1 -1
  33. package/dist/commands/backup.js +255 -33
  34. package/dist/commands/backup.js.map +1 -1
  35. package/dist/commands/cluster.d.ts.map +1 -1
  36. package/dist/commands/cluster.js +78 -47
  37. package/dist/commands/cluster.js.map +1 -1
  38. package/dist/commands/daemon-cmd.d.ts +7 -1
  39. package/dist/commands/daemon-cmd.d.ts.map +1 -1
  40. package/dist/commands/daemon-cmd.js +87 -21
  41. package/dist/commands/daemon-cmd.js.map +1 -1
  42. package/dist/commands/daemon-required.d.ts +34 -0
  43. package/dist/commands/daemon-required.d.ts.map +1 -0
  44. package/dist/commands/daemon-required.js +92 -0
  45. package/dist/commands/daemon-required.js.map +1 -0
  46. package/dist/commands/deploy.d.ts +70 -8
  47. package/dist/commands/deploy.d.ts.map +1 -1
  48. package/dist/commands/deploy.js +99 -64
  49. package/dist/commands/deploy.js.map +1 -1
  50. package/dist/commands/discover.d.ts +25 -4
  51. package/dist/commands/discover.d.ts.map +1 -1
  52. package/dist/commands/discover.js +155 -102
  53. package/dist/commands/discover.js.map +1 -1
  54. package/dist/commands/doctor.d.ts +293 -0
  55. package/dist/commands/doctor.d.ts.map +1 -0
  56. package/dist/commands/doctor.js +1706 -0
  57. package/dist/commands/doctor.js.map +1 -0
  58. package/dist/commands/env.d.ts +8 -1
  59. package/dist/commands/env.d.ts.map +1 -1
  60. package/dist/commands/env.js +13 -8
  61. package/dist/commands/env.js.map +1 -1
  62. package/dist/commands/exec.d.ts.map +1 -1
  63. package/dist/commands/exec.js +3 -2
  64. package/dist/commands/exec.js.map +1 -1
  65. package/dist/commands/fleet-asking.d.ts +87 -0
  66. package/dist/commands/fleet-asking.d.ts.map +1 -0
  67. package/dist/commands/fleet-asking.js +176 -0
  68. package/dist/commands/fleet-asking.js.map +1 -0
  69. package/dist/commands/fleet-recover.d.ts +2 -0
  70. package/dist/commands/fleet-recover.d.ts.map +1 -0
  71. package/dist/commands/fleet-recover.js +30 -0
  72. package/dist/commands/fleet-recover.js.map +1 -0
  73. package/dist/commands/fleet.d.ts +18 -0
  74. package/dist/commands/fleet.d.ts.map +1 -1
  75. package/dist/commands/fleet.js +314 -74
  76. package/dist/commands/fleet.js.map +1 -1
  77. package/dist/commands/health-check.d.ts +8 -6
  78. package/dist/commands/health-check.d.ts.map +1 -1
  79. package/dist/commands/health-check.js +26 -23
  80. package/dist/commands/health-check.js.map +1 -1
  81. package/dist/commands/health.d.ts.map +1 -1
  82. package/dist/commands/health.js +22 -8
  83. package/dist/commands/health.js.map +1 -1
  84. package/dist/commands/host-ingress.d.ts +3 -0
  85. package/dist/commands/host-ingress.d.ts.map +1 -0
  86. package/dist/commands/host-ingress.js +31 -0
  87. package/dist/commands/host-ingress.js.map +1 -0
  88. package/dist/commands/infra.d.ts +42 -1
  89. package/dist/commands/infra.d.ts.map +1 -1
  90. package/dist/commands/infra.js +418 -49
  91. package/dist/commands/infra.js.map +1 -1
  92. package/dist/commands/init.d.ts.map +1 -1
  93. package/dist/commands/init.js +21 -36
  94. package/dist/commands/init.js.map +1 -1
  95. package/dist/commands/inspect-graph.d.ts.map +1 -1
  96. package/dist/commands/inspect-graph.js +2 -6
  97. package/dist/commands/inspect-graph.js.map +1 -1
  98. package/dist/commands/inspect.d.ts.map +1 -1
  99. package/dist/commands/inspect.js +110 -14
  100. package/dist/commands/inspect.js.map +1 -1
  101. package/dist/commands/k8s.d.ts.map +1 -1
  102. package/dist/commands/k8s.js +23 -18
  103. package/dist/commands/k8s.js.map +1 -1
  104. package/dist/commands/kb.d.ts.map +1 -1
  105. package/dist/commands/kb.js +88 -29
  106. package/dist/commands/kb.js.map +1 -1
  107. package/dist/commands/list.d.ts.map +1 -1
  108. package/dist/commands/list.js +21 -9
  109. package/dist/commands/list.js.map +1 -1
  110. package/dist/commands/log-health.d.ts +120 -0
  111. package/dist/commands/log-health.d.ts.map +1 -0
  112. package/dist/commands/log-health.js +212 -0
  113. package/dist/commands/log-health.js.map +1 -0
  114. package/dist/commands/logs.d.ts +96 -2
  115. package/dist/commands/logs.d.ts.map +1 -1
  116. package/dist/commands/logs.js +522 -196
  117. package/dist/commands/logs.js.map +1 -1
  118. package/dist/commands/metrics.d.ts.map +1 -1
  119. package/dist/commands/metrics.js +35 -17
  120. package/dist/commands/metrics.js.map +1 -1
  121. package/dist/commands/monit.d.ts.map +1 -1
  122. package/dist/commands/monit.js +3 -2
  123. package/dist/commands/monit.js.map +1 -1
  124. package/dist/commands/node.d.ts +56 -0
  125. package/dist/commands/node.d.ts.map +1 -1
  126. package/dist/commands/node.js +297 -69
  127. package/dist/commands/node.js.map +1 -1
  128. package/dist/commands/output.d.ts +10 -0
  129. package/dist/commands/output.d.ts.map +1 -1
  130. package/dist/commands/output.js +41 -1
  131. package/dist/commands/output.js.map +1 -1
  132. package/dist/commands/pipeline.d.ts.map +1 -1
  133. package/dist/commands/pipeline.js +33 -19
  134. package/dist/commands/pipeline.js.map +1 -1
  135. package/dist/commands/project.d.ts +19 -0
  136. package/dist/commands/project.d.ts.map +1 -1
  137. package/dist/commands/project.js +125 -27
  138. package/dist/commands/project.js.map +1 -1
  139. package/dist/commands/release-store.d.ts +28 -0
  140. package/dist/commands/release-store.d.ts.map +1 -0
  141. package/dist/commands/release-store.js +158 -0
  142. package/dist/commands/release-store.js.map +1 -0
  143. package/dist/commands/release.d.ts +61 -0
  144. package/dist/commands/release.d.ts.map +1 -0
  145. package/dist/commands/release.js +555 -0
  146. package/dist/commands/release.js.map +1 -0
  147. package/dist/commands/reload.d.ts +16 -0
  148. package/dist/commands/reload.d.ts.map +1 -1
  149. package/dist/commands/reload.js +21 -5
  150. package/dist/commands/reload.js.map +1 -1
  151. package/dist/commands/remote.d.ts +40 -0
  152. package/dist/commands/remote.d.ts.map +1 -1
  153. package/dist/commands/remote.js +146 -35
  154. package/dist/commands/remote.js.map +1 -1
  155. package/dist/commands/restart.d.ts +16 -0
  156. package/dist/commands/restart.d.ts.map +1 -1
  157. package/dist/commands/restart.js +21 -5
  158. package/dist/commands/restart.js.map +1 -1
  159. package/dist/commands/scale.d.ts.map +1 -1
  160. package/dist/commands/scale.js +4 -3
  161. package/dist/commands/scale.js.map +1 -1
  162. package/dist/commands/secret.d.ts +54 -0
  163. package/dist/commands/secret.d.ts.map +1 -1
  164. package/dist/commands/secret.js +236 -34
  165. package/dist/commands/secret.js.map +1 -1
  166. package/dist/commands/service-units.d.ts +181 -0
  167. package/dist/commands/service-units.d.ts.map +1 -0
  168. package/dist/commands/service-units.js +291 -0
  169. package/dist/commands/service-units.js.map +1 -0
  170. package/dist/commands/service.d.ts +39 -0
  171. package/dist/commands/service.d.ts.map +1 -0
  172. package/dist/commands/service.js +370 -0
  173. package/dist/commands/service.js.map +1 -0
  174. package/dist/commands/spinner.d.ts +29 -0
  175. package/dist/commands/spinner.d.ts.map +1 -0
  176. package/dist/commands/spinner.js +44 -0
  177. package/dist/commands/spinner.js.map +1 -0
  178. package/dist/commands/stack.d.ts +49 -1
  179. package/dist/commands/stack.d.ts.map +1 -1
  180. package/dist/commands/stack.js +325 -12
  181. package/dist/commands/stack.js.map +1 -1
  182. package/dist/commands/start.d.ts +16 -0
  183. package/dist/commands/start.d.ts.map +1 -1
  184. package/dist/commands/start.js +20 -3
  185. package/dist/commands/start.js.map +1 -1
  186. package/dist/commands/status.d.ts +9 -0
  187. package/dist/commands/status.d.ts.map +1 -1
  188. package/dist/commands/status.js +45 -29
  189. package/dist/commands/status.js.map +1 -1
  190. package/dist/commands/stop.d.ts +16 -0
  191. package/dist/commands/stop.d.ts.map +1 -1
  192. package/dist/commands/stop.js +34 -6
  193. package/dist/commands/stop.js.map +1 -1
  194. package/dist/commands/tor.d.ts +14 -7
  195. package/dist/commands/tor.d.ts.map +1 -1
  196. package/dist/commands/tor.js +113 -33
  197. package/dist/commands/tor.js.map +1 -1
  198. package/dist/commands/up.d.ts +85 -1
  199. package/dist/commands/up.d.ts.map +1 -1
  200. package/dist/commands/up.js +180 -32
  201. package/dist/commands/up.js.map +1 -1
  202. package/dist/commands/webapp.d.ts.map +1 -1
  203. package/dist/commands/webapp.js +2 -1
  204. package/dist/commands/webapp.js.map +1 -1
  205. package/dist/config/defaults.d.ts +16 -0
  206. package/dist/config/defaults.d.ts.map +1 -1
  207. package/dist/config/defaults.js +26 -3
  208. package/dist/config/defaults.js.map +1 -1
  209. package/dist/config/define-ecosystem.d.ts.map +1 -1
  210. package/dist/config/define-ecosystem.js +2 -0
  211. package/dist/config/define-ecosystem.js.map +1 -1
  212. package/dist/config/index.d.ts +1 -1
  213. package/dist/config/index.d.ts.map +1 -1
  214. package/dist/config/index.js +1 -1
  215. package/dist/config/index.js.map +1 -1
  216. package/dist/config/loader.d.ts +45 -1
  217. package/dist/config/loader.d.ts.map +1 -1
  218. package/dist/config/loader.js +64 -6
  219. package/dist/config/loader.js.map +1 -1
  220. package/dist/config/omnitron-config.schema.d.ts.map +1 -1
  221. package/dist/config/omnitron-config.schema.js +16 -0
  222. package/dist/config/omnitron-config.schema.js.map +1 -1
  223. package/dist/config/types.d.ts +416 -44
  224. package/dist/config/types.d.ts.map +1 -1
  225. package/dist/daemon/config-reload.d.ts +27 -0
  226. package/dist/daemon/config-reload.d.ts.map +1 -0
  227. package/dist/daemon/config-reload.js +42 -0
  228. package/dist/daemon/config-reload.js.map +1 -0
  229. package/dist/daemon/daemon-client.d.ts +136 -2
  230. package/dist/daemon/daemon-client.d.ts.map +1 -1
  231. package/dist/daemon/daemon-client.js +307 -21
  232. package/dist/daemon/daemon-client.js.map +1 -1
  233. package/dist/daemon/daemon-entry.js +90 -30
  234. package/dist/daemon/daemon-entry.js.map +1 -1
  235. package/dist/daemon/daemon-scheduler.d.ts +4 -0
  236. package/dist/daemon/daemon-scheduler.d.ts.map +1 -1
  237. package/dist/daemon/daemon-scheduler.js +137 -7
  238. package/dist/daemon/daemon-scheduler.js.map +1 -1
  239. package/dist/daemon/daemon-state-store.service.d.ts +263 -0
  240. package/dist/daemon/daemon-state-store.service.d.ts.map +1 -0
  241. package/dist/daemon/daemon-state-store.service.js +446 -0
  242. package/dist/daemon/daemon-state-store.service.js.map +1 -0
  243. package/dist/daemon/daemon.d.ts +194 -4
  244. package/dist/daemon/daemon.d.ts.map +1 -1
  245. package/dist/daemon/daemon.js +1160 -157
  246. package/dist/daemon/daemon.js.map +1 -1
  247. package/dist/daemon/daemon.module.d.ts.map +1 -1
  248. package/dist/daemon/daemon.module.js +163 -85
  249. package/dist/daemon/daemon.module.js.map +1 -1
  250. package/dist/daemon/daemon.rpc-service.d.ts +90 -1
  251. package/dist/daemon/daemon.rpc-service.d.ts.map +1 -1
  252. package/dist/daemon/daemon.rpc-service.js +439 -97
  253. package/dist/daemon/daemon.rpc-service.js.map +1 -1
  254. package/dist/daemon/pid-manager.d.ts +16 -3
  255. package/dist/daemon/pid-manager.d.ts.map +1 -1
  256. package/dist/daemon/pid-manager.js +31 -4
  257. package/dist/daemon/pid-manager.js.map +1 -1
  258. package/dist/daemon/spawn-daemon.d.ts +73 -0
  259. package/dist/daemon/spawn-daemon.d.ts.map +1 -0
  260. package/dist/daemon/spawn-daemon.js +101 -0
  261. package/dist/daemon/spawn-daemon.js.map +1 -0
  262. package/dist/daemon/state-store.d.ts +65 -3
  263. package/dist/daemon/state-store.d.ts.map +1 -1
  264. package/dist/daemon/state-store.js +100 -58
  265. package/dist/daemon/state-store.js.map +1 -1
  266. package/dist/database/connection.d.ts +71 -0
  267. package/dist/database/connection.d.ts.map +1 -0
  268. package/dist/database/connection.js +129 -0
  269. package/dist/database/connection.js.map +1 -0
  270. package/dist/database/dialect.d.ts +60 -0
  271. package/dist/database/dialect.d.ts.map +1 -0
  272. package/dist/database/dialect.js +80 -0
  273. package/dist/database/dialect.js.map +1 -0
  274. package/dist/database/migrate.d.ts +5 -2
  275. package/dist/database/migrate.d.ts.map +1 -1
  276. package/dist/database/migrate.js +23 -33
  277. package/dist/database/migrate.js.map +1 -1
  278. package/dist/database/migration-runner.d.ts +50 -0
  279. package/dist/database/migration-runner.d.ts.map +1 -0
  280. package/dist/database/migration-runner.js +143 -0
  281. package/dist/database/migration-runner.js.map +1 -0
  282. package/dist/database/migrations/006_login_throttle.d.ts +17 -0
  283. package/dist/database/migrations/006_login_throttle.d.ts.map +1 -0
  284. package/dist/database/migrations/006_login_throttle.js +33 -0
  285. package/dist/database/migrations/006_login_throttle.js.map +1 -0
  286. package/dist/database/migrations/007_sync_ingested.d.ts +21 -0
  287. package/dist/database/migrations/007_sync_ingested.d.ts.map +1 -0
  288. package/dist/database/migrations/007_sync_ingested.js +39 -0
  289. package/dist/database/migrations/007_sync_ingested.js.map +1 -0
  290. package/dist/database/migrations/008_node_health_version_text.d.ts +24 -0
  291. package/dist/database/migrations/008_node_health_version_text.d.ts.map +1 -0
  292. package/dist/database/migrations/008_node_health_version_text.js +35 -0
  293. package/dist/database/migrations/008_node_health_version_text.js.map +1 -0
  294. package/dist/database/migrations/009_node_health_unmeasured.d.ts +40 -0
  295. package/dist/database/migrations/009_node_health_unmeasured.d.ts.map +1 -0
  296. package/dist/database/migrations/009_node_health_unmeasured.js +63 -0
  297. package/dist/database/migrations/009_node_health_unmeasured.js.map +1 -0
  298. package/dist/database/migrations/010_metrics_raw_had_no_reader.d.ts +22 -0
  299. package/dist/database/migrations/010_metrics_raw_had_no_reader.d.ts.map +1 -0
  300. package/dist/database/migrations/010_metrics_raw_had_no_reader.js +26 -0
  301. package/dist/database/migrations/010_metrics_raw_had_no_reader.js.map +1 -0
  302. package/dist/database/migrations/011_alerts_nobody_had_to_write.d.ts +56 -0
  303. package/dist/database/migrations/011_alerts_nobody_had_to_write.d.ts.map +1 -0
  304. package/dist/database/migrations/011_alerts_nobody_had_to_write.js +79 -0
  305. package/dist/database/migrations/011_alerts_nobody_had_to_write.js.map +1 -0
  306. package/dist/database/migrations/012_a_disk_nobody_watched.d.ts +26 -0
  307. package/dist/database/migrations/012_a_disk_nobody_watched.d.ts.map +1 -0
  308. package/dist/database/migrations/012_a_disk_nobody_watched.js +40 -0
  309. package/dist/database/migrations/012_a_disk_nobody_watched.js.map +1 -0
  310. package/dist/database/migrations/013_an_alarm_delivered.d.ts +14 -0
  311. package/dist/database/migrations/013_an_alarm_delivered.d.ts.map +1 -0
  312. package/dist/database/migrations/013_an_alarm_delivered.js +28 -0
  313. package/dist/database/migrations/013_an_alarm_delivered.js.map +1 -0
  314. package/dist/database/migrations/index.d.ts +21 -0
  315. package/dist/database/migrations/index.d.ts.map +1 -0
  316. package/dist/database/migrations/index.js +47 -0
  317. package/dist/database/migrations/index.js.map +1 -0
  318. package/dist/database/schema.d.ts +27 -11
  319. package/dist/database/schema.d.ts.map +1 -1
  320. package/dist/database/sqlite-date-binding.d.ts +71 -0
  321. package/dist/database/sqlite-date-binding.d.ts.map +1 -0
  322. package/dist/database/sqlite-date-binding.js +174 -0
  323. package/dist/database/sqlite-date-binding.js.map +1 -0
  324. package/dist/execution/data-channel.d.ts +48 -0
  325. package/dist/execution/data-channel.d.ts.map +1 -0
  326. package/dist/execution/data-channel.js +55 -0
  327. package/dist/execution/data-channel.js.map +1 -0
  328. package/dist/execution/execution.service.d.ts +146 -0
  329. package/dist/execution/execution.service.d.ts.map +1 -1
  330. package/dist/execution/execution.service.js +330 -10
  331. package/dist/execution/execution.service.js.map +1 -1
  332. package/dist/index.d.ts +1 -1
  333. package/dist/index.d.ts.map +1 -1
  334. package/dist/infrastructure/bare-metal-plan.d.ts +208 -0
  335. package/dist/infrastructure/bare-metal-plan.d.ts.map +1 -0
  336. package/dist/infrastructure/bare-metal-plan.js +363 -0
  337. package/dist/infrastructure/bare-metal-plan.js.map +1 -0
  338. package/dist/infrastructure/bare-metal-runner.d.ts +65 -0
  339. package/dist/infrastructure/bare-metal-runner.d.ts.map +1 -0
  340. package/dist/infrastructure/bare-metal-runner.js +354 -0
  341. package/dist/infrastructure/bare-metal-runner.js.map +1 -0
  342. package/dist/infrastructure/config-payload.d.ts +149 -0
  343. package/dist/infrastructure/config-payload.d.ts.map +1 -0
  344. package/dist/infrastructure/config-payload.js +235 -0
  345. package/dist/infrastructure/config-payload.js.map +1 -0
  346. package/dist/infrastructure/container-runtime.d.ts +170 -10
  347. package/dist/infrastructure/container-runtime.d.ts.map +1 -1
  348. package/dist/infrastructure/container-runtime.js +565 -52
  349. package/dist/infrastructure/container-runtime.js.map +1 -1
  350. package/dist/infrastructure/gateway-reachability.d.ts +85 -0
  351. package/dist/infrastructure/gateway-reachability.d.ts.map +1 -0
  352. package/dist/infrastructure/gateway-reachability.js +116 -0
  353. package/dist/infrastructure/gateway-reachability.js.map +1 -0
  354. package/dist/infrastructure/gateway-redis-db.d.ts +29 -0
  355. package/dist/infrastructure/gateway-redis-db.d.ts.map +1 -0
  356. package/dist/infrastructure/gateway-redis-db.js +32 -0
  357. package/dist/infrastructure/gateway-redis-db.js.map +1 -0
  358. package/dist/infrastructure/host-ingress-config.d.ts +12 -0
  359. package/dist/infrastructure/host-ingress-config.d.ts.map +1 -0
  360. package/dist/infrastructure/host-ingress-config.js +197 -0
  361. package/dist/infrastructure/host-ingress-config.js.map +1 -0
  362. package/dist/infrastructure/host-ingress-lock.d.ts +4 -0
  363. package/dist/infrastructure/host-ingress-lock.d.ts.map +1 -0
  364. package/dist/infrastructure/host-ingress-lock.js +36 -0
  365. package/dist/infrastructure/host-ingress-lock.js.map +1 -0
  366. package/dist/infrastructure/host-ingress-runtime.d.ts +25 -0
  367. package/dist/infrastructure/host-ingress-runtime.d.ts.map +1 -0
  368. package/dist/infrastructure/host-ingress-runtime.js +114 -0
  369. package/dist/infrastructure/host-ingress-runtime.js.map +1 -0
  370. package/dist/infrastructure/host-ingress-takeover.d.ts +18 -0
  371. package/dist/infrastructure/host-ingress-takeover.d.ts.map +1 -0
  372. package/dist/infrastructure/host-ingress-takeover.js +142 -0
  373. package/dist/infrastructure/host-ingress-takeover.js.map +1 -0
  374. package/dist/infrastructure/host-ingress-types.d.ts +173 -0
  375. package/dist/infrastructure/host-ingress-types.d.ts.map +1 -0
  376. package/dist/infrastructure/host-ingress-types.js +2 -0
  377. package/dist/infrastructure/host-ingress-types.js.map +1 -0
  378. package/dist/infrastructure/host-ingress.service.d.ts +47 -0
  379. package/dist/infrastructure/host-ingress.service.d.ts.map +1 -0
  380. package/dist/infrastructure/host-ingress.service.js +514 -0
  381. package/dist/infrastructure/host-ingress.service.js.map +1 -0
  382. package/dist/infrastructure/host-inspection.d.ts +223 -0
  383. package/dist/infrastructure/host-inspection.d.ts.map +1 -0
  384. package/dist/infrastructure/host-inspection.js +501 -0
  385. package/dist/infrastructure/host-inspection.js.map +1 -0
  386. package/dist/infrastructure/infrastructure-gate.d.ts.map +1 -1
  387. package/dist/infrastructure/infrastructure.service.d.ts +223 -7
  388. package/dist/infrastructure/infrastructure.service.d.ts.map +1 -1
  389. package/dist/infrastructure/infrastructure.service.js +500 -93
  390. package/dist/infrastructure/infrastructure.service.js.map +1 -1
  391. package/dist/infrastructure/known-machines.d.ts +76 -0
  392. package/dist/infrastructure/known-machines.d.ts.map +1 -0
  393. package/dist/infrastructure/known-machines.js +94 -0
  394. package/dist/infrastructure/known-machines.js.map +1 -0
  395. package/dist/infrastructure/node-credentials.d.ts +45 -0
  396. package/dist/infrastructure/node-credentials.d.ts.map +1 -0
  397. package/dist/infrastructure/node-credentials.js +80 -0
  398. package/dist/infrastructure/node-credentials.js.map +1 -0
  399. package/dist/infrastructure/orphan-containers.d.ts +99 -0
  400. package/dist/infrastructure/orphan-containers.d.ts.map +1 -0
  401. package/dist/infrastructure/orphan-containers.js +106 -0
  402. package/dist/infrastructure/orphan-containers.js.map +1 -0
  403. package/dist/infrastructure/phantom-endpoint-janitor.d.ts +20 -0
  404. package/dist/infrastructure/phantom-endpoint-janitor.d.ts.map +1 -1
  405. package/dist/infrastructure/phantom-endpoint-janitor.js +56 -6
  406. package/dist/infrastructure/phantom-endpoint-janitor.js.map +1 -1
  407. package/dist/infrastructure/presets/gateway-openresty.d.ts.map +1 -1
  408. package/dist/infrastructure/presets/gateway-openresty.js +29 -3
  409. package/dist/infrastructure/presets/gateway-openresty.js.map +1 -1
  410. package/dist/infrastructure/presets/minio.d.ts.map +1 -1
  411. package/dist/infrastructure/presets/minio.js +36 -3
  412. package/dist/infrastructure/presets/minio.js.map +1 -1
  413. package/dist/infrastructure/presets/postgres.d.ts.map +1 -1
  414. package/dist/infrastructure/presets/postgres.js +69 -2
  415. package/dist/infrastructure/presets/postgres.js.map +1 -1
  416. package/dist/infrastructure/presets/redis.d.ts.map +1 -1
  417. package/dist/infrastructure/presets/redis.js +13 -0
  418. package/dist/infrastructure/presets/redis.js.map +1 -1
  419. package/dist/infrastructure/presets/registry.d.ts.map +1 -1
  420. package/dist/infrastructure/presets/registry.js +81 -12
  421. package/dist/infrastructure/presets/registry.js.map +1 -1
  422. package/dist/infrastructure/presets/tor.d.ts +38 -0
  423. package/dist/infrastructure/presets/tor.d.ts.map +1 -1
  424. package/dist/infrastructure/presets/tor.js +78 -69
  425. package/dist/infrastructure/presets/tor.js.map +1 -1
  426. package/dist/infrastructure/presets/torrc.d.ts +54 -0
  427. package/dist/infrastructure/presets/torrc.d.ts.map +1 -0
  428. package/dist/infrastructure/presets/torrc.js +153 -0
  429. package/dist/infrastructure/presets/torrc.js.map +1 -0
  430. package/dist/infrastructure/presets/types.d.ts +17 -0
  431. package/dist/infrastructure/presets/types.d.ts.map +1 -1
  432. package/dist/infrastructure/provisioning-outcome.d.ts +85 -0
  433. package/dist/infrastructure/provisioning-outcome.d.ts.map +1 -0
  434. package/dist/infrastructure/provisioning-outcome.js +122 -0
  435. package/dist/infrastructure/provisioning-outcome.js.map +1 -0
  436. package/dist/infrastructure/server-registry.d.ts +27 -0
  437. package/dist/infrastructure/server-registry.d.ts.map +1 -1
  438. package/dist/infrastructure/server-registry.js +60 -5
  439. package/dist/infrastructure/server-registry.js.map +1 -1
  440. package/dist/infrastructure/service-binding.d.ts +60 -0
  441. package/dist/infrastructure/service-binding.d.ts.map +1 -0
  442. package/dist/infrastructure/service-binding.js +90 -0
  443. package/dist/infrastructure/service-binding.js.map +1 -0
  444. package/dist/infrastructure/service-credentials.d.ts +105 -0
  445. package/dist/infrastructure/service-credentials.d.ts.map +1 -0
  446. package/dist/infrastructure/service-credentials.js +141 -0
  447. package/dist/infrastructure/service-credentials.js.map +1 -0
  448. package/dist/infrastructure/service-resolver.d.ts +166 -12
  449. package/dist/infrastructure/service-resolver.d.ts.map +1 -1
  450. package/dist/infrastructure/service-resolver.js +535 -82
  451. package/dist/infrastructure/service-resolver.js.map +1 -1
  452. package/dist/infrastructure/shipped-config.d.ts +43 -0
  453. package/dist/infrastructure/shipped-config.d.ts.map +1 -0
  454. package/dist/infrastructure/shipped-config.js +159 -0
  455. package/dist/infrastructure/shipped-config.js.map +1 -0
  456. package/dist/infrastructure/stack-infra-manager.d.ts +16 -1
  457. package/dist/infrastructure/stack-infra-manager.d.ts.map +1 -1
  458. package/dist/infrastructure/stack-infra-manager.js +81 -53
  459. package/dist/infrastructure/stack-infra-manager.js.map +1 -1
  460. package/dist/infrastructure/types.d.ts +263 -6
  461. package/dist/infrastructure/types.d.ts.map +1 -1
  462. package/dist/mcp/mcp-bridge.d.ts.map +1 -1
  463. package/dist/mcp/mcp-bridge.js.map +1 -1
  464. package/dist/mcp/tool-groups/apps.tools.d.ts +2 -1
  465. package/dist/mcp/tool-groups/apps.tools.d.ts.map +1 -1
  466. package/dist/mcp/tool-groups/apps.tools.js +2 -2
  467. package/dist/mcp/tool-groups/apps.tools.js.map +1 -1
  468. package/dist/mcp/tool-groups/infra.tools.d.ts +16 -2
  469. package/dist/mcp/tool-groups/infra.tools.d.ts.map +1 -1
  470. package/dist/mcp/tool-groups/infra.tools.js +51 -41
  471. package/dist/mcp/tool-groups/infra.tools.js.map +1 -1
  472. package/dist/mcp/tool-groups/kb.tools.d.ts.map +1 -1
  473. package/dist/mcp/tool-groups/kb.tools.js +4 -6
  474. package/dist/mcp/tool-groups/kb.tools.js.map +1 -1
  475. package/dist/mcp/tool-groups/management.tools.d.ts +20 -3
  476. package/dist/mcp/tool-groups/management.tools.d.ts.map +1 -1
  477. package/dist/mcp/tool-groups/management.tools.js +158 -125
  478. package/dist/mcp/tool-groups/management.tools.js.map +1 -1
  479. package/dist/mcp/tool-groups/monitoring.tools.d.ts +10 -1
  480. package/dist/mcp/tool-groups/monitoring.tools.d.ts.map +1 -1
  481. package/dist/mcp/tool-groups/monitoring.tools.js +65 -18
  482. package/dist/mcp/tool-groups/monitoring.tools.js.map +1 -1
  483. package/dist/mcp/unavailable-tools.d.ts +35 -0
  484. package/dist/mcp/unavailable-tools.d.ts.map +1 -0
  485. package/dist/mcp/unavailable-tools.js +59 -0
  486. package/dist/mcp/unavailable-tools.js.map +1 -0
  487. package/dist/monitoring/app-health.indicator.d.ts.map +1 -1
  488. package/dist/monitoring/app-health.indicator.js +12 -0
  489. package/dist/monitoring/app-health.indicator.js.map +1 -1
  490. package/dist/monitoring/desktop-notifier.d.ts +26 -0
  491. package/dist/monitoring/desktop-notifier.d.ts.map +1 -0
  492. package/dist/monitoring/desktop-notifier.js +49 -0
  493. package/dist/monitoring/desktop-notifier.js.map +1 -0
  494. package/dist/monitoring/docker-health.indicator.d.ts +17 -1
  495. package/dist/monitoring/docker-health.indicator.d.ts.map +1 -1
  496. package/dist/monitoring/docker-health.indicator.js +38 -8
  497. package/dist/monitoring/docker-health.indicator.js.map +1 -1
  498. package/dist/monitoring/event-loop-stall.indicator.d.ts +26 -0
  499. package/dist/monitoring/event-loop-stall.indicator.d.ts.map +1 -0
  500. package/dist/monitoring/event-loop-stall.indicator.js +47 -0
  501. package/dist/monitoring/event-loop-stall.indicator.js.map +1 -0
  502. package/dist/monitoring/event-loop-watch.d.ts +124 -0
  503. package/dist/monitoring/event-loop-watch.d.ts.map +1 -0
  504. package/dist/monitoring/event-loop-watch.js +179 -0
  505. package/dist/monitoring/event-loop-watch.js.map +1 -0
  506. package/dist/monitoring/host-disk.d.ts +6 -0
  507. package/dist/monitoring/host-disk.d.ts.map +1 -0
  508. package/dist/monitoring/host-disk.js +26 -0
  509. package/dist/monitoring/host-disk.js.map +1 -0
  510. package/dist/monitoring/log-line.d.ts +188 -0
  511. package/dist/monitoring/log-line.d.ts.map +1 -0
  512. package/dist/monitoring/log-line.js +458 -0
  513. package/dist/monitoring/log-line.js.map +1 -0
  514. package/dist/monitoring/log-manager.d.ts +89 -8
  515. package/dist/monitoring/log-manager.d.ts.map +1 -1
  516. package/dist/monitoring/log-manager.js +246 -77
  517. package/dist/monitoring/log-manager.js.map +1 -1
  518. package/dist/observability/metrics-bridge.d.ts.map +1 -1
  519. package/dist/observability/metrics-server.d.ts.map +1 -1
  520. package/dist/operations.d.ts +16 -0
  521. package/dist/operations.d.ts.map +1 -0
  522. package/dist/operations.js +13 -0
  523. package/dist/operations.js.map +1 -0
  524. package/dist/orchestrator/app-handle.d.ts +157 -2
  525. package/dist/orchestrator/app-handle.d.ts.map +1 -1
  526. package/dist/orchestrator/app-handle.js +165 -3
  527. package/dist/orchestrator/app-handle.js.map +1 -1
  528. package/dist/orchestrator/app-health.d.ts +33 -0
  529. package/dist/orchestrator/app-health.d.ts.map +1 -0
  530. package/dist/orchestrator/app-health.js +74 -0
  531. package/dist/orchestrator/app-health.js.map +1 -0
  532. package/dist/orchestrator/app-traffic.d.ts +14 -0
  533. package/dist/orchestrator/app-traffic.d.ts.map +1 -0
  534. package/dist/orchestrator/app-traffic.js +33 -0
  535. package/dist/orchestrator/app-traffic.js.map +1 -0
  536. package/dist/orchestrator/bootstrap-loader-child.d.ts +19 -0
  537. package/dist/orchestrator/bootstrap-loader-child.d.ts.map +1 -0
  538. package/dist/orchestrator/bootstrap-loader-child.js +43 -0
  539. package/dist/orchestrator/bootstrap-loader-child.js.map +1 -0
  540. package/dist/orchestrator/bootstrap-loader.d.ts +72 -0
  541. package/dist/orchestrator/bootstrap-loader.d.ts.map +1 -1
  542. package/dist/orchestrator/bootstrap-loader.js +350 -5
  543. package/dist/orchestrator/bootstrap-loader.js.map +1 -1
  544. package/dist/orchestrator/bootstrap-process.d.ts +99 -4
  545. package/dist/orchestrator/bootstrap-process.d.ts.map +1 -1
  546. package/dist/orchestrator/bootstrap-process.js +332 -20
  547. package/dist/orchestrator/bootstrap-process.js.map +1 -1
  548. package/dist/orchestrator/build-service.d.ts +6 -12
  549. package/dist/orchestrator/build-service.d.ts.map +1 -1
  550. package/dist/orchestrator/build-service.js +120 -83
  551. package/dist/orchestrator/build-service.js.map +1 -1
  552. package/dist/orchestrator/call-exposed-service.d.ts +7 -0
  553. package/dist/orchestrator/call-exposed-service.d.ts.map +1 -0
  554. package/dist/orchestrator/call-exposed-service.js +50 -0
  555. package/dist/orchestrator/call-exposed-service.js.map +1 -0
  556. package/dist/orchestrator/classic-launcher.d.ts +1 -0
  557. package/dist/orchestrator/classic-launcher.d.ts.map +1 -1
  558. package/dist/orchestrator/classic-launcher.js +40 -4
  559. package/dist/orchestrator/classic-launcher.js.map +1 -1
  560. package/dist/orchestrator/file-watcher.d.ts +79 -1
  561. package/dist/orchestrator/file-watcher.d.ts.map +1 -1
  562. package/dist/orchestrator/file-watcher.js +193 -7
  563. package/dist/orchestrator/file-watcher.js.map +1 -1
  564. package/dist/orchestrator/http-routes.d.ts +4 -0
  565. package/dist/orchestrator/http-routes.d.ts.map +1 -0
  566. package/dist/orchestrator/http-routes.js +19 -0
  567. package/dist/orchestrator/http-routes.js.map +1 -0
  568. package/dist/orchestrator/module-worker-process.d.ts.map +1 -1
  569. package/dist/orchestrator/module-worker-process.js +10 -0
  570. package/dist/orchestrator/module-worker-process.js.map +1 -1
  571. package/dist/orchestrator/orchestrator.service.d.ts +385 -11
  572. package/dist/orchestrator/orchestrator.service.d.ts.map +1 -1
  573. package/dist/orchestrator/orchestrator.service.js +1738 -364
  574. package/dist/orchestrator/orchestrator.service.js.map +1 -1
  575. package/dist/orchestrator/owned-pids.d.ts +83 -0
  576. package/dist/orchestrator/owned-pids.d.ts.map +1 -0
  577. package/dist/orchestrator/owned-pids.js +93 -0
  578. package/dist/orchestrator/owned-pids.js.map +1 -0
  579. package/dist/orchestrator/port-conflict.d.ts +102 -0
  580. package/dist/orchestrator/port-conflict.d.ts.map +1 -0
  581. package/dist/orchestrator/port-conflict.js +131 -0
  582. package/dist/orchestrator/port-conflict.js.map +1 -0
  583. package/dist/orchestrator/process-janitor.d.ts +78 -4
  584. package/dist/orchestrator/process-janitor.d.ts.map +1 -1
  585. package/dist/orchestrator/process-janitor.js +246 -30
  586. package/dist/orchestrator/process-janitor.js.map +1 -1
  587. package/dist/orchestrator/ps-metrics.d.ts +37 -0
  588. package/dist/orchestrator/ps-metrics.d.ts.map +1 -0
  589. package/dist/orchestrator/ps-metrics.js +60 -0
  590. package/dist/orchestrator/ps-metrics.js.map +1 -0
  591. package/dist/orchestrator/service-router.d.ts +58 -2
  592. package/dist/orchestrator/service-router.d.ts.map +1 -1
  593. package/dist/orchestrator/service-router.js +157 -25
  594. package/dist/orchestrator/service-router.js.map +1 -1
  595. package/dist/orchestrator/topology-query.d.ts +49 -0
  596. package/dist/orchestrator/topology-query.d.ts.map +1 -0
  597. package/dist/orchestrator/topology-query.js +54 -0
  598. package/dist/orchestrator/topology-query.js.map +1 -0
  599. package/dist/project/artifact-builder.d.ts +174 -3
  600. package/dist/project/artifact-builder.d.ts.map +1 -1
  601. package/dist/project/artifact-builder.js +454 -42
  602. package/dist/project/artifact-builder.js.map +1 -1
  603. package/dist/project/build-decision.d.ts +58 -0
  604. package/dist/project/build-decision.d.ts.map +1 -0
  605. package/dist/project/build-decision.js +59 -0
  606. package/dist/project/build-decision.js.map +1 -0
  607. package/dist/project/config-resolver.d.ts +6 -4
  608. package/dist/project/config-resolver.d.ts.map +1 -1
  609. package/dist/project/config-resolver.js +128 -74
  610. package/dist/project/config-resolver.js.map +1 -1
  611. package/dist/project/declared-config.d.ts +42 -0
  612. package/dist/project/declared-config.d.ts.map +1 -0
  613. package/dist/project/declared-config.js +60 -0
  614. package/dist/project/declared-config.js.map +1 -0
  615. package/dist/project/dependency-analyzer.d.ts.map +1 -1
  616. package/dist/project/dependency-analyzer.js.map +1 -1
  617. package/dist/project/deploy-phases.d.ts +35 -0
  618. package/dist/project/deploy-phases.d.ts.map +1 -0
  619. package/dist/project/deploy-phases.js +75 -0
  620. package/dist/project/deploy-phases.js.map +1 -0
  621. package/dist/project/exec-in-group.d.ts +49 -0
  622. package/dist/project/exec-in-group.d.ts.map +1 -0
  623. package/dist/project/exec-in-group.js +100 -0
  624. package/dist/project/exec-in-group.js.map +1 -0
  625. package/dist/project/node-app-config.d.ts +101 -0
  626. package/dist/project/node-app-config.d.ts.map +1 -0
  627. package/dist/project/node-app-config.js +134 -0
  628. package/dist/project/node-app-config.js.map +1 -0
  629. package/dist/project/node-app-health.d.ts +76 -0
  630. package/dist/project/node-app-health.d.ts.map +1 -0
  631. package/dist/project/node-app-health.js +119 -0
  632. package/dist/project/node-app-health.js.map +1 -0
  633. package/dist/project/operator-account.d.ts +293 -0
  634. package/dist/project/operator-account.d.ts.map +1 -0
  635. package/dist/project/operator-account.js +373 -0
  636. package/dist/project/operator-account.js.map +1 -0
  637. package/dist/project/registry.d.ts +45 -8
  638. package/dist/project/registry.d.ts.map +1 -1
  639. package/dist/project/registry.js +214 -64
  640. package/dist/project/registry.js.map +1 -1
  641. package/dist/project/requirements-scanner.d.ts +1 -0
  642. package/dist/project/requirements-scanner.d.ts.map +1 -1
  643. package/dist/project/requirements-scanner.js +28 -3
  644. package/dist/project/requirements-scanner.js.map +1 -1
  645. package/dist/project/stack-env.d.ts +37 -0
  646. package/dist/project/stack-env.d.ts.map +1 -0
  647. package/dist/project/stack-env.js +85 -0
  648. package/dist/project/stack-env.js.map +1 -0
  649. package/dist/project/stand-containers.d.ts +11 -0
  650. package/dist/project/stand-containers.d.ts.map +1 -0
  651. package/dist/project/stand-containers.js +27 -0
  652. package/dist/project/stand-containers.js.map +1 -0
  653. package/dist/project/working-tree.d.ts +62 -0
  654. package/dist/project/working-tree.d.ts.map +1 -0
  655. package/dist/project/working-tree.js +101 -0
  656. package/dist/project/working-tree.js.map +1 -0
  657. package/dist/release/artifact-store.d.ts +360 -0
  658. package/dist/release/artifact-store.d.ts.map +1 -0
  659. package/dist/release/artifact-store.js +1040 -0
  660. package/dist/release/artifact-store.js.map +1 -0
  661. package/dist/release/attest-on-node.d.ts +93 -0
  662. package/dist/release/attest-on-node.d.ts.map +1 -0
  663. package/dist/release/attest-on-node.js +207 -0
  664. package/dist/release/attest-on-node.js.map +1 -0
  665. package/dist/release/attest.d.ts +135 -0
  666. package/dist/release/attest.d.ts.map +1 -0
  667. package/dist/release/attest.js +199 -0
  668. package/dist/release/attest.js.map +1 -0
  669. package/dist/release/build-run.d.ts +151 -0
  670. package/dist/release/build-run.d.ts.map +1 -0
  671. package/dist/release/build-run.js +609 -0
  672. package/dist/release/build-run.js.map +1 -0
  673. package/dist/release/builder.d.ts +57 -0
  674. package/dist/release/builder.d.ts.map +1 -0
  675. package/dist/release/builder.js +120 -0
  676. package/dist/release/builder.js.map +1 -0
  677. package/dist/release/daemon-omni.d.ts +41 -0
  678. package/dist/release/daemon-omni.d.ts.map +1 -0
  679. package/dist/release/daemon-omni.js +59 -0
  680. package/dist/release/daemon-omni.js.map +1 -0
  681. package/dist/release/definition-inputs.d.ts +34 -0
  682. package/dist/release/definition-inputs.d.ts.map +1 -0
  683. package/dist/release/definition-inputs.js +90 -0
  684. package/dist/release/definition-inputs.js.map +1 -0
  685. package/dist/release/delivered.d.ts +53 -0
  686. package/dist/release/delivered.d.ts.map +1 -0
  687. package/dist/release/delivered.js +81 -0
  688. package/dist/release/delivered.js.map +1 -0
  689. package/dist/release/layout.d.ts +61 -0
  690. package/dist/release/layout.d.ts.map +1 -0
  691. package/dist/release/layout.js +114 -0
  692. package/dist/release/layout.js.map +1 -0
  693. package/dist/release/load.d.ts +44 -0
  694. package/dist/release/load.d.ts.map +1 -0
  695. package/dist/release/load.js +84 -0
  696. package/dist/release/load.js.map +1 -0
  697. package/dist/release/manifest.d.ts +299 -0
  698. package/dist/release/manifest.d.ts.map +1 -0
  699. package/dist/release/manifest.js +176 -0
  700. package/dist/release/manifest.js.map +1 -0
  701. package/dist/release/migrations.d.ts +32 -0
  702. package/dist/release/migrations.d.ts.map +1 -0
  703. package/dist/release/migrations.js +51 -0
  704. package/dist/release/migrations.js.map +1 -0
  705. package/dist/release/publish.d.ts +71 -0
  706. package/dist/release/publish.d.ts.map +1 -0
  707. package/dist/release/publish.js +102 -0
  708. package/dist/release/publish.js.map +1 -0
  709. package/dist/release/store.d.ts +174 -0
  710. package/dist/release/store.d.ts.map +1 -0
  711. package/dist/release/store.js +257 -0
  712. package/dist/release/store.js.map +1 -0
  713. package/dist/services/alert.rpc-service.d.ts +11 -15
  714. package/dist/services/alert.rpc-service.d.ts.map +1 -1
  715. package/dist/services/alert.rpc-service.js +29 -14
  716. package/dist/services/alert.rpc-service.js.map +1 -1
  717. package/dist/services/alert.service.d.ts +96 -59
  718. package/dist/services/alert.service.d.ts.map +1 -1
  719. package/dist/services/alert.service.js +455 -70
  720. package/dist/services/alert.service.js.map +1 -1
  721. package/dist/services/anonymous-input.d.ts +26 -0
  722. package/dist/services/anonymous-input.d.ts.map +1 -0
  723. package/dist/services/anonymous-input.js +44 -0
  724. package/dist/services/anonymous-input.js.map +1 -0
  725. package/dist/services/audit.rpc-service.d.ts +27 -0
  726. package/dist/services/audit.rpc-service.d.ts.map +1 -0
  727. package/dist/services/audit.rpc-service.js +57 -0
  728. package/dist/services/audit.rpc-service.js.map +1 -0
  729. package/dist/services/audit.service.d.ts +165 -0
  730. package/dist/services/audit.service.d.ts.map +1 -0
  731. package/dist/services/audit.service.js +242 -0
  732. package/dist/services/audit.service.js.map +1 -0
  733. package/dist/services/auth-context.d.ts +21 -1
  734. package/dist/services/auth-context.d.ts.map +1 -1
  735. package/dist/services/auth-context.js +51 -5
  736. package/dist/services/auth-context.js.map +1 -1
  737. package/dist/services/auth.rpc-service.d.ts +0 -1
  738. package/dist/services/auth.rpc-service.d.ts.map +1 -1
  739. package/dist/services/auth.rpc-service.js +23 -9
  740. package/dist/services/auth.rpc-service.js.map +1 -1
  741. package/dist/services/auth.service.d.ts +47 -33
  742. package/dist/services/auth.service.d.ts.map +1 -1
  743. package/dist/services/auth.service.js +155 -23
  744. package/dist/services/auth.service.js.map +1 -1
  745. package/dist/services/backup-pipeline.d.ts +97 -0
  746. package/dist/services/backup-pipeline.d.ts.map +1 -0
  747. package/dist/services/backup-pipeline.js +286 -0
  748. package/dist/services/backup-pipeline.js.map +1 -0
  749. package/dist/services/backup-schedule.d.ts +69 -0
  750. package/dist/services/backup-schedule.d.ts.map +1 -0
  751. package/dist/services/backup-schedule.js +132 -0
  752. package/dist/services/backup-schedule.js.map +1 -0
  753. package/dist/services/backup.rpc-service.d.ts +31 -2
  754. package/dist/services/backup.rpc-service.d.ts.map +1 -1
  755. package/dist/services/backup.rpc-service.js +53 -0
  756. package/dist/services/backup.rpc-service.js.map +1 -1
  757. package/dist/services/backup.service.d.ts +458 -14
  758. package/dist/services/backup.service.d.ts.map +1 -1
  759. package/dist/services/backup.service.js +1368 -170
  760. package/dist/services/backup.service.js.map +1 -1
  761. package/dist/services/bundle-build-worker.d.ts +19 -0
  762. package/dist/services/bundle-build-worker.d.ts.map +1 -0
  763. package/dist/services/bundle-build-worker.js +40 -0
  764. package/dist/services/bundle-build-worker.js.map +1 -0
  765. package/dist/services/bundle-builder.d.ts +490 -0
  766. package/dist/services/bundle-builder.d.ts.map +1 -0
  767. package/dist/services/bundle-builder.js +1159 -0
  768. package/dist/services/bundle-builder.js.map +1 -0
  769. package/dist/services/bundle-worker-protocol.d.ts +19 -0
  770. package/dist/services/bundle-worker-protocol.d.ts.map +1 -0
  771. package/dist/services/bundle-worker-protocol.js +32 -0
  772. package/dist/services/bundle-worker-protocol.js.map +1 -0
  773. package/dist/services/deploy.rpc-service.d.ts +4 -1
  774. package/dist/services/deploy.rpc-service.d.ts.map +1 -1
  775. package/dist/services/deploy.rpc-service.js +10 -0
  776. package/dist/services/deploy.rpc-service.js.map +1 -1
  777. package/dist/services/deploy.service.d.ts +14 -24
  778. package/dist/services/deploy.service.d.ts.map +1 -1
  779. package/dist/services/deploy.service.js +46 -5
  780. package/dist/services/deploy.service.js.map +1 -1
  781. package/dist/services/discovery.rpc-service.d.ts +2 -1
  782. package/dist/services/discovery.rpc-service.d.ts.map +1 -1
  783. package/dist/services/discovery.rpc-service.js.map +1 -1
  784. package/dist/services/discovery.service.d.ts +32 -28
  785. package/dist/services/discovery.service.d.ts.map +1 -1
  786. package/dist/services/discovery.service.js +108 -133
  787. package/dist/services/discovery.service.js.map +1 -1
  788. package/dist/services/event-broadcaster.rpc-service.d.ts +36 -23
  789. package/dist/services/event-broadcaster.rpc-service.d.ts.map +1 -1
  790. package/dist/services/event-broadcaster.rpc-service.js +70 -32
  791. package/dist/services/event-broadcaster.rpc-service.js.map +1 -1
  792. package/dist/services/event-broadcaster.service.d.ts.map +1 -1
  793. package/dist/services/event-broadcaster.service.js.map +1 -1
  794. package/dist/services/fleet.rpc-service.d.ts +2 -1
  795. package/dist/services/fleet.rpc-service.d.ts.map +1 -1
  796. package/dist/services/fleet.rpc-service.js +3 -1
  797. package/dist/services/fleet.rpc-service.js.map +1 -1
  798. package/dist/services/fleet.service.d.ts +32 -34
  799. package/dist/services/fleet.service.d.ts.map +1 -1
  800. package/dist/services/fleet.service.js +66 -10
  801. package/dist/services/fleet.service.js.map +1 -1
  802. package/dist/services/health-check.rpc-service.d.ts +2 -1
  803. package/dist/services/health-check.rpc-service.d.ts.map +1 -1
  804. package/dist/services/health-check.rpc-service.js.map +1 -1
  805. package/dist/services/health-check.service.d.ts +50 -36
  806. package/dist/services/health-check.service.d.ts.map +1 -1
  807. package/dist/services/health-check.service.js +179 -182
  808. package/dist/services/health-check.service.js.map +1 -1
  809. package/dist/services/host-operation-barrier.d.ts +8 -0
  810. package/dist/services/host-operation-barrier.d.ts.map +1 -0
  811. package/dist/services/host-operation-barrier.js +44 -0
  812. package/dist/services/host-operation-barrier.js.map +1 -0
  813. package/dist/services/host-upgrade.d.ts +129 -0
  814. package/dist/services/host-upgrade.d.ts.map +1 -0
  815. package/dist/services/host-upgrade.js +352 -0
  816. package/dist/services/host-upgrade.js.map +1 -0
  817. package/dist/services/infrastructure.rpc-service.d.ts +312 -2
  818. package/dist/services/infrastructure.rpc-service.d.ts.map +1 -1
  819. package/dist/services/infrastructure.rpc-service.js +584 -11
  820. package/dist/services/infrastructure.rpc-service.js.map +1 -1
  821. package/dist/services/kubernetes.rpc-service.d.ts +2 -1
  822. package/dist/services/kubernetes.rpc-service.d.ts.map +1 -1
  823. package/dist/services/kubernetes.rpc-service.js.map +1 -1
  824. package/dist/services/kubernetes.service.d.ts +5 -39
  825. package/dist/services/kubernetes.service.d.ts.map +1 -1
  826. package/dist/services/kubernetes.service.js +29 -5
  827. package/dist/services/kubernetes.service.js.map +1 -1
  828. package/dist/services/local-bundle.d.ts +160 -0
  829. package/dist/services/local-bundle.d.ts.map +1 -0
  830. package/dist/services/local-bundle.js +242 -0
  831. package/dist/services/local-bundle.js.map +1 -0
  832. package/dist/services/lockout.d.ts +28 -0
  833. package/dist/services/lockout.d.ts.map +1 -0
  834. package/dist/services/lockout.js +41 -0
  835. package/dist/services/lockout.js.map +1 -0
  836. package/dist/services/log-collector.rpc-service.d.ts +16 -0
  837. package/dist/services/log-collector.rpc-service.d.ts.map +1 -1
  838. package/dist/services/log-collector.rpc-service.js +16 -0
  839. package/dist/services/log-collector.rpc-service.js.map +1 -1
  840. package/dist/services/log-collector.service.d.ts +172 -54
  841. package/dist/services/log-collector.service.d.ts.map +1 -1
  842. package/dist/services/log-collector.service.js +492 -102
  843. package/dist/services/log-collector.service.js.map +1 -1
  844. package/dist/services/log-retention.d.ts +50 -0
  845. package/dist/services/log-retention.d.ts.map +1 -0
  846. package/dist/services/log-retention.js +52 -0
  847. package/dist/services/log-retention.js.map +1 -0
  848. package/dist/services/maintenance-lock.d.ts +162 -0
  849. package/dist/services/maintenance-lock.d.ts.map +1 -0
  850. package/dist/services/maintenance-lock.js +316 -0
  851. package/dist/services/maintenance-lock.js.map +1 -0
  852. package/dist/services/master-address.d.ts +107 -0
  853. package/dist/services/master-address.d.ts.map +1 -0
  854. package/dist/services/master-address.js +154 -0
  855. package/dist/services/master-address.js.map +1 -0
  856. package/dist/services/migration-output.d.ts +16 -0
  857. package/dist/services/migration-output.d.ts.map +1 -0
  858. package/dist/services/migration-output.js +22 -0
  859. package/dist/services/migration-output.js.map +1 -0
  860. package/dist/services/netron-telemetry-transport.d.ts.map +1 -1
  861. package/dist/services/netron-telemetry-transport.js.map +1 -1
  862. package/dist/services/node-deploy-lease.d.ts +171 -0
  863. package/dist/services/node-deploy-lease.d.ts.map +1 -0
  864. package/dist/services/node-deploy-lease.js +460 -0
  865. package/dist/services/node-deploy-lease.js.map +1 -0
  866. package/dist/services/node-health.repository.d.ts +63 -4
  867. package/dist/services/node-health.repository.d.ts.map +1 -1
  868. package/dist/services/node-health.repository.js +108 -67
  869. package/dist/services/node-health.repository.js.map +1 -1
  870. package/dist/services/node-manager.rpc-service.d.ts +360 -10
  871. package/dist/services/node-manager.rpc-service.d.ts.map +1 -1
  872. package/dist/services/node-manager.rpc-service.js +754 -34
  873. package/dist/services/node-manager.rpc-service.js.map +1 -1
  874. package/dist/services/node-manager.service.d.ts +267 -95
  875. package/dist/services/node-manager.service.d.ts.map +1 -1
  876. package/dist/services/node-manager.service.js +805 -103
  877. package/dist/services/node-manager.service.js.map +1 -1
  878. package/dist/services/node-upgrade.d.ts +129 -0
  879. package/dist/services/node-upgrade.d.ts.map +1 -0
  880. package/dist/services/node-upgrade.js +161 -0
  881. package/dist/services/node-upgrade.js.map +1 -0
  882. package/dist/services/node-upgrade.service.d.ts +232 -0
  883. package/dist/services/node-upgrade.service.d.ts.map +1 -0
  884. package/dist/services/node-upgrade.service.js +468 -0
  885. package/dist/services/node-upgrade.service.js.map +1 -0
  886. package/dist/services/pipeline.rpc-service.d.ts +6 -1
  887. package/dist/services/pipeline.rpc-service.d.ts.map +1 -1
  888. package/dist/services/pipeline.rpc-service.js +9 -0
  889. package/dist/services/pipeline.rpc-service.js.map +1 -1
  890. package/dist/services/pipeline.service.d.ts +17 -46
  891. package/dist/services/pipeline.service.d.ts.map +1 -1
  892. package/dist/services/pipeline.service.js +84 -9
  893. package/dist/services/pipeline.service.js.map +1 -1
  894. package/dist/services/project.rpc-service.d.ts +140 -5
  895. package/dist/services/project.rpc-service.d.ts.map +1 -1
  896. package/dist/services/project.rpc-service.js +260 -22
  897. package/dist/services/project.rpc-service.js.map +1 -1
  898. package/dist/services/project.service.d.ts +687 -15
  899. package/dist/services/project.service.d.ts.map +1 -1
  900. package/dist/services/project.service.js +2846 -337
  901. package/dist/services/project.service.js.map +1 -1
  902. package/dist/services/redeploy-decision.d.ts +176 -0
  903. package/dist/services/redeploy-decision.d.ts.map +1 -0
  904. package/dist/services/redeploy-decision.js +142 -0
  905. package/dist/services/redeploy-decision.js.map +1 -0
  906. package/dist/services/release.rpc-service.d.ts +159 -0
  907. package/dist/services/release.rpc-service.d.ts.map +1 -0
  908. package/dist/services/release.rpc-service.js +395 -0
  909. package/dist/services/release.rpc-service.js.map +1 -0
  910. package/dist/services/release.service.d.ts +114 -0
  911. package/dist/services/release.service.d.ts.map +1 -0
  912. package/dist/services/release.service.js +208 -0
  913. package/dist/services/release.service.js.map +1 -0
  914. package/dist/services/remote-deployer.service.d.ts +580 -8
  915. package/dist/services/remote-deployer.service.d.ts.map +1 -1
  916. package/dist/services/remote-deployer.service.js +1786 -169
  917. package/dist/services/remote-deployer.service.js.map +1 -1
  918. package/dist/services/remote-ops.service.d.ts +51 -9
  919. package/dist/services/remote-ops.service.d.ts.map +1 -1
  920. package/dist/services/remote-ops.service.js +235 -39
  921. package/dist/services/remote-ops.service.js.map +1 -1
  922. package/dist/services/remote-provisioner.d.ts +124 -0
  923. package/dist/services/remote-provisioner.d.ts.map +1 -0
  924. package/dist/services/remote-provisioner.js +258 -0
  925. package/dist/services/remote-provisioner.js.map +1 -0
  926. package/dist/services/secrets.rpc-service.d.ts +51 -2
  927. package/dist/services/secrets.rpc-service.d.ts.map +1 -1
  928. package/dist/services/secrets.rpc-service.js +81 -2
  929. package/dist/services/secrets.rpc-service.js.map +1 -1
  930. package/dist/services/secrets.service.d.ts +108 -9
  931. package/dist/services/secrets.service.d.ts.map +1 -1
  932. package/dist/services/secrets.service.js +313 -48
  933. package/dist/services/secrets.service.js.map +1 -1
  934. package/dist/services/slave-storage.service.d.ts +107 -0
  935. package/dist/services/slave-storage.service.d.ts.map +1 -1
  936. package/dist/services/slave-storage.service.js +171 -2
  937. package/dist/services/slave-storage.service.js.map +1 -1
  938. package/dist/services/static-carry-forward.d.ts +110 -0
  939. package/dist/services/static-carry-forward.d.ts.map +1 -0
  940. package/dist/services/static-carry-forward.js +433 -0
  941. package/dist/services/static-carry-forward.js.map +1 -0
  942. package/dist/services/sync-policy.d.ts +167 -0
  943. package/dist/services/sync-policy.d.ts.map +1 -0
  944. package/dist/services/sync-policy.js +161 -0
  945. package/dist/services/sync-policy.js.map +1 -0
  946. package/dist/services/sync-summary.d.ts +39 -0
  947. package/dist/services/sync-summary.d.ts.map +1 -0
  948. package/dist/services/sync-summary.js +62 -0
  949. package/dist/services/sync-summary.js.map +1 -0
  950. package/dist/services/sync.rpc-service.d.ts +19 -7
  951. package/dist/services/sync.rpc-service.d.ts.map +1 -1
  952. package/dist/services/sync.rpc-service.js +36 -5
  953. package/dist/services/sync.rpc-service.js.map +1 -1
  954. package/dist/services/sync.service.d.ts +350 -18
  955. package/dist/services/sync.service.d.ts.map +1 -1
  956. package/dist/services/sync.service.js +864 -116
  957. package/dist/services/sync.service.js.map +1 -1
  958. package/dist/services/system-info.rpc-service.d.ts +2 -1
  959. package/dist/services/system-info.rpc-service.d.ts.map +1 -1
  960. package/dist/services/system-info.rpc-service.js.map +1 -1
  961. package/dist/services/system-info.service.d.ts +56 -68
  962. package/dist/services/system-info.service.d.ts.map +1 -1
  963. package/dist/services/system-info.service.js +240 -127
  964. package/dist/services/system-info.service.js.map +1 -1
  965. package/dist/services/telemetry.rpc-service.d.ts.map +1 -1
  966. package/dist/services/telemetry.rpc-service.js +9 -3
  967. package/dist/services/telemetry.rpc-service.js.map +1 -1
  968. package/dist/services/telemetry.service.d.ts +4 -2
  969. package/dist/services/telemetry.service.d.ts.map +1 -1
  970. package/dist/services/telemetry.service.js +4 -2
  971. package/dist/services/telemetry.service.js.map +1 -1
  972. package/dist/services/trace-collector.rpc-service.d.ts +2 -1
  973. package/dist/services/trace-collector.rpc-service.d.ts.map +1 -1
  974. package/dist/services/trace-collector.rpc-service.js.map +1 -1
  975. package/dist/services/trace-collector.service.d.ts +2 -41
  976. package/dist/services/trace-collector.service.d.ts.map +1 -1
  977. package/dist/services/trace-collector.service.js +27 -7
  978. package/dist/services/trace-collector.service.js.map +1 -1
  979. package/dist/services/wait-for-postgres.d.ts +28 -0
  980. package/dist/services/wait-for-postgres.d.ts.map +1 -0
  981. package/dist/services/wait-for-postgres.js +77 -0
  982. package/dist/services/wait-for-postgres.js.map +1 -0
  983. package/dist/shared/alert-expression.d.ts +60 -0
  984. package/dist/shared/alert-expression.d.ts.map +1 -0
  985. package/dist/shared/alert-expression.js +124 -0
  986. package/dist/shared/alert-expression.js.map +1 -0
  987. package/dist/shared/audit-outcome.d.ts +39 -0
  988. package/dist/shared/audit-outcome.d.ts.map +1 -0
  989. package/dist/shared/audit-outcome.js +45 -0
  990. package/dist/shared/audit-outcome.js.map +1 -0
  991. package/dist/shared/build-freshness.d.ts +59 -0
  992. package/dist/shared/build-freshness.d.ts.map +1 -0
  993. package/dist/shared/build-freshness.js +113 -0
  994. package/dist/shared/build-freshness.js.map +1 -0
  995. package/dist/shared/build-stamp.d.ts +26 -0
  996. package/dist/shared/build-stamp.d.ts.map +1 -0
  997. package/dist/shared/build-stamp.js +36 -0
  998. package/dist/shared/build-stamp.js.map +1 -0
  999. package/dist/shared/describe-error.d.ts +22 -0
  1000. package/dist/shared/describe-error.d.ts.map +1 -0
  1001. package/dist/shared/describe-error.js +39 -0
  1002. package/dist/shared/describe-error.js.map +1 -0
  1003. package/dist/shared/dto/alerts.d.ts +101 -0
  1004. package/dist/shared/dto/alerts.d.ts.map +1 -0
  1005. package/dist/shared/dto/alerts.js +9 -0
  1006. package/dist/shared/dto/alerts.js.map +1 -0
  1007. package/dist/shared/dto/auth.d.ts +49 -0
  1008. package/dist/shared/dto/auth.d.ts.map +1 -0
  1009. package/dist/shared/dto/auth.js +14 -0
  1010. package/dist/shared/dto/auth.js.map +1 -0
  1011. package/dist/shared/dto/backups.d.ts +16 -0
  1012. package/dist/shared/dto/backups.d.ts.map +1 -0
  1013. package/dist/shared/dto/backups.js +9 -0
  1014. package/dist/shared/dto/backups.js.map +1 -0
  1015. package/dist/shared/dto/deploy.d.ts +28 -0
  1016. package/dist/shared/dto/deploy.d.ts.map +1 -0
  1017. package/dist/shared/dto/deploy.js +8 -0
  1018. package/dist/shared/dto/deploy.js.map +1 -0
  1019. package/dist/shared/dto/discovery.d.ts +24 -0
  1020. package/dist/shared/dto/discovery.d.ts.map +1 -0
  1021. package/dist/shared/dto/discovery.js +9 -0
  1022. package/dist/shared/dto/discovery.js.map +1 -0
  1023. package/dist/shared/dto/fleet.d.ts +35 -0
  1024. package/dist/shared/dto/fleet.d.ts.map +1 -0
  1025. package/dist/shared/dto/fleet.js +8 -0
  1026. package/dist/shared/dto/fleet.js.map +1 -0
  1027. package/dist/shared/dto/health.d.ts +26 -0
  1028. package/dist/shared/dto/health.d.ts.map +1 -0
  1029. package/dist/shared/dto/health.js +9 -0
  1030. package/dist/shared/dto/health.js.map +1 -0
  1031. package/dist/shared/dto/kubernetes.d.ts +44 -0
  1032. package/dist/shared/dto/kubernetes.d.ts.map +1 -0
  1033. package/dist/shared/dto/kubernetes.js +9 -0
  1034. package/dist/shared/dto/kubernetes.js.map +1 -0
  1035. package/dist/shared/dto/logs.d.ts +77 -0
  1036. package/dist/shared/dto/logs.d.ts.map +1 -0
  1037. package/dist/shared/dto/logs.js +10 -0
  1038. package/dist/shared/dto/logs.js.map +1 -0
  1039. package/dist/shared/dto/metrics.d.ts +65 -0
  1040. package/dist/shared/dto/metrics.d.ts.map +1 -0
  1041. package/dist/shared/dto/metrics.js +17 -0
  1042. package/dist/shared/dto/metrics.js.map +1 -0
  1043. package/dist/shared/dto/nodes.d.ts +347 -0
  1044. package/dist/shared/dto/nodes.d.ts.map +1 -0
  1045. package/dist/shared/dto/nodes.js +8 -0
  1046. package/dist/shared/dto/nodes.js.map +1 -0
  1047. package/dist/shared/dto/pipelines.d.ts +53 -0
  1048. package/dist/shared/dto/pipelines.d.ts.map +1 -0
  1049. package/dist/shared/dto/pipelines.js +9 -0
  1050. package/dist/shared/dto/pipelines.js.map +1 -0
  1051. package/dist/shared/dto/project.d.ts +142 -8
  1052. package/dist/shared/dto/project.d.ts.map +1 -1
  1053. package/dist/shared/dto/services.d.ts +858 -7
  1054. package/dist/shared/dto/services.d.ts.map +1 -1
  1055. package/dist/shared/dto/system-info.d.ts +93 -0
  1056. package/dist/shared/dto/system-info.d.ts.map +1 -0
  1057. package/dist/shared/dto/system-info.js +9 -0
  1058. package/dist/shared/dto/system-info.js.map +1 -0
  1059. package/dist/shared/dto/traces.d.ts +49 -0
  1060. package/dist/shared/dto/traces.d.ts.map +1 -0
  1061. package/dist/shared/dto/traces.js +9 -0
  1062. package/dist/shared/dto/traces.js.map +1 -0
  1063. package/dist/shared/env-config.d.ts +74 -0
  1064. package/dist/shared/env-config.d.ts.map +1 -0
  1065. package/dist/shared/env-config.js +99 -0
  1066. package/dist/shared/env-config.js.map +1 -0
  1067. package/dist/shared/events.d.ts +45 -39
  1068. package/dist/shared/events.d.ts.map +1 -1
  1069. package/dist/shared/events.js +6 -0
  1070. package/dist/shared/events.js.map +1 -1
  1071. package/dist/shared/format.d.ts +1 -1
  1072. package/dist/shared/format.d.ts.map +1 -1
  1073. package/dist/shared/format.js +4 -0
  1074. package/dist/shared/format.js.map +1 -1
  1075. package/dist/shared/local-infra-host.d.ts +28 -0
  1076. package/dist/shared/local-infra-host.d.ts.map +1 -0
  1077. package/dist/shared/local-infra-host.js +28 -0
  1078. package/dist/shared/local-infra-host.js.map +1 -0
  1079. package/dist/shared/node-check.d.ts +37 -0
  1080. package/dist/shared/node-check.d.ts.map +1 -0
  1081. package/dist/shared/node-check.js +41 -0
  1082. package/dist/shared/node-check.js.map +1 -0
  1083. package/dist/shared/paths.d.ts +29 -0
  1084. package/dist/shared/paths.d.ts.map +1 -0
  1085. package/dist/shared/paths.js +54 -0
  1086. package/dist/shared/paths.js.map +1 -0
  1087. package/dist/shared/pnpm.d.ts +29 -0
  1088. package/dist/shared/pnpm.d.ts.map +1 -0
  1089. package/dist/shared/pnpm.js +57 -0
  1090. package/dist/shared/pnpm.js.map +1 -0
  1091. package/dist/shared/private-files.d.ts +29 -0
  1092. package/dist/shared/private-files.d.ts.map +1 -0
  1093. package/dist/shared/private-files.js +109 -0
  1094. package/dist/shared/private-files.js.map +1 -0
  1095. package/dist/shared/redact-env.d.ts +24 -0
  1096. package/dist/shared/redact-env.d.ts.map +1 -0
  1097. package/dist/shared/redact-env.js +36 -0
  1098. package/dist/shared/redact-env.js.map +1 -0
  1099. package/dist/shared/release-reading.d.ts +85 -0
  1100. package/dist/shared/release-reading.d.ts.map +1 -0
  1101. package/dist/shared/release-reading.js +81 -0
  1102. package/dist/shared/release-reading.js.map +1 -0
  1103. package/dist/shared/roles.d.ts +58 -3
  1104. package/dist/shared/roles.d.ts.map +1 -1
  1105. package/dist/shared/roles.js +55 -0
  1106. package/dist/shared/roles.js.map +1 -1
  1107. package/dist/shared/rpcauth.d.ts +13 -0
  1108. package/dist/shared/rpcauth.d.ts.map +1 -0
  1109. package/dist/shared/rpcauth.js +23 -0
  1110. package/dist/shared/rpcauth.js.map +1 -0
  1111. package/dist/shared/shell-escape.d.ts +9 -0
  1112. package/dist/shared/shell-escape.d.ts.map +1 -0
  1113. package/dist/shared/shell-escape.js +11 -0
  1114. package/dist/shared/shell-escape.js.map +1 -0
  1115. package/dist/shared/sync-reading.d.ts +45 -0
  1116. package/dist/shared/sync-reading.d.ts.map +1 -0
  1117. package/dist/shared/sync-reading.js +42 -0
  1118. package/dist/shared/sync-reading.js.map +1 -0
  1119. package/dist/shared/tokens.d.ts +19 -0
  1120. package/dist/shared/tokens.d.ts.map +1 -1
  1121. package/dist/shared/tokens.js +28 -0
  1122. package/dist/shared/tokens.js.map +1 -1
  1123. package/dist/webapp/webapp.service.d.ts +33 -2
  1124. package/dist/webapp/webapp.service.d.ts.map +1 -1
  1125. package/dist/webapp/webapp.service.js +143 -31
  1126. package/dist/webapp/webapp.service.js.map +1 -1
  1127. package/dist/workers/health-monitor-process.d.ts.map +1 -1
  1128. package/dist/workers/health-monitor-process.js +10 -0
  1129. package/dist/workers/health-monitor-process.js.map +1 -1
  1130. package/dist/workers/health-monitor.service.d.ts +45 -3
  1131. package/dist/workers/health-monitor.service.d.ts.map +1 -1
  1132. package/dist/workers/health-monitor.service.js +152 -25
  1133. package/dist/workers/health-monitor.service.js.map +1 -1
  1134. package/dist/workers/system-worker-manager.d.ts +43 -0
  1135. package/dist/workers/system-worker-manager.d.ts.map +1 -1
  1136. package/dist/workers/system-worker-manager.js +150 -3
  1137. package/dist/workers/system-worker-manager.js.map +1 -1
  1138. package/dist/workers/types.d.ts +8 -2
  1139. package/dist/workers/types.d.ts.map +1 -1
  1140. package/package.json +68 -30
  1141. package/webapp/dist/BUILD.json +5 -0
  1142. package/webapp/dist/assets/FormControlLabel-BbSxpQwN.js +1 -0
  1143. package/webapp/dist/assets/Grid-DizFZYue.js +1 -0
  1144. package/webapp/dist/assets/Switch-Bfiqzx8l.js +1 -0
  1145. package/webapp/dist/assets/alerts-DH_RVRS3.js +1 -0
  1146. package/webapp/dist/assets/apps-B96355sz.js +1 -0
  1147. package/webapp/dist/assets/audit-CYGtbi4k.js +1 -0
  1148. package/webapp/dist/assets/client-BmqFExWT.js +1 -0
  1149. package/webapp/dist/assets/constants-veKGFkC6.js +1 -0
  1150. package/webapp/dist/assets/containers-B_TpThln.js +2 -0
  1151. package/webapp/dist/assets/daemon-memory-CCPZ4cV_.js +1 -0
  1152. package/webapp/dist/assets/dashboard-DHf2_65N.js +1 -0
  1153. package/webapp/dist/assets/dashboard-builder-CWY2pjeG.js +1 -0
  1154. package/webapp/dist/assets/deploy-release-dialog-Dp-zpSAd.js +1 -0
  1155. package/webapp/dist/assets/deployments-tJI6O3fK.js +1 -0
  1156. package/webapp/dist/assets/detail-B2zP1Hto.js +8 -0
  1157. package/webapp/dist/assets/detail-CoIrwmsy.js +1 -0
  1158. package/webapp/dist/assets/detail-DWaGaOtL.js +2 -0
  1159. package/webapp/dist/assets/formatters-BqjL73N3.js +1 -0
  1160. package/webapp/dist/assets/index-Dvo0iprx.css +1 -0
  1161. package/webapp/dist/assets/index-vMnY_4jR.js +412 -0
  1162. package/webapp/dist/assets/logs-DTRjOfX8.js +12 -0
  1163. package/webapp/dist/assets/maplibre-gl-DowHoZAx.js +814 -0
  1164. package/webapp/dist/assets/metrics-UCajtdox.js +2 -0
  1165. package/webapp/dist/assets/nodes-DuTVU0f6.js +7 -0
  1166. package/webapp/dist/assets/pipelines-C8BeDcAl.js +4 -0
  1167. package/webapp/dist/assets/projects-Dp_zFfLY.js +1 -0
  1168. package/webapp/dist/assets/react-DlmbjZJV.js +1 -0
  1169. package/webapp/dist/assets/react-apexcharts.esm-C8pn45ml.js +97 -0
  1170. package/webapp/dist/assets/release-bits-BzTKz5fM.js +1 -0
  1171. package/webapp/dist/assets/release-build-panel-DcW9YcXQ.js +1 -0
  1172. package/webapp/dist/assets/releases-B4hcr1bE.js +2 -0
  1173. package/webapp/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
  1174. package/webapp/dist/assets/settings-DIteZTbe.js +1 -0
  1175. package/webapp/dist/assets/settled-pair-C7SjZuns.js +1 -0
  1176. package/webapp/dist/assets/sign-in-D4kMBOHp.js +1 -0
  1177. package/webapp/dist/assets/stacks-Brnf-EB7.js +1 -0
  1178. package/webapp/dist/assets/sync-reading-C3PnJ7AX.js +1 -0
  1179. package/webapp/dist/assets/system-info-CFDNVKkU.js +1 -0
  1180. package/webapp/dist/assets/topology-7WeTUsDN.js +12 -0
  1181. package/webapp/dist/assets/topology-DLioOiRN.css +1 -0
  1182. package/webapp/dist/assets/traces-9c3gwCN-.js +1 -0
  1183. package/webapp/dist/assets/use-stack-context-CnA5FTHu.js +1 -0
  1184. package/webapp/dist/index.html +6 -6
  1185. package/dist/commands/dev.d.ts +0 -23
  1186. package/dist/commands/dev.d.ts.map +0 -1
  1187. package/dist/commands/dev.js +0 -76
  1188. package/dist/commands/dev.js.map +0 -1
  1189. package/dist/config/define-app.d.ts +0 -6
  1190. package/dist/config/define-app.d.ts.map +0 -1
  1191. package/dist/config/define-app.js +0 -38
  1192. package/dist/config/define-app.js.map +0 -1
  1193. package/dist/infrastructure/gateway-generator.d.ts +0 -63
  1194. package/dist/infrastructure/gateway-generator.d.ts.map +0 -1
  1195. package/dist/infrastructure/gateway-generator.js +0 -253
  1196. package/dist/infrastructure/gateway-generator.js.map +0 -1
  1197. package/dist/monitoring/health.service.d.ts +0 -14
  1198. package/dist/monitoring/health.service.d.ts.map +0 -1
  1199. package/dist/monitoring/health.service.js +0 -50
  1200. package/dist/monitoring/health.service.js.map +0 -1
  1201. package/dist/monitoring/metrics.service.d.ts +0 -14
  1202. package/dist/monitoring/metrics.service.d.ts.map +0 -1
  1203. package/dist/monitoring/metrics.service.js +0 -40
  1204. package/dist/monitoring/metrics.service.js.map +0 -1
  1205. package/dist/orchestrator/bootstrap-runner.d.ts +0 -15
  1206. package/dist/orchestrator/bootstrap-runner.d.ts.map +0 -1
  1207. package/dist/orchestrator/bootstrap-runner.js +0 -152
  1208. package/dist/orchestrator/bootstrap-runner.js.map +0 -1
  1209. package/dist/orchestrator/topology-proxy.d.ts +0 -65
  1210. package/dist/orchestrator/topology-proxy.d.ts.map +0 -1
  1211. package/dist/orchestrator/topology-proxy.js +0 -209
  1212. package/dist/orchestrator/topology-proxy.js.map +0 -1
  1213. package/dist/orchestrator/topology-relay.d.ts +0 -111
  1214. package/dist/orchestrator/topology-relay.d.ts.map +0 -1
  1215. package/dist/orchestrator/topology-relay.js +0 -255
  1216. package/dist/orchestrator/topology-relay.js.map +0 -1
  1217. package/dist/services/auth-middleware.d.ts +0 -47
  1218. package/dist/services/auth-middleware.d.ts.map +0 -1
  1219. package/dist/services/auth-middleware.js +0 -158
  1220. package/dist/services/auth-middleware.js.map +0 -1
  1221. package/dist/services/environment.rpc-service.d.ts +0 -22
  1222. package/dist/services/environment.rpc-service.d.ts.map +0 -1
  1223. package/dist/services/environment.rpc-service.js +0 -65
  1224. package/dist/services/environment.rpc-service.js.map +0 -1
  1225. package/dist/services/environment.service.d.ts +0 -87
  1226. package/dist/services/environment.service.d.ts.map +0 -1
  1227. package/dist/services/environment.service.js +0 -220
  1228. package/dist/services/environment.service.js.map +0 -1
  1229. package/dist/services/metrics-collector.service.d.ts +0 -103
  1230. package/dist/services/metrics-collector.service.d.ts.map +0 -1
  1231. package/dist/services/metrics-collector.service.js +0 -235
  1232. package/dist/services/metrics-collector.service.js.map +0 -1
  1233. package/dist/services/metrics.rpc-service.d.ts +0 -41
  1234. package/dist/services/metrics.rpc-service.d.ts.map +0 -1
  1235. package/dist/services/metrics.rpc-service.js +0 -90
  1236. package/dist/services/metrics.rpc-service.js.map +0 -1
  1237. package/dist/services/metrics.service.d.ts +0 -72
  1238. package/dist/services/metrics.service.d.ts.map +0 -1
  1239. package/dist/services/metrics.service.js +0 -185
  1240. package/dist/services/metrics.service.js.map +0 -1
  1241. package/dist/supervisor/omnitron-supervisor.d.ts +0 -21
  1242. package/dist/supervisor/omnitron-supervisor.d.ts.map +0 -1
  1243. package/dist/supervisor/omnitron-supervisor.js +0 -43
  1244. package/dist/supervisor/omnitron-supervisor.js.map +0 -1
  1245. package/dist/webapp/static-server.d.ts +0 -24
  1246. package/dist/webapp/static-server.d.ts.map +0 -1
  1247. package/dist/webapp/static-server.js +0 -152
  1248. package/dist/webapp/static-server.js.map +0 -1
  1249. package/webapp/dist/assets/Badge-D2ZBP5iK.js +0 -1
  1250. package/webapp/dist/assets/Card-vO88g06B.js +0 -1
  1251. package/webapp/dist/assets/CardActions-C9h7Q2wW.js +0 -1
  1252. package/webapp/dist/assets/CardContent-BA_Xmmhk.js +0 -1
  1253. package/webapp/dist/assets/CardHeader-ceIs__6x.js +0 -1
  1254. package/webapp/dist/assets/Chip-Ckk0xUhK.js +0 -1
  1255. package/webapp/dist/assets/DialogActions-BhHM7hov.js +0 -1
  1256. package/webapp/dist/assets/DialogContentText-BT2JQRmK.js +0 -1
  1257. package/webapp/dist/assets/FormControlLabel-CcQByVS8.js +0 -1
  1258. package/webapp/dist/assets/Grid-14PETRQW.js +0 -1
  1259. package/webapp/dist/assets/InputAdornment-B8aHIsQ7.js +0 -1
  1260. package/webapp/dist/assets/Skeleton-B74-Ds6O.js +0 -37
  1261. package/webapp/dist/assets/Switch-DvWD2wh4.js +0 -1
  1262. package/webapp/dist/assets/SwitchBase-CDU4nY8O.js +0 -1
  1263. package/webapp/dist/assets/Tab-DcCOYYp5.js +0 -1
  1264. package/webapp/dist/assets/TableContainer-CzTh4mu-.js +0 -1
  1265. package/webapp/dist/assets/TableRow-BySEreZR.js +0 -2
  1266. package/webapp/dist/assets/TextField-BLf-BHz2.js +0 -1
  1267. package/webapp/dist/assets/alerts-DnRbT41C.js +0 -1
  1268. package/webapp/dist/assets/apps-CdR0VH2d.js +0 -1
  1269. package/webapp/dist/assets/chunk-DtcAfS_W.js +0 -1
  1270. package/webapp/dist/assets/client-DU8I_lDc.js +0 -2
  1271. package/webapp/dist/assets/constants-BgnT-Xdy.js +0 -1
  1272. package/webapp/dist/assets/containers-Dw-rRXV1.js +0 -1
  1273. package/webapp/dist/assets/dashboard-CzgB0uZz.js +0 -1
  1274. package/webapp/dist/assets/dashboard-builder-DsA0yg33.js +0 -1
  1275. package/webapp/dist/assets/deployments-DtQAoIcQ.js +0 -1
  1276. package/webapp/dist/assets/detail-BJR-G7JG.js +0 -1
  1277. package/webapp/dist/assets/detail-nlCeSltl.js +0 -8
  1278. package/webapp/dist/assets/formatters-Do41SRmE.js +0 -1
  1279. package/webapp/dist/assets/index-ByQDwp2r.js +0 -566
  1280. package/webapp/dist/assets/index-CHVXOsTS.css +0 -1
  1281. package/webapp/dist/assets/isMuiElement-DwF2naaF.js +0 -1
  1282. package/webapp/dist/assets/logs-Kn8o4H7k.js +0 -11
  1283. package/webapp/dist/assets/metrics-DN8Zu5wT.js +0 -2
  1284. package/webapp/dist/assets/nodes-Bgch7AtN.js +0 -7
  1285. package/webapp/dist/assets/pipelines-Cry3qm-S.js +0 -4
  1286. package/webapp/dist/assets/popper-BJFSj6rc.js +0 -1
  1287. package/webapp/dist/assets/preload-helper-DSXbuxSR.js +0 -1
  1288. package/webapp/dist/assets/projects-Dym2GI1U.js +0 -1
  1289. package/webapp/dist/assets/react-apexcharts.esm-CSGK5Cxn.js +0 -958
  1290. package/webapp/dist/assets/react-apexcharts.esm-DEAWOwx5.js +0 -1003
  1291. package/webapp/dist/assets/settings-va3zTcow.js +0 -1
  1292. package/webapp/dist/assets/sign-in-ik35umbp.js +0 -1
  1293. package/webapp/dist/assets/stacks-CVFOnxbF.js +0 -1
  1294. package/webapp/dist/assets/system-info-npIXAjX2.js +0 -1
  1295. package/webapp/dist/assets/topology-CHpVij2M.css +0 -1
  1296. package/webapp/dist/assets/topology-bDe70vLs.js +0 -12
  1297. package/webapp/dist/assets/traces-DIIcT8DF.js +0 -1
  1298. package/webapp/dist/assets/use-stack-context-DHLwZv1r.js +0 -1
  1299. /package/webapp/dist/assets/{fs-CgZLtHiQ.js → fs-Ccl3sAcp.js} +0 -0
@@ -14,42 +14,181 @@
14
14
  */
15
15
  import fs from 'node:fs';
16
16
  import path from 'node:path';
17
+ import { LOGGER_SERVICE_TOKEN } from '@omnitron-dev/titan/module/logger';
18
+ import { AUDIT_SERVICE_TOKEN, DAEMON_STATE_STORE_TOKEN, ORCHESTRATOR_TOKEN, SECRETS_SERVICE_TOKEN } from '../shared/tokens.js';
19
+ import { gatewayStaticLinkName } from './static-carry-forward.js';
17
20
  import { EventEmitter } from '@omnitron-dev/eventemitter';
18
21
  import { ProjectRegistry } from '../project/registry.js';
22
+ import { describeWorkingTrees, refusalForDirtyTree } from '../project/working-tree.js';
23
+ import { reportPhases } from '../project/deploy-phases.js';
24
+ import { effectiveAppName } from '../orchestrator/orchestrator.service.js';
25
+ import { summariseSync } from './sync-summary.js';
19
26
  import { StackInfrastructureManager } from '../infrastructure/stack-infra-manager.js';
27
+ import { describeProvisioning, summariseProvisioning } from '../infrastructure/provisioning-outcome.js';
28
+ import { waitForPostgres } from './wait-for-postgres.js';
20
29
  import { resolveStack, resolvedConfigToEnv } from '../project/config-resolver.js';
30
+ import { resolveStartupOrder } from '../orchestrator/dependency-resolver.js';
21
31
  import { SlaveConnector } from '../cluster/slave-connector.js';
22
- import { RemoteDeployer } from './remote-deployer.service.js';
32
+ import { staleBuild } from './bundle-builder.js';
33
+ import { NODE_STACK } from '../project/node-app-config.js';
34
+ import { overlayCredentials } from '../infrastructure/node-credentials.js';
35
+ import { readDeclaredConfig, withDeclaredConfig } from '../project/declared-config.js';
36
+ import { bindService } from '../infrastructure/service-binding.js';
37
+ import { RemoteDeployer, stackNodeToDeployTarget, withNodeCredentials, } from './remote-deployer.service.js';
38
+ import { withNodeLeases } from './node-deploy-lease.js';
39
+ import { DEFAULT_MAINTENANCE_TIMING, MaintenanceLock } from './maintenance-lock.js';
40
+ import { gatewayRedisDb } from '../infrastructure/gateway-redis-db.js';
41
+ /**
42
+ * What a deployment asks through the gateway before it lets the platform go:
43
+ * the portal's page and main's health. Each app already passed its own health
44
+ * check inside the deployment; these prove the path a person's request takes.
45
+ */
46
+ const MAINTENANCE_PROBE_PATHS = ['/', '/api/main/health'];
47
+ import { migrationOutput } from './migration-output.js';
48
+ import { ExecutionService } from '../execution/execution.service.js';
49
+ /** Whether two paths name one directory — symlinks and `..` resolved. */
50
+ function samePath(a, b) {
51
+ const real = (p) => {
52
+ try {
53
+ return fs.realpathSync(path.resolve(p));
54
+ }
55
+ catch {
56
+ return path.resolve(p);
57
+ }
58
+ };
59
+ return real(a) === real(b);
60
+ }
61
+ /**
62
+ * The infrastructure a stack actually gets: the ecosystem's, plus its own.
63
+ *
64
+ * Shallow for the top-level keys — a stack that declares `postgres` replaces
65
+ * the ecosystem's — and merged for `services`, because those are named
66
+ * things and a stack adding one must not remove the rest.
67
+ */
68
+ export function mergeInfrastructure(ecosystemConfig, stackConfig) {
69
+ if (!stackConfig.infrastructure && !ecosystemConfig.infrastructure)
70
+ return undefined;
71
+ return {
72
+ ...ecosystemConfig.infrastructure,
73
+ ...stackConfig.infrastructure,
74
+ services: {
75
+ ...ecosystemConfig.infrastructure?.services,
76
+ ...stackConfig.infrastructure?.services,
77
+ },
78
+ };
79
+ }
23
80
  // =============================================================================
24
81
  // ProjectService
25
82
  // =============================================================================
83
+ /**
84
+ * The stack's frontend could not be put on a node, twice.
85
+ *
86
+ * Its own class because it ends the deployment where every other failure to
87
+ * bring up a node's infrastructure is logged and deployed past: nothing on
88
+ * the node has changed yet, and a deployment that continued would recreate
89
+ * the gateway without a web root — see `shipStackStatics`.
90
+ */
91
+ export class FrontendNotDeliveredError extends Error {
92
+ constructor(host, cause) {
93
+ super(`The frontend could not be delivered to ${host} (two attempts): ${cause.slice(0, 400)}. ` +
94
+ 'Nothing on the node was changed — its gateway keeps serving the frontend it had.');
95
+ this.name = 'FrontendNotDeliveredError';
96
+ }
97
+ }
98
+ /** The services whose credentials a node generates, and a deployment reads back from it. */
99
+ const CREDENTIALED_SERVICES = ['postgres', 'redis', 'minio'];
26
100
  export class ProjectService extends EventEmitter {
27
101
  logger;
28
102
  orchestrator;
29
- fleetService;
30
- syncService;
31
103
  registry;
32
104
  configRegistry = new Map();
33
105
  stackStates = new Map();
34
106
  infraManager;
35
107
  slaveConnector = null;
36
108
  deployer;
37
- constructor(logger, orchestrator, fleetService, syncService) {
109
+ // Enabled-stacks reconciler state (see startEnabledStacksReconciler)
110
+ resumeTimer = null;
111
+ resumeBackoff = new Map();
112
+ dockerWasAvailable = null;
113
+ syncService;
114
+ secrets;
115
+ audit;
116
+ constructor(logger, orchestrator, daemonStateStore,
117
+ /**
118
+ * Optional local collaborators — by NAME. Both daemon roles have a vault. These were four more
119
+ * positional parameters, and the daemon's factory passed them through an
120
+ * `any`: `new ProjectService(logger, orchestrator, dStore, fleet,
121
+ * undefined, secrets, audit)`. A slot removed or reordered there moves
122
+ * every later argument into its neighbour's place and still compiles. The
123
+ * fourth had been read by nothing since the connector stopped writing the
124
+ * fleet table (f785d7f0); it went, and the rest became names.
125
+ */
126
+ options = {}) {
38
127
  super();
39
128
  this.logger = logger;
40
129
  this.orchestrator = orchestrator;
41
- this.fleetService = fleetService;
42
- this.syncService = syncService;
43
- this.registry = new ProjectRegistry();
130
+ this.syncService = options.syncService;
131
+ this.secrets = options.secrets;
132
+ this.audit = options.audit;
133
+ // T-7 — registry persistence routed through DaemonStateStore
134
+ // (SQLite, transactional, co-located with daemon-state-kv,
135
+ // pid-lock, etc.). The registry itself is unchanged in API;
136
+ // only its backing storage moved.
137
+ this.registry = new ProjectRegistry(daemonStateStore);
44
138
  this.infraManager = new StackInfrastructureManager(logger);
45
- this.deployer = new RemoteDeployer(logger);
139
+ // The deployer reaches nodes through the same SSH implementation as the
140
+ // health checks, which is the only one that can present a password or a
141
+ // key passphrase — see the note at the top of `remote-deployer.service.ts`.
142
+ this.deployer = new RemoteDeployer(logger, new ExecutionService(logger));
143
+ }
144
+ /**
145
+ * Adopt the daemon's mesh connector.
146
+ *
147
+ * There must be ONE of these per master. It holds a live connection — and,
148
+ * for a node whose daemon port is firewalled, an SSH tunnel — per node, so
149
+ * a second instance means a second connection to every node and two
150
+ * independent pulls of the same buffer, racing over which one acks an
151
+ * entry the other has not ingested yet.
152
+ *
153
+ * The daemon builds it, because the daemon is what knows how to reach a
154
+ * node: the SSH credentials live in the node registry's vault, not here.
155
+ * Absent one, the lazy fallback below keeps the behaviour this class had
156
+ * on its own, which is what the tests construct.
157
+ */
158
+ setSlaveConnector(connector) {
159
+ this.slaveConnector = connector;
160
+ }
161
+ /**
162
+ * How to reach a host the node registry knows about.
163
+ *
164
+ * A stack says which host; the registry says how to reach it — the user,
165
+ * the port, and the credential, which is in the daemon's vault because a
166
+ * config file in a repository is not where those go. Wired by the daemon,
167
+ * which is the side that has both.
168
+ */
169
+ setNodeCredentialResolver(resolve) {
170
+ this.resolveNodeCredentials = resolve;
171
+ }
172
+ resolveNodeCredentials = null;
173
+ /** A deploy target for a stack node, with the registry filling the gaps. */
174
+ async targetForStackNode(node) {
175
+ const declared = stackNodeToDeployTarget(node);
176
+ if (!this.resolveNodeCredentials)
177
+ return declared;
178
+ const registered = await this.resolveNodeCredentials(node.host).catch(() => null);
179
+ return withNodeCredentials(declared, registered);
46
180
  }
47
181
  /**
48
- * Get or create the SlaveConnector (lazy — only created when first remote/cluster stack starts).
182
+ * Get or create the SlaveConnector.
183
+ *
184
+ * Created lazily here only when the daemon has not handed one over —
185
+ * historically this was the sole owner, and it created one when the first
186
+ * remote or cluster stack started. That is also why a node with no stack
187
+ * on it was never connected to at all: nothing else ever asked.
49
188
  */
50
189
  getSlaveConnector() {
51
190
  if (!this.slaveConnector) {
52
- this.slaveConnector = new SlaveConnector(this.logger, this.fleetService, this.syncService ?? null);
191
+ this.slaveConnector = new SlaveConnector(this.logger, this.syncService ?? null);
53
192
  }
54
193
  return this.slaveConnector;
55
194
  }
@@ -64,6 +203,19 @@ export class ProjectService extends EventEmitter {
64
203
  const detected = this.registry.autoDetect(cwd);
65
204
  return detected ? this.toProjectInfo(detected) : null;
66
205
  }
206
+ /**
207
+ * What each app on each node is doing, for a console that polls.
208
+ *
209
+ * A stack deployment publishes progress as it goes — transferring,
210
+ * installing, the message naming the step that failed — and the console
211
+ * polls. Without somewhere for an event to wait, those two never met: a
212
+ * deployment that takes a quarter of an hour showed one row reading
213
+ * `deploying` from the first second to the last, and every event that said
214
+ * WHERE it was reached a handler that re-emitted it to nobody.
215
+ */
216
+ getDeployProgress() {
217
+ return this.deployer.getProgress();
218
+ }
67
219
  listProjects() {
68
220
  return this.registry.list().map((p) => this.toProjectInfo(p));
69
221
  }
@@ -73,30 +225,99 @@ export class ProjectService extends EventEmitter {
73
225
  throw new Error(`Project '${name}' not found`);
74
226
  return this.toProjectInfo(project);
75
227
  }
76
- addProject(name, projectPath) {
228
+ async addProject(name, projectPath) {
229
+ // Already registered AT THIS PATH: read it again rather than refuse.
230
+ //
231
+ // A deployment registers a node's project after writing its config, on
232
+ // every deployment. The node answered «Project 'sample' already registered»
233
+ // to every one after the first (four in two days, measured), the
234
+ // deployer's SSH step failed on the exit code, and the node never read
235
+ // the config it had just been given — a redeployment that changed an
236
+ // app's environment restarted the app on the old one (payments on
237
+ // sample/test, pointed at the chain's previous address, 2026-09-23). A
238
+ // different path under the same name is still refused: that is another
239
+ // project, not the same one again.
240
+ const existing = this.registry.get(name);
241
+ if (existing && samePath(existing.path, projectPath)) {
242
+ const redefined = await this.rereadProject(name);
243
+ return { ...this.toProjectInfo(existing), reread: { redefined } };
244
+ }
77
245
  const project = this.registry.add(name, projectPath);
78
246
  this.logger.info({ project: name, path: projectPath }, 'Project registered');
79
247
  this.emit('project:added', name, projectPath);
80
248
  return this.toProjectInfo(project);
81
249
  }
250
+ /**
251
+ * Read a registered project's config again, and give every app its running
252
+ * local stacks supervise the definition that config now describes.
253
+ *
254
+ * The processes are not restarted here: each takes its new definition on
255
+ * its next start (`AppHandle.redefine`) — which a deployment asks for per
256
+ * app, only where something changed (`decideRedeploy`). Returns how many
257
+ * running apps were redefined.
258
+ */
259
+ async rereadProject(name) {
260
+ this.configRegistry.delete(name);
261
+ const config = await this.loadProjectConfig(name);
262
+ const stacks = this.resolveStacks(config, name);
263
+ let redefined = 0;
264
+ for (const [key, state] of this.stackStates) {
265
+ if (!key.startsWith(`${name}/`) || state.status === 'stopped' || state.status === 'error')
266
+ continue;
267
+ const stackConfig = stacks[state.stack];
268
+ // A remote stack's apps run on its nodes; they are redefined there.
269
+ if (!stackConfig || stackConfig.type !== 'local')
270
+ continue;
271
+ const definitions = await this.loadAppDefinitions(name, stackConfig, config);
272
+ const entries = this.resolveStackApps(stackConfig, config);
273
+ const stackEnv = await this.stackEnvFor(name, state.stack, stackConfig, entries.map((e) => e.name));
274
+ const build = this.stackEntryBuilder(name, state.stack, stackConfig, config, definitions, stackEnv);
275
+ for (const entry of entries) {
276
+ const built = build(entry);
277
+ if (await this.orchestrator.redefineApp(built.name, built))
278
+ redefined += 1;
279
+ }
280
+ }
281
+ this.logger.info({ project: name, redefined }, 'Project read again — running apps take the new definition on their next start');
282
+ this.emit('project:updated', name, {});
283
+ return redefined;
284
+ }
285
+ /** Refresh only local container monitoring from the current validated project config. */
286
+ async refreshInfrastructureOwnership(projectName, stackName) {
287
+ const project = this.registry.get(projectName);
288
+ if (!project)
289
+ throw new Error(`Project '${projectName}' not found`);
290
+ this.configRegistry.delete(projectName);
291
+ const config = await this.loadProjectConfig(projectName);
292
+ const stack = this.resolveStacks(config, projectName)[stackName];
293
+ if (!stack || stack.type !== 'local')
294
+ throw new Error('Ownership refresh requires a declared local stack');
295
+ const definitions = await this.loadAppDefinitions(projectName, stack, config);
296
+ // The same requirements merge used at stack start. A failed fresh scan must
297
+ // refuse a handoff rather than silently releasing auto-detected services.
298
+ const { scanRequirements, buildInfraFromRequirements } = await import('../project/requirements-scanner.js');
299
+ const auto = buildInfraFromRequirements(await scanRequirements(this.resolveStackApps(stack, config), project.path));
300
+ const explicit = mergeInfrastructure(config, stack);
301
+ const effective = { ...auto, ...explicit, services: { ...explicit?.services } };
302
+ return this.infraManager.refreshOwnership(projectName, stackName, stack, effective, definitions, project.path);
303
+ }
82
304
  updateProject(name, data) {
83
305
  const project = this.registry.get(name);
84
306
  if (!project)
85
307
  throw new Error(`Project '${name}' not found`);
86
308
  if (data.path) {
87
- // Invalidate cached config so next access reloads from new path
88
- this.configRegistry.delete(name);
89
- const updated = this.registry.updatePath(name, data.path);
90
- // Registry may have renamed the project (basename of new path)
91
- if (updated.name !== name) {
92
- this.configRegistry.delete(updated.name);
93
- this.logger.info({ oldName: name, newName: updated.name, path: data.path }, 'Project renamed and path updated');
94
- this.emit('project:renamed', name, updated.name);
95
- }
96
- else {
97
- this.logger.info({ project: name, path: data.path }, 'Project path updated');
309
+ const nextPath = path.resolve(data.path);
310
+ if (nextPath !== path.resolve(project.path)) {
311
+ const running = this.getRunningStacks(name);
312
+ if (running.length > 0) {
313
+ throw new Error(`Project '${name}' has running stacks: ${running.join(', ')}. Stop them before changing its path.`);
314
+ }
98
315
  }
99
- this.emit('project:updated', updated.name, data);
316
+ // Validate and persist first. A rejected path must not invalidate a live config.
317
+ const updated = this.registry.updatePath(name, nextPath);
318
+ this.configRegistry.delete(name);
319
+ this.logger.info({ project: name, path: nextPath }, 'Project path updated');
320
+ this.emit('project:updated', name, data);
100
321
  return this.toProjectInfo(updated);
101
322
  }
102
323
  return this.toProjectInfo(project);
@@ -208,14 +429,25 @@ export class ProjectService extends EventEmitter {
208
429
  const { isDockerAvailable, ensureImage } = await import('../infrastructure/container-runtime.js');
209
430
  const dockerReady = await isDockerAvailable();
210
431
  if (dockerReady) {
211
- // Pull images in background (don't block stack creation)
432
+ // Pull images in background (don't block stack creation).
433
+ //
434
+ // Asked of the presets, not spelled again here. These three were
435
+ // written out a second time, and the copies had already drifted:
436
+ // this one named `minio/minio:latest`, which Docker Hub answers
437
+ // `pull access denied` for, while the preset named the image the
438
+ // resolver actually uses. A prefetch that pulls something else is
439
+ // worse than none — it reports success and warms nothing.
440
+ const { createDefaultRegistry } = await import('../infrastructure/presets/index.js');
441
+ const presets = createDefaultRegistry();
212
442
  const images = new Set();
213
- if (stackConfig.infrastructure.postgres)
214
- images.add('postgres:17-alpine');
215
- if (stackConfig.infrastructure.redis)
216
- images.add('redis:7-alpine');
217
- if (stackConfig.infrastructure.minio)
218
- images.add('minio/minio:latest');
443
+ for (const name of ['postgres', 'redis', 'minio']) {
444
+ if (!stackConfig.infrastructure[name])
445
+ continue;
446
+ const declared = stackConfig.infrastructure[name].image;
447
+ const image = declared ?? presets.get(name)?.defaultImage;
448
+ if (image)
449
+ images.add(image);
450
+ }
219
451
  for (const img of images) {
220
452
  ensureImage(img).catch((err) => this.logger.warn({ image: img, error: err.message }, 'Failed to pull image'));
221
453
  }
@@ -304,17 +536,221 @@ export class ProjectService extends EventEmitter {
304
536
  throw new Error(`Stack '${stackName}' not found in project '${projectName}'`);
305
537
  return this.toStackInfo(projectName, stackName, stackConfig);
306
538
  }
307
- async startStack(projectName, stackName) {
539
+ /**
540
+ * Stack starts in flight, by `project/stack`.
541
+ *
542
+ * `stackStates` records `starting`, and it is written AFTER the config is
543
+ * loaded and the short-circuits above have run — so two callers that arrive
544
+ * together both see no state and both proceed. Measured on the test stack:
545
+ * the daemon's own `startProjectStacks` on boot and an operator's
546
+ * `omnitron stack start sample test` landed within twenty seconds of each
547
+ * other, and ran the whole remote deployment TWICE, concurrently.
548
+ *
549
+ * That is not merely wasteful. The artifact build rebuilds shared packages
550
+ * whose `build` script is `rm -rf dist && tsc`, so one pass emptied
551
+ * `@omnitron-dev/titan-database/dist` while the other compiled `@sample/main`
552
+ * against it:
553
+ *
554
+ * src/modules/rbac/rls-schema.ts(53,41): error TS2307: Cannot find
555
+ * module '@omnitron-dev/titan-database/rls'
556
+ *
557
+ * — an error about a subpath that exists, reported against a build that is
558
+ * correct, which sends the reader to the wrong package entirely. Building
559
+ * the same app by hand a minute later exits zero.
560
+ *
561
+ * A second caller gets the FIRST call's promise rather than a refusal:
562
+ * asking for a stack that is already being started should end when it has
563
+ * been started, which is what the caller meant.
564
+ */
565
+ startsInFlight = new Map();
566
+ async startStack(projectName, stackName, opts) {
567
+ const inFlightKey = `${projectName}/${stackName}`;
568
+ const running = this.startsInFlight.get(inFlightKey);
569
+ if (running) {
570
+ this.logger.info({ project: projectName, stack: stackName }, 'This stack is already being started — joining the run in progress');
571
+ return running;
572
+ }
573
+ const started = this.startStackOnce(projectName, stackName, opts?.source ?? 'unknown', opts?.allowDirty === true, opts?.release, opts?.reinstall === true).finally(() => {
574
+ this.startsInFlight.delete(inFlightKey);
575
+ });
576
+ this.startsInFlight.set(inFlightKey, started);
577
+ return started;
578
+ }
579
+ /**
580
+ * One start of a stack, recorded however it ends.
581
+ *
582
+ * The row was written on the success path only — inside the `try` at the
583
+ * end of `runStackStart` — so a start that was refused or failed left
584
+ * nothing in the trail. Measured on the master 2026-09-23: 178 rows since
585
+ * the trail began (2026-09-20 15:37Z), not one of them a failure, against
586
+ * the log's eight operator starts that threw — five dirty-tree refusals
587
+ * (09-22 04:37:24 … 08:27:49), a release refused for a failed gate
588
+ * (15:12:41), «sample/test takes releases only» (15:32:43), and the
589
+ * deployment of 21:19:02 that failed on 6 of 6 apps, followed at 21:20:37
590
+ * by the redeploy the trail DID record, alone, as if it had been the only
591
+ * attempt.
592
+ *
593
+ * Written as `stack.start.failed`, not as `stack.start` with a flag. Two
594
+ * readers take every `stack.start` row as a deployment that happened —
595
+ * `ReleaseRpcService.deployments` (the console's «last deployed») and
596
+ * `lastDeployedAt` (the attestation freshness refusal) — and a refused
597
+ * deployment is not one; filed under the same action it would become the
598
+ * stack's «last deployment». Same shape as `node.upgrade.failed`.
599
+ *
600
+ * A remote stack that a restart re-attaches is left out both ways: it
601
+ * deploys nothing, and the reconciler re-asks a node that does not answer
602
+ * every few minutes — rows about looking, not about changing anything.
603
+ */
604
+ async startStackOnce(projectName, stackName, source, allowDirty, releaseId, reinstall = false) {
605
+ const known = { attach: false };
606
+ try {
607
+ return await this.runStackStart(projectName, stackName, source, allowDirty, releaseId, known, reinstall);
608
+ }
609
+ catch (err) {
610
+ if (!known.attach) {
611
+ await this.audit?.record({
612
+ action: 'stack.start.failed',
613
+ resourceType: 'stack',
614
+ resourceId: `${projectName}/${stackName}`,
615
+ outcome: 'failed',
616
+ error: err,
617
+ details: {
618
+ source,
619
+ ...(known.type ? { type: known.type } : {}),
620
+ ...(releaseId ? { release: releaseId } : {}),
621
+ ...(known.commit !== undefined ? { commit: known.commit } : {}),
622
+ },
623
+ });
624
+ }
625
+ throw err;
626
+ }
627
+ }
628
+ async runStackStart(projectName, stackName, source, allowDirty, releaseId, known, reinstall = false) {
308
629
  const config = await this.loadProjectConfig(projectName);
309
630
  const stacks = this.resolveStacks(config, projectName);
310
631
  const stackConfig = stacks[stackName];
311
632
  if (!stackConfig)
312
633
  throw new Error(`Stack '${stackName}' not found in project '${projectName}'`);
634
+ known.type = stackConfig.type;
635
+ // A restart of this daemon re-attaches a remote stack; it never deploys
636
+ // one. See `attachRemoteStack`.
637
+ if (stackConfig.type !== 'local' && (source === 'boot' || source === 'auto-resume')) {
638
+ known.attach = true;
639
+ return this.attachRemoteStack(projectName, stackName, stackConfig, source);
640
+ }
641
+ // A release, when one is named — and a stack that takes nothing else.
642
+ let release = null;
643
+ if (releaseId) {
644
+ release = await this.admitRelease(projectName, stackName, stackConfig, config, releaseId);
645
+ }
646
+ else if (stackConfig.release?.mode === 'required') {
647
+ throw new Error(`${projectName}/${stackName} takes releases only: \`omnitron release build ${projectName} --for ${stackName}\`, ` +
648
+ `then \`omnitron stack start ${projectName} ${stackName} --release <id>\`.`);
649
+ }
650
+ // A remote deployment ships what is on DISK, so before anything is built
651
+ // the disk has to be a commit.
652
+ //
653
+ // Measured over one night with three sessions in one checkout: an
654
+ // automatic resume compiled somebody's half-finished edit, `main` failed
655
+ // to build, and five artifacts of six went out under `Stack started`.
656
+ // The hazard is not the operator typing the command — it is that every
657
+ // master restart runs this path, and a restart happens for reasons
658
+ // nobody chose: a crash, a laptop asleep, launchd. Four of them did so
659
+ // that day.
660
+ //
661
+ // Local stacks are exempt on purpose. Compiling the working tree is what
662
+ // a development stand is FOR — hot reload is that feature — and a rule
663
+ // that refused there would be turned off within the hour.
664
+ const projectForTree = this.registry.get(projectName);
665
+ // Not only the project's own repository. The static bundle INLINES the
666
+ // sources of the packages it links — the portal's vite alias resolves
667
+ // prism and netron-browser to their `src` in another checkout — so an
668
+ // uncommitted change there rides to the node inside `dist` while this
669
+ // tree is spotless. The backends differ: their vendored
670
+ // `@omnitron-dev/*` are replaced on the node by symlinks to the node
671
+ // daemon's copy, so what was packed for them never runs.
672
+ //
673
+ // The list is derived from the mechanism, not written down:
674
+ // `linkedSourceDirs` is the same function the staleness check uses to
675
+ // decide what the bundle is built from.
676
+ const treeInputs = [];
677
+ if (projectForTree) {
678
+ treeInputs.push(projectForTree.path);
679
+ const staticAbs = this.staticDirOf(stackConfig.infrastructure, projectForTree.path);
680
+ if (staticAbs) {
681
+ const { linkedSourceDirs } = await import('./bundle-builder.js');
682
+ const pkgDir = staticAbs.replace(/\/[^/]+\/?$/, '');
683
+ treeInputs.push(...linkedSourceDirs(pkgDir));
684
+ }
685
+ }
686
+ const trees = projectForTree
687
+ ? await describeWorkingTrees(treeInputs)
688
+ : [
689
+ {
690
+ root: projectName,
691
+ tree: { checked: false, why: `project '${projectName}' is not in the registry`, dirty: [] },
692
+ },
693
+ ];
694
+ const tree = trees[0]?.tree ?? { checked: false, why: 'no tree to read', dirty: [] };
695
+ known.commit = tree.checked ? (tree.head ?? null) : null;
696
+ // With a release the disk has already been held to the release's commit
697
+ // (`admitRelease`) — stricter than HEAD, and the artifacts are not built
698
+ // from it at all.
699
+ if (stackConfig.type === 'remote' && !release) {
700
+ if (!allowDirty) {
701
+ for (const { root, tree: t } of trees) {
702
+ const refusal = refusalForDirtyTree(t, `${projectName}/${stackName}`);
703
+ if (refusal)
704
+ throw new Error(trees.length > 1 ? `${refusal}\n(in ${root})` : refusal);
705
+ }
706
+ }
707
+ else if (tree.checked && tree.dirty.length > 0) {
708
+ this.logger.warn({ project: projectName, stack: stackName, dirty: tree.dirty.length, head: tree.head }, 'Deploying a working tree that is not its commit — asked for with --allow-dirty');
709
+ }
710
+ if (!tree.checked) {
711
+ // Not a pass. The deployment goes on, because refusing every project
712
+ // that is not a git checkout would be a rule about git rather than
713
+ // about deployments — but the log says the check did not run.
714
+ this.logger.warn({ project: projectName, stack: stackName, why: tree.why }, 'Could not tell whether the working tree matches its commit — shipping what is on disk');
715
+ }
716
+ }
313
717
  const stateKey = `${projectName}/${stackName}`;
314
718
  const existing = this.stackStates.get(stateKey);
315
- if (existing?.status === 'running') {
316
- this.logger.warn({ project: projectName, stack: stackName }, 'Stack already running');
317
- return this.toStackInfo(projectName, stackName, stackConfig);
719
+ // Not for a release: putting a release onto a running stack is the point.
720
+ if (existing?.status === 'running' && !release) {
721
+ // `running` is a claim about the past, and nothing revises it when the
722
+ // apps underneath fall over — so this short-circuit used to hand the
723
+ // operator a stack it had not touched. `toStackInfo` reads live
724
+ // orchestrator statuses, which means the very response we returned
725
+ // already said how many apps were up; nothing compared the two halves.
726
+ // Observed on the dev stand with all six apps dead: `stack start`
727
+ // printed "Stack acme/dev started — 0/6 apps online", exited 0, and
728
+ // started nothing. Ask what is actually online before believing it.
729
+ //
730
+ // And ask the machine that runs them. `toStackInfo` reads THIS
731
+ // daemon's orchestrator, which lists none of a remote stack's
732
+ // applications because they run on the node — so `down` was all six,
733
+ // every time, and this short-circuit could never fire for a remote
734
+ // stack. Measured 2026-09-22: six occurrences, every one of them
735
+ // `down: [main, storage, quotes, payments, messaging, geo]` against
736
+ // a node where all six were online with pids and uptimes to show for
737
+ // it. Each fell through to a full deployment of a stack that needed
738
+ // nothing — which is most of the «four master restarts, four full
739
+ // redeploys, twenty-four application restarts» that `decideRedeploy`
740
+ // was written to stop. Same reader, same mistake, third caller: see
741
+ // `getStackStatus` and `withRemoteAppStatuses`, which is what this
742
+ // now goes through. For a local stack it changes nothing — the
743
+ // wrapper returns the info untouched.
744
+ const current = await this.withRemoteAppStatuses(projectName, this.toStackInfo(projectName, stackName, stackConfig));
745
+ const down = current.apps.filter((a) => a.status !== 'online');
746
+ if (down.length === 0) {
747
+ this.logger.warn({ project: projectName, stack: stackName }, 'Stack already running');
748
+ return current;
749
+ }
750
+ this.logger.warn({ project: projectName, stack: stackName, down: down.map((a) => a.name) }, 'Stack is marked running but some of its apps are not — starting them');
751
+ // Fall through. Starting is idempotent for the apps still up:
752
+ // `startAppInternal` returns the existing handle for an online app.
753
+ this.stackStates.delete(stateKey);
318
754
  }
319
755
  // Reset stale "starting" state (daemon may have restarted mid-launch)
320
756
  if (existing?.status === 'starting') {
@@ -332,12 +768,13 @@ export class ProjectService extends EventEmitter {
332
768
  };
333
769
  this.stackStates.set(stateKey, state);
334
770
  this.emit('stack:starting', projectName, stackName, stackConfig.type);
771
+ let reach = null;
335
772
  try {
336
773
  if (stackConfig.type === 'local') {
337
774
  await this.startLocalStack(projectName, stackName, stackConfig, config);
338
775
  }
339
776
  else if (stackConfig.type === 'remote') {
340
- await this.startRemoteStack(projectName, stackName, stackConfig, config);
777
+ reach = await this.startRemoteStack(projectName, stackName, stackConfig, config, release, reinstall);
341
778
  }
342
779
  else if (stackConfig.type === 'cluster') {
343
780
  await this.startClusterStack(projectName, stackName, stackConfig, config);
@@ -347,8 +784,76 @@ export class ProjectService extends EventEmitter {
347
784
  // Update registry with enabled stack
348
785
  this.updateEnabledStacks(projectName, stackName, true);
349
786
  this.emit('stack:started', projectName, stackName, stackConfig.type);
350
- this.logger.info({ project: projectName, stack: stackName }, 'Stack started');
351
- return this.toStackInfo(projectName, stackName, stackConfig);
787
+ this.logger.info({ project: projectName, stack: stackName, source }, 'Stack started');
788
+ const info = this.toStackInfo(projectName, stackName, stackConfig);
789
+ // What did not come up, said in the row. A boot at 12:14 UTC on
790
+ // 2026-09-23 started sample/dev with payments and messaging errored and
791
+ // recorded `outcome: ok, apps: 6`: the stack «started», two of its six
792
+ // apps never did. For a local stack the apps are this daemon's; for a
793
+ // remote one, what did not happen is a node that was skipped.
794
+ const notUp = stackConfig.type === 'local'
795
+ ? info.apps.filter((a) => a.status !== 'online').map((a) => `${a.name} (${a.status})`)
796
+ : [
797
+ ...(reach?.skipped ?? []).map((n) => `node ${typeof n === 'string' ? n : JSON.stringify(n)}`),
798
+ ...(reach?.notReady ?? []).map((n) => `infrastructure on ${n}`),
799
+ ];
800
+ // Recorded for every caller, and saying WHICH one. `source: 'unknown'`
801
+ // is deliberate rather than a default of `'operator'`: a caller that
802
+ // has not been taught to identify itself should be visible in the
803
+ // trail, not quietly attributed to a person.
804
+ //
805
+ // The online count the RPC layer used to attach is gone on purpose —
806
+ // it was measured AFTER this returned, by re-asking the node, so it
807
+ // described a later moment than the row it sat in.
808
+ await this.audit?.record({
809
+ action: 'stack.start',
810
+ resourceType: 'stack',
811
+ resourceId: `${projectName}/${stackName}`,
812
+ // Said, now that the other ending is recorded too (`startStackOnce`).
813
+ outcome: notUp.length > 0 ? 'partial' : 'ok',
814
+ // The commit, so the trail answers what went out and not only when.
815
+ // A node's artifacts carry no revision (every app is version `0.0.1`
816
+ // forever, and `BUNDLE.json` is excluded from the checksum and read
817
+ // by nobody), so this row is the only place the two can be joined.
818
+ details: {
819
+ type: stackConfig.type,
820
+ // How many the stack DECLARES. What a remote deployment reached is
821
+ // a different number and now sits beside it: a row saying `apps: 6`
822
+ // described a deployment whose only node could not be provisioned,
823
+ // and nothing in it said so.
824
+ apps: info.apps.length,
825
+ ...(notUp.length > 0 ? { notUp: notUp.join(', ') } : {}),
826
+ source,
827
+ ...(reach ? { nodes: reach.nodes, reached: reach.reached } : {}),
828
+ ...(reach && reach.skipped.length > 0 ? { skipped: reach.skipped } : {}),
829
+ ...(reach && (reach.notReady?.length ?? 0) > 0 ? { notReady: reach.notReady } : {}),
830
+ ...(tree.checked ? { commit: tree.head, dirty: tree.dirty.length } : { commit: null }),
831
+ // Every tree the artifacts were built from, not only the project's:
832
+ // the omni checkout's packages are vendored into them, and the
833
+ // project's commit alone could not say which omni went out.
834
+ // The release, when that is what went out: its id and the two
835
+ // commits its artifacts were built from in clean clones. Flat,
836
+ // because `scrubDetails` keeps facts and replaces a nested object
837
+ // with `[object]` — measured on the first release deployed, whose
838
+ // row said exactly that. Not the same as `trees` below, which
839
+ // describes THIS master's disk: the artifacts came from the
840
+ // release's omni, and the master's may have moved since.
841
+ ...(release
842
+ ? {
843
+ release: release.id,
844
+ releaseProjectCommit: release.manifest.project.commit.slice(0, 8),
845
+ releaseOmniCommit: release.manifest.omni.commit.slice(0, 8),
846
+ }
847
+ : {}),
848
+ trees: trees.map(({ root, tree: t }) => ({
849
+ repo: path.basename(root),
850
+ ...(t.checked ? { commit: t.head ?? null, dirty: t.dirty.length } : { commit: null, why: t.why ?? null }),
851
+ })),
852
+ },
853
+ });
854
+ // For a remote stack, what did not come up is not in its app list — a
855
+ // node skipped, infrastructure not up — so the answer says it too.
856
+ return stackConfig.type !== 'local' && notUp.length > 0 ? { ...info, notUp } : info;
352
857
  }
353
858
  catch (err) {
354
859
  state.status = 'error';
@@ -402,7 +907,24 @@ export class ProjectService extends EventEmitter {
402
907
  throw err;
403
908
  }
404
909
  }
405
- getStackStatus(projectName, stackName) {
910
+ /**
911
+ * What is running in this stack, asked of whoever is running it.
912
+ *
913
+ * For a remote stack that is the node. This counted `this.orchestrator
914
+ * .list()` — the MASTER's process table — which lists none of a remote
915
+ * stack's applications, so `stack runtime sample test` answered
916
+ * `totalApps: 0, onlineApps: 0, connectedNodes: 0` while `stack status`
917
+ * listed six apps online from the same node, three times in three seconds
918
+ * (measured 2026-09-22). The JSON surface is the one a script and a
919
+ * dashboard read.
920
+ *
921
+ * The sibling readers were taught this already — `listStacks`, `getStack`
922
+ * and `startStack` all pass through `withRemoteAppStatuses`, and the
923
+ * comment beside one of them names the same symptom: «only 0/6 apps came
924
+ * online» about six that were running. The fix reached three callers of
925
+ * four.
926
+ */
927
+ async getStackStatus(projectName, stackName) {
406
928
  const stateKey = `${projectName}/${stackName}`;
407
929
  const state = this.stackStates.get(stateKey);
408
930
  const config = this.getLoadedConfig(projectName);
@@ -411,148 +933,955 @@ export class ProjectService extends EventEmitter {
411
933
  if (!stackConfig)
412
934
  throw new Error(`Stack '${stackName}' not found`);
413
935
  const prefix = `${projectName}/${stackName}/`;
414
- const apps = this.orchestrator.list().filter((a) => a.name.startsWith(prefix));
936
+ // Match by namespaced prefix OR by bare-name when the bare entry's
937
+ // effective name is part of this stack's configured app set. See
938
+ // toStackInfo for the full rationale; we keep the same rule here so
939
+ // CLI `omnitron list` and the webapp's stack status agree.
940
+ const configForStatus = this.getLoadedConfig(projectName);
941
+ const configuredNames = configForStatus
942
+ ? new Set(this.resolveStackApps(stackConfig, configForStatus).map((a) => a.name))
943
+ : new Set();
944
+ const apps = this.orchestrator.list().filter((a) => {
945
+ if (a.name.startsWith(prefix))
946
+ return true;
947
+ if (a.name.includes('/'))
948
+ return false;
949
+ return configuredNames.has(effectiveAppName(a.name));
950
+ });
415
951
  const onlineApps = apps.filter((a) => a.status === 'online');
416
952
  // Derive status from app health if running
417
953
  let status = state?.status ?? 'stopped';
418
954
  if (status === 'running' && apps.length > 0 && onlineApps.length < apps.length) {
419
955
  status = 'degraded';
420
956
  }
421
- return {
957
+ const base = {
422
958
  name: stackName,
423
959
  type: stackConfig.type,
424
960
  status,
425
961
  totalApps: apps.length,
426
962
  onlineApps: onlineApps.length,
427
963
  totalNodes: stackConfig.nodes?.length ?? 1,
428
- connectedNodes: stackConfig.type === 'local'
429
- ? 1
430
- : (this.slaveConnector?.getConnections().filter((c) => c.stack === stackName && c.status === 'connected').length ?? 0),
964
+ connectedNodes: stackConfig.type === 'local' ? 1 : 0,
431
965
  totalCpu: apps.reduce((sum, a) => sum + a.cpu, 0),
432
966
  totalMemory: apps.reduce((sum, a) => sum + a.memory, 0),
433
967
  syncSummary: (stackConfig.type === 'remote' || stackConfig.type === 'cluster')
434
- ? {
435
- totalSlaves: stackConfig.nodes?.filter((n) => n.role !== 'master').length ?? 0,
436
- syncedSlaves: 0,
437
- totalPending: 0,
438
- }
968
+ ? summariseSync(this.nodesWithSync(stackConfig))
439
969
  : null,
440
970
  };
971
+ if (stackConfig.type === 'local')
972
+ return base;
973
+ // The node is asked, and what it answers replaces every count that was
974
+ // taken from this machine. `connectedNodes` is the number of nodes that
975
+ // ANSWERED rather than the number of entries in the connector's
976
+ // registry: that registry reported 0 connections for a node that was
977
+ // answering RPCs in the same second.
978
+ const { info, answered } = await this.askNodes(projectName, this.getStack(projectName, stackName));
979
+ const remote = info.apps;
980
+ const up = remote.filter((a) => a.status === 'online');
981
+ return {
982
+ ...base,
983
+ status: info.status,
984
+ totalApps: remote.length,
985
+ onlineApps: up.length,
986
+ connectedNodes: answered,
987
+ totalCpu: remote.reduce((sum, a) => sum + (a.cpu ?? 0), 0),
988
+ totalMemory: remote.reduce((sum, a) => sum + (a.memory ?? 0), 0),
989
+ };
441
990
  }
442
991
  /**
443
- * Get the infrastructure manager (for daemon-level access).
992
+ * Each configured node paired with what it last said about its own
993
+ * replication — the input the summary is counted from.
994
+ *
995
+ * Matching is by `host` and effective `port`, the same rule the node list
996
+ * below uses; a node with no connection contributes `null`, which the
997
+ * summary reads as «not heard from» rather than «nothing pending».
444
998
  */
445
- getInfraManager() {
446
- return this.infraManager;
999
+ nodesWithSync(stackConfig) {
1000
+ const connections = this.slaveConnector?.getConnections() ?? [];
1001
+ return (stackConfig.nodes ?? []).map((node) => {
1002
+ const port = node.port ?? 9700;
1003
+ const conn = connections.find((c) => c.host === node.host && c.port === port);
1004
+ return { role: node.role, syncStatus: conn?.syncStatus ?? null };
1005
+ });
1006
+ }
1007
+ /** Where releases are kept on this master — `~/.omnitron/releases`. */
1008
+ async releaseStore() {
1009
+ const { releasesRoot } = await import('../release/load.js');
1010
+ return releasesRoot();
447
1011
  }
448
1012
  /**
449
- * Dispose of all resources held by this service.
450
- * Called during daemon shutdown.
1013
+ * Take a release for a stack, or say why not.
1014
+ *
1015
+ * Three things are checked, each refusing by name. The stack's own
1016
+ * requirements (`decideStackRelease`): every gate the release recorded
1017
+ * passed, the floor it names is among them, every app the stack runs is
1018
+ * carried. The disk: the stack definition is still evaluated HERE — the
1019
+ * project's config keeps machine-local state beside itself (a generated
1020
+ * dev signing key, an admin's saved coverage), so it cannot be run from a
1021
+ * snapshot — and therefore this directory must be exactly the release's
1022
+ * project commit, or the definition deployed is not the release's. And
1023
+ * the statics: a stack whose gateway serves a bundle takes it from the
1024
+ * release, built for this stack, or not at all.
451
1025
  */
452
- async dispose() {
453
- this.slaveConnector?.dispose();
454
- await this.infraManager.teardownAll();
1026
+ /**
1027
+ * Would this release be taken by this stack? Asked without deploying it.
1028
+ *
1029
+ * The console shows this before an operator presses Deploy, and it is the
1030
+ * DEPLOYMENT'S OWN decision — the same `admitRelease` a start runs, with
1031
+ * nothing skipped and nothing re-implemented. A second copy of these rules
1032
+ * in the browser would drift from these ones within a week, and the only
1033
+ * thing worse than a refusal at deploy time is a console that promised the
1034
+ * opposite.
1035
+ */
1036
+ async checkRelease(projectName, stackName, releaseId) {
1037
+ try {
1038
+ const config = await this.loadProjectConfig(projectName);
1039
+ const stacks = this.resolveStacks(config, projectName);
1040
+ const stackConfig = stacks[stackName];
1041
+ if (!stackConfig)
1042
+ throw new Error(`Stack '${stackName}' not found in project '${projectName}'`);
1043
+ const release = await this.admitRelease(projectName, stackName, stackConfig, config, releaseId, 'check');
1044
+ const gates = release.manifest.gates;
1045
+ const passed = gates.filter((g) => g.status === 'passed').length;
1046
+ return {
1047
+ ok: true,
1048
+ because: `${projectName}/${stackName} would take ${release.id}: ${passed} of ${gates.length} gates passed, ` +
1049
+ `${release.files.length} artifact(s) match the manifest` +
1050
+ `${release.staticsDir ? `, static bundle built for ${release.manifest.statics?.stack}` : ''}.`,
1051
+ };
1052
+ }
1053
+ catch (err) {
1054
+ return { ok: false, because: err.message };
1055
+ }
455
1056
  }
456
- // ===========================================================================
457
- // Config Management
458
- // ===========================================================================
459
- async loadProjectConfig(projectName) {
1057
+ /**
1058
+ * Run a release's probes on the node of the stack that carries it, and
1059
+ * hand back what the producer printed and how it exited.
1060
+ *
1061
+ * Deciding whether to KEEP the result is not done here: that is
1062
+ * `release/attest.ts`, reached through the release service, which also
1063
+ * holds the audit trail the freshness refusal needs. This method is the
1064
+ * transport — the release's own scripts and the stack definition at its
1065
+ * commit, uploaded and run on the node under its deploy lease, so an
1066
+ * attestation never interleaves with a deployment.
1067
+ *
1068
+ * One node only, and refused by name otherwise. The probes reach
1069
+ * `localhost` and the stack's containers; on a stack of several nodes one
1070
+ * run measures one machine, and an attestation is stored per STACK — kept
1071
+ * as it is, it would claim for all of them what was measured on one.
1072
+ */
1073
+ async attestOnNode(projectName, stackName, releaseId) {
1074
+ const config = await this.loadProjectConfig(projectName);
1075
+ const stacks = this.resolveStacks(config, projectName);
1076
+ const stackConfig = stacks[stackName];
1077
+ if (!stackConfig)
1078
+ throw new Error(`Stack '${stackName}' not found in project '${projectName}'`);
1079
+ if (stackConfig.type === 'local') {
1080
+ throw new Error(`${projectName}/${stackName} is local: its probes run on this machine with \`node scripts/attest.mjs --stack=${stackName}\`, and need no transport`);
1081
+ }
1082
+ // The probes write to the system they measure — accounts, organisations,
1083
+ // payments accounts with their deposit addresses (on sample/test, 2026-09-24:
1084
+ // 91 probe organisations in the public catalogue, 10 mainnet addresses on
1085
+ // accounts nobody owns). So they run only where a stack said they may,
1086
+ // and never on one that takes other stacks' attestations: a stack that
1087
+ // declares `verifiedOn` is where people are, and is verified elsewhere.
1088
+ const releaseRules = stackConfig.release;
1089
+ if (releaseRules?.verifiedOn) {
1090
+ throw new Error(`${projectName}/${stackName} takes releases verified on '${releaseRules.verifiedOn.stack}' — it is verified there, ` +
1091
+ `and the probes, which write accounts and organisations into what they measure, do not run here`);
1092
+ }
1093
+ if (!releaseRules?.attest) {
1094
+ throw new Error(`${projectName}/${stackName} does not declare \`release.attest\` — the probes write accounts and organisations ` +
1095
+ `into the stack they measure, and run only on a stack that said they may`);
1096
+ }
1097
+ const nodes = stackConfig.nodes ?? [];
1098
+ if (nodes.length === 0)
1099
+ throw new Error(`${projectName}/${stackName} has no nodes to run the probes on`);
1100
+ if (nodes.length > 1) {
1101
+ throw new Error(`${projectName}/${stackName} has ${nodes.length} nodes. The probes reach localhost and the stack's containers, so one run ` +
1102
+ `measures one machine, and an attestation is stored per stack — it would claim for ${nodes.length} what was measured ` +
1103
+ `on 1. Not supported until attestations are kept per node.`);
1104
+ }
460
1105
  const project = this.registry.get(projectName);
461
1106
  if (!project)
462
- throw new Error(`Project '${projectName}' not found`);
463
- const { loadEcosystemConfig } = await import('../config/loader.js');
464
- const config = await loadEcosystemConfig(project.path);
465
- this.configRegistry.set(projectName, { config, loadedAt: Date.now() });
466
- return config;
467
- }
468
- getLoadedConfig(projectName) {
469
- return this.configRegistry.get(projectName)?.config ?? null;
470
- }
471
- async reloadConfig(projectName) {
472
- if (projectName) {
473
- await this.loadProjectConfig(projectName);
474
- this.emit('project:config_reloaded', projectName);
475
- }
476
- else {
477
- for (const project of this.registry.list()) {
478
- await this.loadProjectConfig(project.name);
479
- this.emit('project:config_reloaded', project.name);
480
- }
1107
+ throw new Error(`Project '${projectName}' is not in the registry`);
1108
+ const { loadRelease } = await import('../release/load.js');
1109
+ const release = await loadRelease(releaseId, await this.releaseStore());
1110
+ const { stageAttestation, attestationCommand, producerProvisions } = await import('../release/attest-on-node.js');
1111
+ const staged = await stageAttestation({
1112
+ releaseRoot: release.root,
1113
+ projectPath: fs.realpathSync(project.path),
1114
+ projectCommit: release.manifest.project.commit,
1115
+ });
1116
+ // The stack decides whether its probes may create accounts; the release's
1117
+ // producer decides whether it can. Both, or the flag is not passed — a
1118
+ // producer handed a flag it does not know measures nothing (exit 2).
1119
+ const accounts = stackConfig.release?.attest?.provision !== true
1120
+ ? 'not-declared'
1121
+ : producerProvisions(staged.dir)
1122
+ ? 'provisioned'
1123
+ : 'producer-cannot';
1124
+ const target = await this.targetForStackNode(nodes[0]);
1125
+ const machine = `${target.host}:${target.sshPort ?? 22}`;
1126
+ const containerPrefix = stackConfig.settings?.containerPrefix ?? `${projectName}-${stackName}`;
1127
+ try {
1128
+ return await this.deployer.underLease(target, `attestation of ${releaseId} on ${projectName}/${stackName}`, async () => {
1129
+ const { remoteDir } = await this.deployer.uploadStaticBundle(target, staged.dir, '/opt/omnitron/attest');
1130
+ this.logger.info({
1131
+ project: projectName,
1132
+ stack: stackName,
1133
+ release: releaseId,
1134
+ node: machine,
1135
+ remoteDir,
1136
+ scriptsFrom: staged.scriptsFrom,
1137
+ sourceFiles: staged.sourceFiles,
1138
+ accounts,
1139
+ }, 'Running the release\'s probes on its node');
1140
+ try {
1141
+ // Through the data channel: the producer's line is evidence to
1142
+ // store, and a probe's output that said `password=…` was cut by
1143
+ // the transport's masker through its closing quote.
1144
+ const run = await this.deployer.readFromNode(target, attestationCommand({ projectName, remoteDir, stack: stackName, releaseId, containerPrefix, provision: accounts === 'provisioned' }));
1145
+ return { ...run, node: machine, scriptsFrom: staged.scriptsFrom, sourceFiles: staged.sourceFiles, accounts };
1146
+ }
1147
+ finally {
1148
+ // The stage carries the application's source. The run's output is
1149
+ // the record, kept on this side; the node keeps its artifacts and
1150
+ // not a copy of the code they were built from. With its marker, so
1151
+ // the next run uploads again instead of trusting a directory gone.
1152
+ //
1153
+ // This ended in `.catch(() => undefined)`, which catches a throw —
1154
+ // and `runOnNode` answers a failed command with its exit code, over
1155
+ // a transport that turns even an unreachable node into `exit 1`. An
1156
+ // `rm` the node refused left the source where it was, the
1157
+ // attestation finished without a word, and nothing else ever
1158
+ // removes `/opt/omnitron/attest/`. So it is said here, and only
1159
+ // said: the probes' result stands, since this `rm` measured none of
1160
+ // it, and a failure in a `finally` must not replace what it guards.
1161
+ const { shellEscape } = await import('../shared/shell-escape.js');
1162
+ const removed = await this.deployer
1163
+ .runOnNode(target, `rm -rf ${shellEscape(remoteDir)} ${shellEscape(`${remoteDir}.delivered`)}`, 60_000)
1164
+ .catch((err) => ({ stdout: '', stderr: err instanceof Error ? err.message : String(err), code: -1 }));
1165
+ if (removed.code !== 0) {
1166
+ const words = (removed.stderr || removed.stdout).trim().split('\n').slice(-3).join(' | ');
1167
+ this.logger.warn({
1168
+ project: projectName,
1169
+ stack: stackName,
1170
+ release: releaseId,
1171
+ node: machine,
1172
+ remoteDir,
1173
+ code: removed.code,
1174
+ reason: words || `exit ${removed.code}`,
1175
+ }, 'The release\'s source is still on the node after its probes, and nothing else removes it');
1176
+ }
1177
+ }
1178
+ });
481
1179
  }
482
- }
483
- // ===========================================================================
484
- // Helpers
485
- // ===========================================================================
486
- getRunningStacks(projectName) {
487
- const result = [];
488
- for (const [, state] of this.stackStates) {
489
- if (state.project === projectName && (state.status === 'running' || state.status === 'starting')) {
490
- result.push(state.stack);
491
- }
1180
+ finally {
1181
+ fs.rmSync(staged.dir, { recursive: true, force: true });
492
1182
  }
493
- return result;
494
- }
495
- /** Build the stack handle key for an app */
496
- static handleKey(project, stack, app) {
497
- return `${project}/${stack}/${app}`;
498
1183
  }
499
- // ===========================================================================
500
- // Infrastructure Generation & Validation
501
- // ===========================================================================
502
1184
  /**
503
- * Generate optimal default infrastructure config by scanning app requirements.
1185
+ * Make a named account with a platform role on a remote stack, and keep
1186
+ * its password in this daemon's vault — never in the answer.
504
1187
  *
505
- * Reads each app's config/default.json `omnitron` section (same pattern as
506
- * requirements-scanner) and aggregates needs into a minimal InfrastructureConfig:
507
- * - Any app with `database` → shared PostgreSQL with per-app databases
508
- * - Any app with `redis` → shared Redis with auto-allocated DB indices
509
- * - Any app with `s3` → shared MinIO with per-app buckets
1188
+ * The project's own tool makes it (`scripts/operator-account.mjs` at the
1189
+ * project's HEAD commit), on the stack's node, under its deploy lease
1190
+ * (`withOperatorTool`). The password comes back sealed to a key made for
1191
+ * this run and is opened only to go into the vault — the SSH layer masks
1192
+ * anything that looks like a password on its way back (see
1193
+ * `project/operator-account.ts`).
1194
+ *
1195
+ * Two steps that must both happen or neither: the account on the stand and
1196
+ * its password in the vault. The vault is asked first whether the key is
1197
+ * free — a key already there is somebody's secret, and a new password would
1198
+ * replace it — and if the write still fails, the account just made is taken
1199
+ * away again, by its name AND the id the tool printed: an account whose
1200
+ * password nobody holds is a privileged row under a name every later run
1201
+ * refuses as taken. When the run's outcome is not known at all, the stand is
1202
+ * asked what it holds, and the answer says it — with the command that takes
1203
+ * the account away if it is there.
510
1204
  */
511
- async generateDefaultInfrastructure(projectConfig, projectPath, appNames) {
512
- // Resolve which apps to scan
513
- const apps = appNames === 'all'
514
- ? projectConfig.apps.filter((a) => a.enabled !== false)
515
- : projectConfig.apps.filter((a) => a.enabled !== false && appNames.includes(a.name));
516
- // Read omnitron config from each app's config/default.json
517
- const appConfigs = new Map();
518
- for (const app of apps) {
519
- if (!app.bootstrap)
520
- continue;
521
- const bootstrapPath = `${projectPath}/${app.bootstrap}`;
522
- const srcDir = path.dirname(bootstrapPath);
523
- const appRoot = path.resolve(srcDir, '..');
524
- const configPath = path.join(appRoot, 'config', 'default.json');
525
- try {
526
- const content = fs.readFileSync(configPath, 'utf-8');
527
- const json = JSON.parse(content);
528
- if (json.omnitron) {
529
- appConfigs.set(app.name, json.omnitron);
1205
+ async createOperatorAccount(projectName, stackName, input) {
1206
+ const secrets = this.secrets;
1207
+ if (!secrets) {
1208
+ throw new Error('This daemon has no vault: a password made on the node would have nowhere to go, so nothing was made');
1209
+ }
1210
+ const username = input.username?.trim();
1211
+ if (!username)
1212
+ throw new Error('An account needs a name (--username)');
1213
+ const tool = await import('../project/operator-account.js');
1214
+ const vaultKey = input.vaultKey?.trim() || tool.accountVaultKey(projectName, stackName, username);
1215
+ // Before the node is touched.
1216
+ if ((await secrets.get(vaultKey)) !== null) {
1217
+ throw new Error(`The vault already holds '${vaultKey}' — somebody's secret, which a new password would replace. ` +
1218
+ 'Name another key with --vault-key; nothing was made');
1219
+ }
1220
+ const key = await tool.sealingKey();
1221
+ return this.withOperatorTool(projectName, stackName, `operator account ${username}`, async (on) => {
1222
+ const facts = { project: projectName, stack: stackName, node: on.machine, username };
1223
+ const run = await on.run((remoteDir, containerPrefix) => tool.operatorAccountCommand({ projectName,
1224
+ remoteDir,
1225
+ containerPrefix,
1226
+ username,
1227
+ role: input.role,
1228
+ displayName: input.displayName,
1229
+ sealTo: key.spki,
1230
+ }), 180_000);
1231
+ const outcome = tool.readAccountRun(run, username, (sealed) => key.open(sealed));
1232
+ if (!outcome.made) {
1233
+ this.logger.error({ ...facts, code: run.code, uncertain: outcome.uncertain }, 'No operator account was made');
1234
+ // Not known whether it exists: ask the stand, so the answer can say
1235
+ // what is there and how to take it away.
1236
+ let holds = '';
1237
+ if (outcome.uncertain) {
1238
+ const shown = tool.readShowRun(await on.run((remoteDir, containerPrefix) => tool.operatorAccountShowCommand({ projectName, remoteDir, containerPrefix, username }), 60_000));
1239
+ holds = !shown.ok
1240
+ ? ` What the stand holds could not be read either: ${shown.because}`
1241
+ : shown.account
1242
+ ? ` The stand holds ${username}: id ${shown.account.id}, role ${shown.account.role}, made ${shown.account.createdAt ?? '(unknown)'}, ` +
1243
+ `${shown.account.lastActiveAt ? `last active ${shown.account.lastActiveAt}` : 'never signed in'}. Nobody holds its password; ` +
1244
+ `take it away with \`omnitron stack account ${projectName} ${stackName} --remove ${username} --id ${shown.account.id}\``
1245
+ : ` The stand holds no ${username} — nothing was made.`;
530
1246
  }
1247
+ throw new Error(`${on.where}: ${outcome.because}.${holds}`);
1248
+ }
1249
+ const { account } = outcome;
1250
+ try {
1251
+ await secrets.set(vaultKey, account.password);
531
1252
  }
532
1253
  catch (err) {
533
- this.logger.debug({ app: app.name, configPath, error: err.message }, 'Could not read app config for infrastructure scan');
1254
+ const why = err instanceof Error ? err.message : String(err);
1255
+ const undo = tool.readRemoveRun(await on.run((remoteDir, containerPrefix) => tool.operatorAccountRemoveCommand({ projectName, remoteDir, containerPrefix, username, id: account.id }), 120_000), username, account.id);
1256
+ this.logger.error({ ...facts, id: account.id, vaultKey, undone: undo.removed, error: why }, 'The vault refused an operator account\'s password');
1257
+ throw new Error(undo.removed
1258
+ ? `${on.where}: the vault refused the password of ${username} (${why}) — the account was taken away again; nothing is left on the stand`
1259
+ : `${on.where}: the vault refused the password of ${username} (${why}), and taking the account away failed too ` +
1260
+ `(${undo.because}): ${username} (id ${account.id}) is on the stand with a password nobody holds`, { cause: err });
534
1261
  }
1262
+ this.logger.info({ ...facts, role: account.role, id: account.id, vaultKey, commit: on.commit }, 'Operator account made — its password is in the vault');
1263
+ return { username, role: account.role, id: account.id, node: on.machine, vaultKey, commit: on.commit };
1264
+ });
1265
+ }
1266
+ /**
1267
+ * The stack's maintenance lock by hand (`maintenance-lock.ts`): the state
1268
+ * as its gateway enforces it, or held for a bounded time, or let go — a lock
1269
+ * a failed deployment left (it also expires by itself), or work outside a
1270
+ * deployment. Not under the node's deploy lease: letting go of the lock is
1271
+ * exactly what an operator does while a stuck deployment holds that lease.
1272
+ * Only omnitron's keys; the administrators' mode is main's, in /admin.
1273
+ */
1274
+ async stackMaintenance(projectName, stackName, request) {
1275
+ const config = await this.loadProjectConfig(projectName);
1276
+ const stackConfig = this.resolveStacks(config, projectName)[stackName];
1277
+ if (!stackConfig)
1278
+ throw new Error(`Stack '${stackName}' not found in project '${projectName}'`);
1279
+ if (stackConfig.type === 'local') {
1280
+ throw new Error(`${projectName}/${stackName} is local: its gateway is on this machine, and so is anyone using it`);
535
1281
  }
536
- if (appConfigs.size === 0) {
537
- return {};
1282
+ const nodes = stackConfig.nodes ?? [];
1283
+ if (nodes.length !== 1) {
1284
+ throw new Error(`${projectName}/${stackName} has ${nodes.length} node(s); which one runs the gateway is not decided here — one is supported`);
1285
+ }
1286
+ const lockModule = await import('./maintenance-lock.js');
1287
+ const target = await this.targetForStackNode(nodes[0]);
1288
+ const prefix = stackConfig.settings?.containerPrefix ?? `${projectName}-${stackName}`;
1289
+ // Where the gateway that is RUNNING reads — asked of it, not computed: by
1290
+ // hand one acts on the platform as it is.
1291
+ const site = await lockModule.runningGatewaySite(this.deployer.leaseRunner(target), prefix, 'by-hand');
1292
+ if (!site)
1293
+ throw new Error(`${projectName}/${stackName} has no running gateway (${prefix}-gateway) on ${target.host}`);
1294
+ if (request.action === 'on') {
1295
+ await lockModule.holdByHand(site, request.minutes ?? 15);
1296
+ }
1297
+ else if (request.action === 'off') {
1298
+ await lockModule.releaseByHand(site);
1299
+ }
1300
+ else if (request.action !== 'status') {
1301
+ throw new Error(`'${String(request.action)}' is not a maintenance action — status, on or off`);
1302
+ }
1303
+ // What the gateway enforces — asked after its 2-second cache has turned.
1304
+ if (request.action !== 'status')
1305
+ await new Promise((resolve) => setTimeout(resolve, 2_500));
1306
+ const state = await lockModule.readMaintenanceState(site);
1307
+ this.logger.info({ project: projectName, stack: stackName, action: request.action, active: state.active, reason: state.reason }, 'Maintenance lock by hand');
1308
+ return { where: `${projectName}/${stackName} on ${target.host}`, ...state };
1309
+ }
1310
+ /**
1311
+ * What the probes left on a stand — the project's own census of it,
1312
+ * `scripts/probe-leftovers.mjs` without a flag: SELECTs and nothing else,
1313
+ * the accounts, organisations and orphans it would take and what would hold
1314
+ * each back. Its removal is not a door here: that takes the owner's word
1315
+ * each time, and this census is what the word is given on.
1316
+ */
1317
+ async probeLeftovers(projectName, stackName, request) {
1318
+ const tool = await import('../project/operator-account.js');
1319
+ const mode = request.mode;
1320
+ if (mode !== 'census' && mode !== 'rehearse' && mode !== 'apply') {
1321
+ throw new Error(`'${String(mode)}' is not asked for from here — the census, a rehearsal, or the removal`);
1322
+ }
1323
+ const what = mode === 'census'
1324
+ ? 'census of what the probes left'
1325
+ : mode === 'rehearse'
1326
+ ? 'rehearsal of removing what the probes left'
1327
+ : 'removal of what the probes left';
1328
+ return this.withOperatorTool(projectName, stackName, what, async (on) => {
1329
+ const read = tool.readLeftoversRun(await on.run((remoteDir, containerPrefix) => tool.probeLeftoversCommand({ projectName, remoteDir, containerPrefix, mode, also: request.also }),
1330
+ // A rehearsal or a removal runs every database's removal and its
1331
+ // invariants — longer than counting.
1332
+ mode === 'census' ? 300_000 : 900_000), mode);
1333
+ if (!read.ok)
1334
+ throw new Error(`${on.where}: could not take the ${what}: ${read.because}`);
1335
+ return { node: on.machine, commit: on.commit, mode, report: read.census.report, lines: read.census.lines };
1336
+ }, tool.PROBE_LEFTOVERS_TOOL);
1337
+ }
1338
+ /**
1339
+ * The stand's accounts counted — by role, by status, by the form their MFA
1340
+ * data is in, and whether the seeded admin still opens with its published
1341
+ * password. Counts only. For decisions that depend on what a stand holds
1342
+ * without reading it: a new MFA root cannot open what the old one sealed,
1343
+ * so how many rows are sealed is asked first.
1344
+ */
1345
+ async censusOperatorAccounts(projectName, stackName) {
1346
+ const tool = await import('../project/operator-account.js');
1347
+ return this.withOperatorTool(projectName, stackName, 'census of accounts', async (on) => {
1348
+ // Three minutes, not one: the census checks privileged accounts against
1349
+ // every password the repository publishes with bcrypt at the stand's
1350
+ // cost — 18 s for 17 accounts on the dev stand.
1351
+ const read = tool.readCensusRun(await on.run((remoteDir, containerPrefix) => tool.operatorCensusCommand({ projectName, remoteDir, containerPrefix }), 180_000));
1352
+ if (!read.ok)
1353
+ throw new Error(`${on.where}: could not take the census: ${read.because}`);
1354
+ return { node: on.machine, commit: on.commit, census: read.census };
1355
+ });
1356
+ }
1357
+ /** What a remote stack's stand holds under a name — never a secret. */
1358
+ async showOperatorAccount(projectName, stackName, username) {
1359
+ const name = username?.trim();
1360
+ if (!name)
1361
+ throw new Error('Which account? (--show <name>)');
1362
+ const tool = await import('../project/operator-account.js');
1363
+ return this.withOperatorTool(projectName, stackName, `reading operator account ${name}`, async (on) => {
1364
+ const shown = tool.readShowRun(await on.run((remoteDir, containerPrefix) => tool.operatorAccountShowCommand({ projectName, remoteDir, containerPrefix, username: name }), 60_000));
1365
+ if (!shown.ok)
1366
+ throw new Error(`${on.where}: could not read ${name}: ${shown.because}`);
1367
+ return { node: on.machine, commit: on.commit, account: shown.account };
1368
+ });
1369
+ }
1370
+ /**
1371
+ * Take an account away from a remote stack's stand — the row with this
1372
+ * name AND this id, or nothing — and the password this daemon keeps for it,
1373
+ * if it keeps one: a password for an account that is gone is not a secret
1374
+ * worth keeping, and a vault that still lists it says the account exists.
1375
+ */
1376
+ async removeOperatorAccount(projectName, stackName, input) {
1377
+ const username = input.username?.trim();
1378
+ const id = input.id?.trim();
1379
+ if (!username || !id)
1380
+ throw new Error('Taking an account away needs its name and its id (--remove <name> --id <uuid>)');
1381
+ const tool = await import('../project/operator-account.js');
1382
+ const vaultKey = input.vaultKey?.trim() || tool.accountVaultKey(projectName, stackName, username);
1383
+ const removed = await this.withOperatorTool(projectName, stackName, `removing operator account ${username}`, async (on) => {
1384
+ const outcome = tool.readRemoveRun(await on.run((remoteDir, containerPrefix) => tool.operatorAccountRemoveCommand({ projectName, remoteDir, containerPrefix, username, id }), 120_000), username, id);
1385
+ if (!outcome.removed)
1386
+ throw new Error(`${on.where}: ${username} (id ${id}) was not removed: ${outcome.because}`);
1387
+ return { node: on.machine, commit: on.commit };
1388
+ });
1389
+ // After the stand, never before: a key removed for an account that stayed
1390
+ // would be a password lost.
1391
+ const dropped = (await this.secrets?.delete(vaultKey)) === true;
1392
+ this.logger.info({ project: projectName, stack: stackName, node: removed.node, username, id, vaultKey: dropped ? vaultKey : null }, 'Operator account removed');
1393
+ return { username, id, node: removed.node, commit: removed.commit, vaultKeyRemoved: dropped ? vaultKey : null };
1394
+ }
1395
+ /**
1396
+ * The release this stack last started with, as the audit trail recorded it
1397
+ * (`stack.start`, `details.release`) — or null: no trail, no start, or a
1398
+ * start that was not a release.
1399
+ */
1400
+ async releaseRunningOn(projectName, stackName) {
1401
+ if (!this.audit)
1402
+ return null;
1403
+ try {
1404
+ const rows = await this.audit.latestPerResource('stack.start');
1405
+ const row = rows.find((r) => r.resourceId === `${projectName}/${stackName}`);
1406
+ const release = row?.details?.['release'];
1407
+ return typeof release === 'string' && release !== '[object]' ? release : null;
538
1408
  }
539
- // Aggregate needs
540
- const needsPostgres = [...appConfigs.values()].some((c) => !!c.database);
541
- const needsRedis = [...appConfigs.values()].some((c) => !!c.redis);
542
- const needsS3 = [...appConfigs.values()].some((c) => !!c.s3);
543
- const infrastructure = {};
544
- // PostgreSQL: shared instance with per-app databases
545
- if (needsPostgres) {
546
- const databases = {};
547
- for (const [appName, config] of appConfigs) {
548
- if (config.database) {
549
- const dbConfig = typeof config.database === 'object' ? config.database : {};
550
- databases[appName] = {
551
- migrate: true,
552
- ...(dbConfig.pool ? {} : {}),
553
- };
554
- }
555
- }
1409
+ catch (err) {
1410
+ this.logger.warn({ project: projectName, stack: stackName, error: err.message }, 'The audit trail could not be read — which release runs on this stack is unknown');
1411
+ return null;
1412
+ }
1413
+ }
1414
+ /**
1415
+ * What every operator-account action shares: a remote stack of one node,
1416
+ * the project's tool staged from its HEAD commit, the node's deploy lease,
1417
+ * and the stage taken away again on both sides, whatever `work` did.
1418
+ *
1419
+ * One node, like the attestation: the tool reaches the node's containers,
1420
+ * and which of several nodes holds the stack's database is not decided
1421
+ * here.
1422
+ */
1423
+ async withOperatorTool(projectName, stackName, purpose, work,
1424
+ /** The project's tool the work runs — its commit must have it. */
1425
+ toolPath) {
1426
+ const config = await this.loadProjectConfig(projectName);
1427
+ const stackConfig = this.resolveStacks(config, projectName)[stackName];
1428
+ if (!stackConfig)
1429
+ throw new Error(`Stack '${stackName}' not found in project '${projectName}'`);
1430
+ const tool = await import('../project/operator-account.js');
1431
+ if (stackConfig.type === 'local') {
1432
+ throw new Error(`${projectName}/${stackName} is local: its containers are on this machine — run ` +
1433
+ `\`node ${tool.OPERATOR_ACCOUNT_TOOL}\` in the project`);
1434
+ }
1435
+ const nodes = stackConfig.nodes ?? [];
1436
+ if (nodes.length === 0)
1437
+ throw new Error(`${projectName}/${stackName} has no node to reach its stand on`);
1438
+ if (nodes.length > 1) {
1439
+ throw new Error(`${projectName}/${stackName} has ${nodes.length} nodes. The tool reaches one node's containers, and which of them ` +
1440
+ 'holds the stack\'s database is not decided here — not supported until it is');
1441
+ }
1442
+ const project = this.registry.get(projectName);
1443
+ if (!project)
1444
+ throw new Error(`Project '${projectName}' is not in the registry`);
1445
+ const staged = await tool.stageOperatorTool(fs.realpathSync(project.path), toolPath ?? tool.OPERATOR_ACCOUNT_TOOL);
1446
+ const target = await this.targetForStackNode(nodes[0]);
1447
+ const machine = `${target.host}:${target.sshPort ?? 22}`;
1448
+ const containerPrefix = stackConfig.settings?.containerPrefix ?? `${projectName}-${stackName}`;
1449
+ const { shellEscape } = await import('../shared/shell-escape.js');
1450
+ try {
1451
+ return await this.deployer.underLease(target, `${purpose} on ${projectName}/${stackName}`, async () => {
1452
+ const { remoteDir } = await this.deployer.uploadStaticBundle(target, staged.dir, '/opt/omnitron/operator');
1453
+ try {
1454
+ return await work({
1455
+ // The tool's answer line is data; the sealed password needs no
1456
+ // help past the masker, and the rest of the line gets it here.
1457
+ run: (command, timeoutMs) => this.deployer.readFromNode(target, command(remoteDir, containerPrefix), timeoutMs),
1458
+ machine,
1459
+ where: `${projectName}/${stackName} on ${machine}`,
1460
+ commit: staged.commit,
1461
+ });
1462
+ }
1463
+ finally {
1464
+ // The tool leaves the node with its run, as the attestation's stage
1465
+ // does, and a removal the node refused is said rather than swallowed.
1466
+ const removed = await this.deployer
1467
+ .runOnNode(target, `rm -rf ${shellEscape(remoteDir)} ${shellEscape(`${remoteDir}.delivered`)}`, 60_000)
1468
+ .catch((err) => ({ stdout: '', stderr: err instanceof Error ? err.message : String(err), code: -1 }));
1469
+ if (removed.code !== 0) {
1470
+ this.logger.warn({
1471
+ project: projectName,
1472
+ stack: stackName,
1473
+ node: machine,
1474
+ remoteDir,
1475
+ code: removed.code,
1476
+ reason: removed.stderr.trim().split('\n').slice(-3).join(' | ') || `exit ${removed.code}`,
1477
+ }, 'The operator tool is still on the node after its run, and nothing else removes it');
1478
+ }
1479
+ }
1480
+ });
1481
+ }
1482
+ finally {
1483
+ fs.rmSync(staged.dir, { recursive: true, force: true });
1484
+ }
1485
+ }
1486
+ /**
1487
+ * The paths of the working tree a deployment of this stack reads, relative
1488
+ * to its root — what admission requires to be the release's commit.
1489
+ *
1490
+ * The definition (`release/definition-inputs.ts`), and the directories the
1491
+ * deployment ships to its nodes (`shippedDirsOf`): the gateway's nginx tree,
1492
+ * a service's scripts. Those were read from the working tree and compared
1493
+ * with nothing, so an uncommitted edit to the gateway's template reached the
1494
+ * test node past every gate a release has.
1495
+ */
1496
+ async deploymentReads(projectName, stackConfig, config, root) {
1497
+ const { definitionInputs } = await import('../release/definition-inputs.js');
1498
+ const { shippedDirsOf } = await import('../infrastructure/shipped-config.js');
1499
+ const shipped = shippedDirsOf(mergeInfrastructure(config, stackConfig), await this.collectDeclaredServices(projectName, stackConfig, config));
1500
+ return definitionInputs(root, this.resolveStackApps(stackConfig, config).flatMap((a) => (a.bootstrap ? [a.bootstrap] : [])), shipped.map(([, dir]) => dir));
1501
+ }
1502
+ async admitRelease(projectName, stackName, stackConfig, config, releaseId, purpose = 'deploy') {
1503
+ if (stackConfig.type === 'local') {
1504
+ throw new Error(`A release is for a remote stack — ${projectName}/${stackName} is local and runs its working tree.`);
1505
+ }
1506
+ const { loadRelease, treeEqualsCommit } = await import('../release/load.js');
1507
+ const { decideStackRelease } = await import('../release/manifest.js');
1508
+ const release = await loadRelease(releaseId, await this.releaseStore());
1509
+ const apps = this.resolveStackApps(stackConfig, config).map((a) => a.name);
1510
+ // The fourth argument, which was `[]` for as long as nothing wrote one:
1511
+ // a stack declaring `verifiedOn` refused every release forever, with a
1512
+ // refusal that read «nothing has been verified on 'test'» while there
1513
+ // was no way for anything to be. See `release/attest.ts`.
1514
+ const { loadAttestations } = await import('../release/attest.js');
1515
+ const attestations = loadAttestations(release.id, await this.releaseStore());
1516
+ const verdict = decideStackRelease(release.manifest, stackConfig.release, apps, attestations);
1517
+ if (verdict.action === 'refuse') {
1518
+ throw new Error(`Refusing release ${release.id} for ${projectName}/${stackName}: ${verdict.because}.`);
1519
+ }
1520
+ // A migration that ran on this stack is history: the release running
1521
+ // here says which ran, and a file among them that changed since is
1522
+ // refused now — not at the migration step, after the build and the
1523
+ // transfer. See `release/migrations.ts`.
1524
+ const running = await this.releaseRunningOn(projectName, stackName);
1525
+ if (!running) {
1526
+ this.logger.info({ project: projectName, stack: stackName }, 'No release is recorded as running on this stack — applied migrations are not compared');
1527
+ }
1528
+ else if (running !== release.id) {
1529
+ let ran;
1530
+ try {
1531
+ ran = (await loadRelease(running, await this.releaseStore())).manifest.migrations;
1532
+ }
1533
+ catch (err) {
1534
+ this.logger.warn({ project: projectName, stack: stackName, running, error: err.message }, 'The release running on this stack could not be read — its migrations are not compared');
1535
+ }
1536
+ if (ran && release.manifest.migrations) {
1537
+ const { changedReleasedMigrations } = await import('../release/migrations.js');
1538
+ const changed = changedReleasedMigrations(ran, release.manifest.migrations);
1539
+ if (changed.length > 0) {
1540
+ throw new Error(`Refusing release ${release.id} for ${projectName}/${stackName}: ${changed.length} migration(s) that ran here ` +
1541
+ `with ${running} changed or went away (${changed.slice(0, 5).join(', ')}${changed.length > 5 ? ', …' : ''}). ` +
1542
+ `An applied migration is history — put the change in a new migration.`);
1543
+ }
1544
+ }
1545
+ else {
1546
+ this.logger.info({ project: projectName, stack: stackName, running }, 'A release without migration digests on one side — applied migrations are not compared this time');
1547
+ }
1548
+ }
1549
+ const project = this.registry.get(projectName);
1550
+ if (!project)
1551
+ throw new Error(`Project '${projectName}' is not in the registry`);
1552
+ const commit = release.manifest.project.commit;
1553
+ // Only what the definition reads has to be the release's — see
1554
+ // `release/definition-inputs.ts`. The rest of the checkout is other
1555
+ // people's work in progress, which no deployment reads. When the files
1556
+ // cannot be named, the whole tree is compared, as before.
1557
+ const root = fs.realpathSync(project.path);
1558
+ let inputs;
1559
+ try {
1560
+ inputs = await this.deploymentReads(projectName, stackConfig, config, root);
1561
+ }
1562
+ catch (err) {
1563
+ this.logger.warn({ project: projectName, stack: stackName, error: err.message }, 'Could not name the files the definition reads — the whole working tree has to be the release\'s commit');
1564
+ }
1565
+ const tree = await treeEqualsCommit(root, commit, inputs);
1566
+ if (!tree.equal) {
1567
+ throw new Error(`Refusing release ${release.id} for ${projectName}/${stackName}: ` +
1568
+ `${inputs ? `of the ${inputs.length} path(s) the stack definition and what it ships are read from, ` : ''}` +
1569
+ `${tree.files.length} differ from its commit ${commit.slice(0, 8)} ` +
1570
+ `(${tree.files.slice(0, 5).join(', ')}${tree.files.length > 5 ? ', …' : ''}). ` +
1571
+ `The definition is read from ${project.path}, so those have to be the release's: commit or set them aside, ` +
1572
+ `check out ${commit.slice(0, 8)}, or build a release from what is here.`);
1573
+ }
1574
+ // The omni an application runs on a node is that node daemon's, not the
1575
+ // release's — see `release/daemon-omni.ts`.
1576
+ const { refuseForeignOmni } = await import('../release/daemon-omni.js');
1577
+ const foreign = refuseForeignOmni(release.manifest.omni.commit, await this.askDaemonVersions(stackConfig));
1578
+ if (foreign) {
1579
+ throw new Error(`Refusing release ${release.id} for ${projectName}/${stackName}: ${foreign}.`);
1580
+ }
1581
+ const infra = mergeInfrastructure(config, stackConfig);
1582
+ const servesStatics = Boolean(infra?.services?.['gateway']?.config?.staticDir ?? infra?.gateway?.staticDir);
1583
+ if (servesStatics && (!release.staticsDir || release.manifest.statics?.stack !== stackName)) {
1584
+ throw new Error(`Refusing release ${release.id} for ${projectName}/${stackName}: the stack's gateway serves a static bundle, and the ` +
1585
+ `release carries ${release.manifest.statics ? `one built for '${release.manifest.statics.stack}'` : 'none'} — ` +
1586
+ `\`omnitron release build ${projectName} --for ${stackName}\` builds it with this stack's environment.`);
1587
+ }
1588
+ this.logger.info({
1589
+ project: projectName,
1590
+ stack: stackName,
1591
+ release: release.id,
1592
+ because: verdict.because,
1593
+ projectCommit: commit.slice(0, 8),
1594
+ omniCommit: release.manifest.omni.commit.slice(0, 8),
1595
+ }, purpose === 'check' ? 'Release would be admitted — asked, not deployed' : 'Release admitted');
1596
+ return release;
1597
+ }
1598
+ /** What each of a stack's nodes says its daemon is — the omni its applications load. */
1599
+ async askDaemonVersions(stackConfig) {
1600
+ const connector = this.getSlaveConnector();
1601
+ return Promise.all((stackConfig.nodes ?? []).map(async (node) => {
1602
+ try {
1603
+ const pong = (await connector.invokeOnSlave(node.host, node.port ?? 9700, 'OmnitronDaemon', 'ping', []));
1604
+ return { host: node.host, version: pong?.version ?? null };
1605
+ }
1606
+ catch (err) {
1607
+ return { host: node.host, version: null, error: err.message };
1608
+ }
1609
+ }));
1610
+ }
1611
+ /**
1612
+ * What a restart of THIS daemon may do to a remote stack: look, not ship.
1613
+ *
1614
+ * Boot and the enabled-stacks reconciler resumed every enabled stack through
1615
+ * a full start, and for a remote stack a full start is a deployment — build,
1616
+ * deliver, restart — of whatever this machine's working tree holds, onto
1617
+ * machines other people deploy to as well. Measured 2026-09-22: a master
1618
+ * restarted on a clean tree shipped its tree to the one test server by
1619
+ * itself (`source: 'auto-resume'`, 13:56:56 → 14:00:05, five applications
1620
+ * restarted onto new artifacts); the three restarts before it that morning
1621
+ * were stopped only because the tree happened to be dirty — a condition
1622
+ * that has nothing to do with intent, and a clean tree of an old commit
1623
+ * passes it as easily. Every development machine with the stack enabled did
1624
+ * the same, on every restart, past every gate.
1625
+ *
1626
+ * The applications on a node are supervised by the node's own daemon. What
1627
+ * this master lost in its restart is its own picture of them, so that is
1628
+ * what is rebuilt: the nodes are asked what they run, and the answer becomes
1629
+ * the stack's state — `running`, or `degraded` when some application is not
1630
+ * online (the node restarts its own; this master does not ship over it). A
1631
+ * stack no node answered for is an error the reconciler retries, by asking
1632
+ * again. Deploying stays something an operator asks for.
1633
+ */
1634
+ async attachRemoteStack(projectName, stackName, stackConfig, source) {
1635
+ const { info, answered } = await this.askNodes(projectName, this.toStackInfo(projectName, stackName, stackConfig));
1636
+ const why = source === 'boot' ? 'a daemon boot' : 'an automatic resume';
1637
+ if (answered === 0) {
1638
+ throw new Error(`Not deploying ${projectName}/${stackName} from ${why}: none of its node(s) answered, so there is nothing ` +
1639
+ `to attach to yet. \`omnitron stack start ${projectName} ${stackName}\` deploys it, deliberately.`);
1640
+ }
1641
+ const down = info.apps.filter((a) => a.status !== 'online');
1642
+ this.stackStates.set(`${projectName}/${stackName}`, {
1643
+ project: projectName,
1644
+ stack: stackName,
1645
+ status: down.length === 0 ? 'running' : 'degraded',
1646
+ config: stackConfig,
1647
+ startedAt: Date.now(),
1648
+ infraService: null,
1649
+ });
1650
+ this.logger.info({
1651
+ project: projectName,
1652
+ stack: stackName,
1653
+ source,
1654
+ online: info.apps.length - down.length,
1655
+ total: info.apps.length,
1656
+ ...(down.length > 0 ? { down: down.map((a) => a.name) } : {}),
1657
+ }, down.length === 0
1658
+ ? 'Attached to a remote stack running on its nodes — nothing deployed'
1659
+ : 'Attached to a remote stack with applications not online — its nodes supervise them; nothing deployed from a restart');
1660
+ return info;
1661
+ }
1662
+ /**
1663
+ * Get the infrastructure manager (for daemon-level access).
1664
+ */
1665
+ getInfraManager() {
1666
+ return this.infraManager;
1667
+ }
1668
+ // ===========================================================================
1669
+ // Enabled-stacks reconciler — desired-state convergence
1670
+ // ===========================================================================
1671
+ /**
1672
+ * Periodically converge running stacks onto the persisted desired state
1673
+ * (`project.enabledStacks`). Boot-time resume in daemon.startApps() is
1674
+ * one-shot — on 2026-07-11 it fired seconds after login while Docker
1675
+ * (OrbStack) was still coming up, failed with "Docker is not available",
1676
+ * and the platform stayed appless for days despite a healthy supervised
1677
+ * daemon. The reconciler retries with per-stack exponential backoff until
1678
+ * the stack is running, and picks up any later divergence (a stack that
1679
+ * crashed into 'error', infra that vanished) the same way.
1680
+ *
1681
+ * Manual intent is respected by construction: `stack stop` removes the
1682
+ * stack from enabledStacks (persisted), so a deliberately stopped stack is
1683
+ * never resurrected; 'starting'/'stopping' states are skipped as in-flight.
1684
+ */
1685
+ startEnabledStacksReconciler(intervalMs = 60_000) {
1686
+ if (this.resumeTimer)
1687
+ return;
1688
+ this.resumeTimer = setInterval(() => {
1689
+ void this.reconcileEnabledStacks().catch((err) => {
1690
+ this.logger.warn({ error: err.message }, 'Enabled-stacks reconciler tick failed');
1691
+ });
1692
+ }, intervalMs);
1693
+ this.resumeTimer.unref();
1694
+ this.logger.info({ intervalMs }, 'Enabled-stacks reconciler started');
1695
+ }
1696
+ async reconcileEnabledStacks() {
1697
+ // Collect the divergence set first — cheap, no I/O.
1698
+ const pending = [];
1699
+ for (const project of this.registry.list()) {
1700
+ for (const stackName of project.enabledStacks ?? []) {
1701
+ const state = this.stackStates.get(`${project.name}/${stackName}`);
1702
+ const status = state?.status;
1703
+ // Converge only from fully-down states (absent/stopped/error).
1704
+ // 'degraded' means mostly-up — restarting the whole stack over it
1705
+ // would churn healthy apps; per-app recovery owns that case.
1706
+ if (status === 'running' || status === 'starting' || status === 'stopping' || status === 'degraded') {
1707
+ continue;
1708
+ }
1709
+ pending.push({ project: project.name, stack: stackName });
1710
+ }
1711
+ }
1712
+ if (pending.length === 0)
1713
+ return;
1714
+ // One cheap probe gates the whole round: without Docker every start is
1715
+ // guaranteed to fail, so don't burn backoff budget on it.
1716
+ const { isDockerAvailable } = await import('../infrastructure/container-runtime.js');
1717
+ if (!(await isDockerAvailable())) {
1718
+ if (this.dockerWasAvailable !== false) {
1719
+ this.logger.warn({ pending: pending.map((p) => `${p.project}/${p.stack}`) }, 'Reconciler: Docker unavailable — deferring enabled-stack resume');
1720
+ }
1721
+ this.dockerWasAvailable = false;
1722
+ return;
1723
+ }
1724
+ this.dockerWasAvailable = true;
1725
+ const now = Date.now();
1726
+ for (const { project, stack } of pending) {
1727
+ const key = `${project}/${stack}`;
1728
+ const backoff = this.resumeBackoff.get(key);
1729
+ if (backoff && now < backoff.nextAt)
1730
+ continue;
1731
+ this.logger.info({ project, stack, attemptDelayMs: backoff?.delayMs ?? 0 }, 'Reconciler: resuming enabled stack');
1732
+ try {
1733
+ // Config may not be loaded yet on a fresh daemon (boot resume died
1734
+ // before reaching this project) — load lazily, same as boot does.
1735
+ if (!this.getLoadedConfig(project)) {
1736
+ await this.loadProjectConfig(project);
1737
+ }
1738
+ await this.startStack(project, stack, { source: 'auto-resume' });
1739
+ this.resumeBackoff.delete(key);
1740
+ this.logger.info({ project, stack }, 'Reconciler: stack resumed');
1741
+ }
1742
+ catch (err) {
1743
+ const prev = backoff?.delayMs ?? 0;
1744
+ const delayMs = Math.min(prev > 0 ? prev * 2 : 60_000, 15 * 60_000);
1745
+ this.resumeBackoff.set(key, { nextAt: Date.now() + delayMs, delayMs });
1746
+ this.logger.error({ project, stack, error: err.message, retryInMs: delayMs }, 'Reconciler: stack resume failed — will retry');
1747
+ }
1748
+ }
1749
+ }
1750
+ /**
1751
+ * Dispose of all resources held by this service.
1752
+ * Called during daemon shutdown.
1753
+ */
1754
+ async dispose() {
1755
+ if (this.resumeTimer) {
1756
+ clearInterval(this.resumeTimer);
1757
+ this.resumeTimer = null;
1758
+ }
1759
+ this.slaveConnector?.dispose();
1760
+ this.infraManager.dispose();
1761
+ }
1762
+ // ===========================================================================
1763
+ // Config Management
1764
+ // ===========================================================================
1765
+ async loadProjectConfig(projectName) {
1766
+ const project = this.registry.get(projectName);
1767
+ if (!project)
1768
+ throw new Error(`Project '${projectName}' not found`);
1769
+ const { loadEcosystemConfig } = await import('../config/loader.js');
1770
+ const config = await loadEcosystemConfig(project.path);
1771
+ this.configRegistry.set(projectName, { config, loadedAt: Date.now() });
1772
+ return config;
1773
+ }
1774
+ getLoadedConfig(projectName) {
1775
+ return this.configRegistry.get(projectName)?.config ?? null;
1776
+ }
1777
+ /** In-flight loads, so callers arriving together load a project once. */
1778
+ configLoads = new Map();
1779
+ /**
1780
+ * The project's config, loaded now if the daemon has not got to it yet.
1781
+ *
1782
+ * A reader that arrived in the first seconds after a daemon start found no
1783
+ * config and was answered with an error: measured on the master restarted
1784
+ * at 12:07:48 UTC, 20 ERROR lines «Project 'sample' config not loaded» from
1785
+ * the console's `getStack` polls, logged as server failures and counted by
1786
+ * `doctor` — for a project that loads a moment later, which is what the
1787
+ * reconciler already does for itself (`loadProjectConfig` when absent).
1788
+ */
1789
+ async ensureConfig(projectName) {
1790
+ const loaded = this.getLoadedConfig(projectName);
1791
+ if (loaded)
1792
+ return loaded;
1793
+ let pending = this.configLoads.get(projectName);
1794
+ if (!pending) {
1795
+ pending = this.loadProjectConfig(projectName).finally(() => this.configLoads.delete(projectName));
1796
+ this.configLoads.set(projectName, pending);
1797
+ }
1798
+ return pending;
1799
+ }
1800
+ async reloadConfig(projectName) {
1801
+ if (projectName) {
1802
+ await this.loadProjectConfig(projectName);
1803
+ this.emit('project:config_reloaded', projectName);
1804
+ }
1805
+ else {
1806
+ for (const project of this.registry.list()) {
1807
+ await this.loadProjectConfig(project.name);
1808
+ this.emit('project:config_reloaded', project.name);
1809
+ }
1810
+ }
1811
+ }
1812
+ // ===========================================================================
1813
+ // Helpers
1814
+ // ===========================================================================
1815
+ getRunningStacks(projectName) {
1816
+ const result = [];
1817
+ for (const [, state] of this.stackStates) {
1818
+ if (state.project === projectName && (state.status === 'running' || state.status === 'starting')) {
1819
+ result.push(state.stack);
1820
+ }
1821
+ }
1822
+ return result;
1823
+ }
1824
+ /** Build the stack handle key for an app */
1825
+ static handleKey(project, stack, app) {
1826
+ return `${project}/${stack}/${app}`;
1827
+ }
1828
+ // ===========================================================================
1829
+ // Infrastructure Generation & Validation
1830
+ // ===========================================================================
1831
+ /**
1832
+ * Generate optimal default infrastructure config by scanning app requirements.
1833
+ *
1834
+ * Reads each app's config/default.json `omnitron` section (same pattern as
1835
+ * requirements-scanner) and aggregates needs into a minimal InfrastructureConfig:
1836
+ * - Any app with `database` → shared PostgreSQL with per-app databases
1837
+ * - Any app with `redis` → shared Redis with auto-allocated DB indices
1838
+ * - Any app with `s3` → shared MinIO with per-app buckets
1839
+ */
1840
+ async generateDefaultInfrastructure(projectConfig, projectPath, appNames) {
1841
+ // Resolve which apps to scan
1842
+ const apps = appNames === 'all'
1843
+ ? projectConfig.apps.filter((a) => a.enabled !== false)
1844
+ : projectConfig.apps.filter((a) => a.enabled !== false && appNames.includes(a.name));
1845
+ // Read omnitron config from each app's config/default.json
1846
+ const appConfigs = new Map();
1847
+ for (const app of apps) {
1848
+ if (!app.bootstrap)
1849
+ continue;
1850
+ const bootstrapPath = `${projectPath}/${app.bootstrap}`;
1851
+ const srcDir = path.dirname(bootstrapPath);
1852
+ const appRoot = path.resolve(srcDir, '..');
1853
+ const configPath = path.join(appRoot, 'config', 'default.json');
1854
+ try {
1855
+ const content = fs.readFileSync(configPath, 'utf-8');
1856
+ const json = JSON.parse(content);
1857
+ if (json.omnitron) {
1858
+ appConfigs.set(app.name, json.omnitron);
1859
+ }
1860
+ }
1861
+ catch (err) {
1862
+ this.logger.debug({ app: app.name, configPath, error: err.message }, 'Could not read app config for infrastructure scan');
1863
+ }
1864
+ }
1865
+ if (appConfigs.size === 0) {
1866
+ return {};
1867
+ }
1868
+ // Aggregate needs
1869
+ const needsPostgres = [...appConfigs.values()].some((c) => !!c.database);
1870
+ const needsRedis = [...appConfigs.values()].some((c) => !!c.redis);
1871
+ const needsS3 = [...appConfigs.values()].some((c) => !!c.s3);
1872
+ const infrastructure = {};
1873
+ // PostgreSQL: shared instance with per-app databases
1874
+ if (needsPostgres) {
1875
+ const databases = {};
1876
+ for (const [appName, config] of appConfigs) {
1877
+ if (config.database) {
1878
+ const dbConfig = typeof config.database === 'object' ? config.database : {};
1879
+ databases[appName] = {
1880
+ migrate: true,
1881
+ ...(dbConfig.extensions?.length ? { extensions: dbConfig.extensions } : {}),
1882
+ };
1883
+ }
1884
+ }
556
1885
  infrastructure.postgres = {
557
1886
  port: 5432,
558
1887
  databases,
@@ -601,6 +1930,48 @@ export class ProjectService extends EventEmitter {
601
1930
  * Validate that a stack's infrastructure config satisfies all app requirements.
602
1931
  * Returns issues if infrastructure is missing for apps that need it.
603
1932
  */
1933
+ /**
1934
+ * What each node of a stack would find and do on its host for the services
1935
+ * the stack declares — asked, not done (`host-inspection.ts`).
1936
+ *
1937
+ * Built from the parts a deployment sends — the applications'
1938
+ * declarations and the stack's overrides with their references resolved
1939
+ * from this vault — plus the services reached elsewhere, to say whether
1940
+ * they answer. The node that runs mainnet chains could otherwise be read
1941
+ * only by deploying to it: it takes SSH by password, and that stays in
1942
+ * the vault.
1943
+ */
1944
+ async inspectStackHost(data) {
1945
+ const config = await this.loadProjectConfig(data.project);
1946
+ const stackConfig = this.resolveStacks(config, data.project)[data.stack];
1947
+ if (!stackConfig)
1948
+ throw new Error(`No stack '${data.stack}' in ${data.project}`);
1949
+ const nodes = stackConfig.nodes ?? [];
1950
+ if (stackConfig.type === 'local' || nodes.length === 0) {
1951
+ throw new Error(`${data.project}/${data.stack} runs on no node of its own — its host is this machine`);
1952
+ }
1953
+ const services = await this.collectDeclaredServices(data.project, stackConfig, config, { external: true });
1954
+ const overrides = await this.resolveOverrideSecrets(data.project, stackConfig.serviceOverrides);
1955
+ const { project: _project, stack: _stack, ...asked } = data;
1956
+ const connector = this.getSlaveConnector();
1957
+ const readings = [];
1958
+ for (const node of nodes) {
1959
+ const host = node.host;
1960
+ const port = node.port ?? 9700;
1961
+ try {
1962
+ await connector.addSlave({ host, port, label: node.label });
1963
+ if (!(await connector.waitUntilConnected(host, port, 60_000))) {
1964
+ throw new Error('it did not join the mesh within a minute');
1965
+ }
1966
+ const inspection = (await connector.invokeOnSlave(host, port, 'OmnitronInfra', 'inspectHostServices', [{ services, overrides, ...asked }], { retryOnDisconnect: true }));
1967
+ readings.push({ node: `${host}:${port}`, inspection });
1968
+ }
1969
+ catch (err) {
1970
+ readings.push({ node: `${host}:${port}`, error: err.message });
1971
+ }
1972
+ }
1973
+ return readings;
1974
+ }
604
1975
  async validateStackInfrastructure(projectName, stackName) {
605
1976
  const config = await this.loadProjectConfig(projectName);
606
1977
  const stacks = this.resolveStacks(config, projectName);
@@ -669,52 +2040,18 @@ export class ProjectService extends EventEmitter {
669
2040
  // Validation is best-effort — don't block stack start
670
2041
  }
671
2042
  // 1. Load bootstrap definitions (needed for both infra provisioning and config resolution)
672
- const appDefinitions = new Map();
673
2043
  const appEntries = this.resolveStackApps(stackConfig, ecosystemConfig);
2044
+ // What the stack gives its apps, vault references resolved — now, before
2045
+ // anything is provisioned: a key the vault does not hold refuses the
2046
+ // start here (project/stack-env.ts).
2047
+ const stackEnv = await this.stackEnvFor(projectName, stackName, stackConfig, appEntries.map((e) => e.name));
674
2048
  const project = this.registry.get(projectName);
675
- for (const entry of appEntries) {
676
- if (entry.bootstrap && project) {
677
- try {
678
- const bootstrapAbsPath = path.resolve(project.path, entry.bootstrap);
679
- const { loadBootstrapConfig } = await import('../orchestrator/bootstrap-loader.js');
680
- const definition = await loadBootstrapConfig(bootstrapAbsPath, { devMode: false });
681
- // Populate omnitronConfig from app's config/default.json if not already set
682
- if (!definition.omnitronConfig) {
683
- const srcDir = path.dirname(bootstrapAbsPath);
684
- const appRoot = path.resolve(srcDir, '..');
685
- const configPath = path.join(appRoot, 'config', 'default.json');
686
- try {
687
- const content = fs.readFileSync(configPath, 'utf-8');
688
- const json = JSON.parse(content);
689
- if (json.omnitron) {
690
- definition.omnitronConfig = json.omnitron;
691
- }
692
- }
693
- catch { /* config file missing — skip */ }
694
- }
695
- appDefinitions.set(entry.name, definition);
696
- }
697
- catch {
698
- // Non-critical — app can start without definition
699
- }
700
- }
701
- }
2049
+ const appDefinitions = await this.loadAppDefinitions(projectName, stackConfig, ecosystemConfig);
702
2050
  // 2. Provision per-stack infrastructure (built-in + app-declared services)
703
2051
  // Auto-detect core services (postgres, redis, minio) from app requirements,
704
2052
  // then merge with explicit infrastructure config (gateway, custom services).
705
2053
  // Merge stack-level + ecosystem-level infrastructure (stack overrides ecosystem, but both contribute)
706
- let effectiveInfra;
707
- if (stackConfig.infrastructure || ecosystemConfig.infrastructure) {
708
- effectiveInfra = {
709
- ...ecosystemConfig.infrastructure,
710
- ...stackConfig.infrastructure,
711
- // Deep-merge services maps from both levels
712
- services: {
713
- ...ecosystemConfig.infrastructure?.services,
714
- ...stackConfig.infrastructure?.services,
715
- },
716
- };
717
- }
2054
+ let effectiveInfra = mergeInfrastructure(ecosystemConfig, stackConfig);
718
2055
  if (project) {
719
2056
  try {
720
2057
  const { scanRequirements, buildInfraFromRequirements } = await import('../project/requirements-scanner.js');
@@ -740,6 +2077,10 @@ export class ProjectService extends EventEmitter {
740
2077
  try {
741
2078
  const infraService = await this.infraManager.provisionStack(projectName, stackName, stackConfig, effectiveInfra, appDefinitions, project?.path);
742
2079
  state.infraService = infraService;
2080
+ const outcome = summariseProvisioning(infraService.getDesiredServices(), infraService.getState().services);
2081
+ if (!outcome.ready && !outcome.empty) {
2082
+ throw new Error(describeProvisioning(outcome));
2083
+ }
743
2084
  }
744
2085
  catch (err) {
745
2086
  const reason = err.message;
@@ -777,104 +2118,893 @@ export class ProjectService extends EventEmitter {
777
2118
  if (effectiveInfra && project) {
778
2119
  await this.runStackMigrations(projectName, stackName, effectiveInfra, appEntries, project.path);
779
2120
  }
780
- // 3. Resolve per-app infrastructure config via stack resolver
781
- const portAllocation = this.infraManager.getPortAllocation(projectName, stackName);
782
- const normalizedSvcs = this.infraManager.getNormalizedServices(projectName, stackName);
783
- const resolvedStack = resolveStack(ecosystemConfig, projectName, stackName, stackConfig, appDefinitions, portAllocation ?? undefined, undefined, normalizedSvcs ?? undefined);
784
- // 4. Start each app with namespaced handle key and resolved infra config
785
- for (const entry of appEntries) {
786
- const appConfig = resolvedStack.appConfigs.get(entry.name);
787
- // Build environment variables from resolved infrastructure config
788
- const infraEnv = appConfig ? resolvedConfigToEnv(appConfig, entry.name, stackName) : {};
789
- this.logger.info({ app: entry.name, envKeys: Object.keys(infraEnv), hasS3: !!infraEnv['S3_ENDPOINT'], hasDb: !!infraEnv['DATABASE_URL'] }, 'Infrastructure env vars for app');
790
- const namespacedEntry = {
791
- ...entry,
792
- name: ProjectService.handleKey(projectName, stackName, entry.name),
793
- // Resolve bootstrap to absolute path using project root — the orchestrator's
794
- // cwd may differ from the project directory (e.g. daemon running from webapp/).
795
- ...(entry.bootstrap && project
796
- ? { bootstrap: path.resolve(project.path, entry.bootstrap) }
797
- : {}),
798
- // Set CWD to project root so child processes can resolve relative config paths
799
- // (e.g., apps/storage/config/default.json) correctly.
800
- ...(project ? { cwd: project.path } : {}),
801
- env: {
802
- ...entry.env,
803
- ...infraEnv,
804
- ...stackConfig.settings?.env,
805
- OMNITRON_PROJECT: projectName,
806
- OMNITRON_STACK: stackName,
807
- OMNITRON_STACK_TYPE: 'local',
808
- },
809
- };
810
- try {
811
- await this.orchestrator.startApp(namespacedEntry, ecosystemConfig);
812
- }
813
- catch (err) {
814
- this.logger.error({ app: entry.name, project: projectName, stack: stackName, error: err.message }, 'Failed to start app in stack');
815
- }
2121
+ // 3. How each app is started: its entry, built from the stack's resolved
2122
+ // configuration (`stackEntryBuilder` — the same builder a re-read of
2123
+ // the project uses to redefine running apps).
2124
+ const appConfigBuilder = this.stackEntryBuilder(projectName, stackName, stackConfig, ecosystemConfig, appDefinitions, stackEnv);
2125
+ // 4. Start apps in dependency-aware parallel batches.
2126
+ //
2127
+ // Pre-fix this was a serial `for ... await` loop in declaration
2128
+ // order. For the downstream stack that meant geo + pricing (zero
2129
+ // deps) waited 30+ seconds for unrelated apps to finish booting
2130
+ // before they themselves were even spawned — every app paid
2131
+ // the cumulative cold-start cost. The dependency-aware batched
2132
+ // start that `OrchestratorService.startAll` uses was bypassed
2133
+ // entirely because stack-mode goes through this code path
2134
+ // instead of startAll.
2135
+ //
2136
+ // resolveStartupOrder returns waves: every app in wave N has
2137
+ // all its `dependsOn` already in wave 0..N-1. We start each
2138
+ // wave with Promise.all so apps without deps run truly in
2139
+ // parallel, and a wave can't start until every member of the
2140
+ // previous wave is online. Same skip-on-failed-dep semantics
2141
+ // as startAll.
2142
+ const failed = new Set();
2143
+ const blocked = new Set();
2144
+ for (const wave of resolveStartupOrder(appEntries)) {
2145
+ await Promise.all(wave.map(async (entry) => {
2146
+ const blockingDep = (entry.dependsOn ?? []).find((d) => failed.has(d) || blocked.has(d));
2147
+ if (blockingDep) {
2148
+ blocked.add(entry.name);
2149
+ this.logger.warn({ app: entry.name, project: projectName, stack: stackName, blockedBy: blockingDep }, 'Skipping app — dependency failed or was blocked');
2150
+ return;
2151
+ }
2152
+ const namespacedEntry = appConfigBuilder(entry);
2153
+ const envMap = namespacedEntry.env;
2154
+ this.logger.info({
2155
+ app: entry.name,
2156
+ wave: 'parallel',
2157
+ envKeys: envMap ? Object.keys(envMap) : [],
2158
+ hasS3: !!envMap?.['S3_ENDPOINT'],
2159
+ hasDb: !!envMap?.['DATABASE_URL'],
2160
+ }, 'Infrastructure env vars for app');
2161
+ try {
2162
+ await this.orchestrator.startApp(namespacedEntry, ecosystemConfig);
2163
+ }
2164
+ catch (err) {
2165
+ failed.add(entry.name);
2166
+ this.logger.error({ app: entry.name, project: projectName, stack: stackName, error: err.message }, 'Failed to start app in stack');
2167
+ }
2168
+ }));
2169
+ }
2170
+ if (failed.size > 0 || blocked.size > 0) {
2171
+ this.logger.warn({ project: projectName, stack: stackName, failed: [...failed], blocked: [...blocked] }, 'Stack startup finished with failures — some apps did not launch');
2172
+ }
2173
+ }
2174
+ /**
2175
+ * Deploy a stack to its nodes, and say what it is doing while it does it.
2176
+ *
2177
+ * Measured on 2026-09-22: 829 seconds between this method's first log line
2178
+ * and its next one, with the work in flight the whole time. The CLI stops
2179
+ * waiting at 600 and says the operation is «probably still running» — true,
2180
+ * and silent about which of its seven steps was running. See
2181
+ * `deploy-phases.ts`.
2182
+ */
2183
+ async startRemoteStack(projectName, stackName, stackConfig, ecosystemConfig, release = null, reinstall = false) {
2184
+ const phases = reportPhases(this.logger, { project: projectName, stack: stackName });
2185
+ try {
2186
+ return await this.deployRemoteStack(projectName, stackName, stackConfig, ecosystemConfig, phases, release, reinstall);
2187
+ }
2188
+ finally {
2189
+ // Every exit, including the refusals: a reporter that outlives its
2190
+ // deployment narrates a step nobody is taking.
2191
+ phases.done();
816
2192
  }
817
2193
  }
818
- async startRemoteStack(projectName, stackName, stackConfig, ecosystemConfig) {
2194
+ async deployRemoteStack(projectName, stackName, stackConfig, ecosystemConfig, phases, release = null, reinstall = false) {
819
2195
  const nodes = stackConfig.nodes ?? [];
820
2196
  if (nodes.length === 0) {
821
2197
  throw new Error(`Remote stack '${stackName}' has no nodes configured`);
822
2198
  }
823
2199
  this.logger.info({ project: projectName, stack: stackName, nodes: nodes.length }, 'Starting remote stack — deploying to slave daemons');
824
2200
  const connector = this.getSlaveConnector();
2201
+ // Which nodes this deployment actually reached.
2202
+ //
2203
+ // A node that fails to provision is skipped and the loop moves on —
2204
+ // right when there are others, and a lie when there are not. Measured
2205
+ // 2026-09-22: the only node's SSH stopped completing handshakes, both
2206
+ // attempts logged «Failed to provision slave — skipping», and the
2207
+ // deployment went on to mark the stack running and write a `stack.start`
2208
+ // row saying `apps: 6`. The CLI was honest — «only 0/6 apps came
2209
+ // online» — and the audit trail, which is what anyone reads a week
2210
+ // later, recorded a deployment that never touched the machine.
2211
+ const reached = [];
2212
+ const skipped = [];
2213
+ /** Nodes that took the deployment with infrastructure not up, in the node's words. */
2214
+ const notReady = [];
2215
+ // What the applications in this stack declare they need.
2216
+ //
2217
+ // Collected here and sent to the node, because the node does not have
2218
+ // the application definitions when it is asked to provision — and
2219
+ // reading them again on that side would be a second implementation of
2220
+ // variant selection and override merging, on the side with less
2221
+ // information.
2222
+ phases.enter('reading what the applications declare');
2223
+ const declaredServices = await this.collectDeclaredServices(projectName, stackConfig, ecosystemConfig);
825
2224
  // Build artifacts for apps in this stack
826
2225
  const appEntries = this.resolveStackApps(stackConfig, ecosystemConfig);
2226
+ // What the stack gives its apps, vault references resolved — before any
2227
+ // node is touched: a key the vault does not hold refuses the deployment
2228
+ // here, not after a node was provisioned (project/stack-env.ts). A remote
2229
+ // stack's `settings.env` reached no app before this; a local one's did.
2230
+ const stackEnv = await this.stackEnvFor(projectName, stackName, stackConfig, appEntries.map((e) => e.name));
827
2231
  const project = this.registry.get(projectName);
828
2232
  let artifacts = [];
829
- if (project) {
2233
+ // Why a variable and not a `throw` inside the `try` below: the `catch`
2234
+ // is right there, and it swallowed exactly this. Caught by the test for
2235
+ // this change on its first run — the refusal fired, the catch logged
2236
+ // "deploying without rebuild", and the deployment carried on to the node
2237
+ // as if nothing had happened.
2238
+ let refusal = null;
2239
+ if (release) {
2240
+ // Built in clean clones, gated, and checked against the manifest by
2241
+ // `loadRelease` — nothing is compiled here, from this machine's disk.
2242
+ const files = new Map(release.files.map((f) => [f.app, f]));
2243
+ artifacts = appEntries.flatMap((entry) => {
2244
+ const file = files.get(entry.name);
2245
+ return file
2246
+ ? [{
2247
+ app: file.app,
2248
+ version: file.version,
2249
+ path: file.path,
2250
+ size: file.bytes,
2251
+ builtAt: release.manifest.builtAt,
2252
+ checksum: file.inputs,
2253
+ tarballSha256: file.sha256,
2254
+ }]
2255
+ : [];
2256
+ });
2257
+ const missing = appEntries.filter((e) => !files.has(e.name)).map((e) => e.name);
2258
+ if (missing.length > 0) {
2259
+ refusal = `Refusing to deploy ${stackName}: release ${release.id} carries no artifact for ${missing.join(', ')}.`;
2260
+ }
2261
+ else {
2262
+ this.logger.info({ release: release.id, artifacts: artifacts.map((a) => `${a.app}@${a.version}`), stack: stackName }, 'Deploying a release — artifacts from its clean build, nothing compiled here');
2263
+ }
2264
+ }
2265
+ else if (project) {
830
2266
  try {
831
2267
  const { ArtifactBuilder } = await import('../project/artifact-builder.js');
832
- const builder = new ArtifactBuilder(project.path);
833
- artifacts = await builder.buildAll(appEntries);
834
- this.logger.info({ artifacts: artifacts.map((a) => `${a.app}@${a.version}`), stack: stackName }, 'Artifacts built for deployment');
2268
+ const builder = new ArtifactBuilder(project.path, undefined, {
2269
+ info: (msg) => this.logger.info({ project: project.name }, msg),
2270
+ });
2271
+ phases.enter('building artifacts');
2272
+ const outcome = await builder.buildAll(appEntries);
2273
+ artifacts = outcome.built;
2274
+ if (outcome.failed.length > 0) {
2275
+ // Said at error level and per app: a stack whose artifacts did not
2276
+ // build deploys nothing, and the reason is in the builder's hands
2277
+ // and nowhere else.
2278
+ for (const f of outcome.failed) {
2279
+ this.logger.error({ app: f.app, stack: stackName, error: f.error }, 'Artifact build failed');
2280
+ }
2281
+ // ...and then the deployment STOPS, which it did not.
2282
+ //
2283
+ // Measured on 2026-09-21: `main` failed to compile at 15:17:27,
2284
+ // the five that did compile were deployed, and at 15:19:02 this
2285
+ // method's caller logged `Stack started`. `omnitron stack status`
2286
+ // then reported `6/6 online` — true and useless, because the node
2287
+ // daemon is restarted unconditionally on every deploy and
2288
+ // auto-starts whatever its config still lists. `main` kept serving
2289
+ // the artifact from the PREVIOUS cycle while the operator was
2290
+ // shown a green stack. Earlier the same day three of six failed
2291
+ // and the deployment went out with three.
2292
+ //
2293
+ // A partial deployment is worse than none: the apps that landed
2294
+ // are newer than the ones that did not, and nothing on the node or
2295
+ // in this log says which is which. The one place that can refuse
2296
+ // is here, before a single byte leaves the machine.
2297
+ refusal =
2298
+ `Refusing to deploy ${stackName}: ${outcome.failed.length} of ${appEntries.length} artifact(s) failed to build — ` +
2299
+ outcome.failed.map((f) => f.app).join(', ') +
2300
+ '. Nothing was deployed; the per-app errors are above.';
2301
+ }
2302
+ else {
2303
+ this.logger.info({ artifacts: artifacts.map((a) => `${a.app}@${a.version}`), stack: stackName }, 'Artifacts built for deployment');
2304
+ }
835
2305
  }
836
2306
  catch (err) {
837
- this.logger.error({ error: err.message, stack: stackName }, 'Failed to build artifacts — deploying without rebuild');
2307
+ // The build system itself did not run — a missing module, a spawn
2308
+ // that threw, a timeout. `deploying without rebuild` described what
2309
+ // came next and did not object to it: the node was handed whatever
2310
+ // it had from the last cycle and told it was current.
2311
+ this.logger.error({ error: err.message, stack: stackName }, 'The artifact build did not run — nothing will be deployed');
2312
+ refusal = `Refusing to deploy ${stackName}: the artifact build did not run — ${err.message}`;
838
2313
  }
839
2314
  }
840
- // Resolve master address (from this daemon's perspective — what slaves connect to)
2315
+ else {
2316
+ // `if (project)` with nothing on the other side meant a name the
2317
+ // registry does not know produced NO log line at all: no build, no
2318
+ // error, an empty artifact list, and step 3 below skipped by
2319
+ // `if (artifacts.length > 0)`. The deployment then opened the mesh,
2320
+ // told the node to start what it already had, and reported success —
2321
+ // a stack "deployed" without a single byte leaving this machine.
2322
+ this.logger.error({ project: projectName, stack: stackName, known: this.registry.list().map((p) => p.name) }, 'This project is not in the registry, so nothing was built and nothing will be deployed');
2323
+ refusal =
2324
+ `Refusing to deploy ${stackName}: project '${projectName}' is not in the registry, so nothing was built. ` +
2325
+ `Known projects: ${this.registry.list().map((p) => p.name).join(', ') || '(none)'}.`;
2326
+ }
2327
+ // Before the mesh is opened and before the node is touched at all.
2328
+ if (refusal)
2329
+ throw new Error(refusal);
2330
+ // Resolve master address (from the SLAVE's perspective — what it dials)
841
2331
  const { DEFAULT_DAEMON_CONFIG: _dc } = await import('../config/defaults.js');
842
- const masterHost = _dc.host === '0.0.0.0' ? 'auto' : _dc.host;
2332
+ const { resolveMasterHost } = await import('./master-address.js');
843
2333
  const masterPort = _dc.port;
844
- // Deploy to each node: provision slave → deploy artifacts → connect
2334
+ // One writer per node. Every development machine is a master deploying
2335
+ // to these same nodes, and nothing made them see each other: two
2336
+ // `stack start`s interleaved on one node. Each node's lease is taken
2337
+ // before anything on it changes, confirmed before every step that changes
2338
+ // it, and given back when this deployment is done — `node-deploy-lease.ts`.
2339
+ const targets = new Map();
845
2340
  for (const node of nodes) {
846
- const nodeKey = `${node.host}:${node.port ?? 9700}`;
847
- // 1. Provision slave: install runtime + omnitron + generate slave config + start daemon
848
- const unsubProvision = this.deployer.onProgress((progress) => {
849
- this.emit('stack:deploy_progress', projectName, stackName, progress);
850
- });
851
- const provisioned = await this.deployer.provisionSlaveNode(node, masterHost === 'auto' ? node.host : masterHost, // If master binds 0.0.0.0, slave uses its own view of master
852
- masterPort, projectName);
853
- unsubProvision();
854
- if (!provisioned) {
855
- this.logger.error({ node: nodeKey }, 'Failed to provision slave — skipping');
856
- continue;
2341
+ targets.set(`${node.host}:${node.port ?? 9700}`, await this.targetForStackNode(node));
2342
+ }
2343
+ phases.enter(`leasing ${nodes.length} node(s)`);
2344
+ return withNodeLeases([...targets].map(([node, target]) => ({
2345
+ node,
2346
+ machine: `${target.host}:${target.sshPort ?? 22}`,
2347
+ run: this.deployer.leaseRunner(target),
2348
+ })), `${projectName}/${stackName}`, this.logger, async (leases) => {
2349
+ // Each node's platform is held at its gateway from before the first
2350
+ // step that can stop an app until the apps answer through it again
2351
+ // (`maintenance-lock.ts`). A node whose deployment throws or is
2352
+ // skipped keeps its lock only until this callback ends.
2353
+ const locks = new Map();
2354
+ try {
2355
+ // Deploy to each node: provision slave → deploy artifacts → connect
2356
+ for (const node of nodes) {
2357
+ const nodeKey = `${node.host}:${node.port ?? 9700}`;
2358
+ if (!leases.has(nodeKey)) {
2359
+ skipped.push(nodeKey);
2360
+ this.logger.error({ node: nodeKey, because: leases.unreachable.get(nodeKey) }, 'Could not reach this node to lease it — skipping');
2361
+ continue;
2362
+ }
2363
+ // 1. Provision slave: install runtime + omnitron + generate slave config + start daemon
2364
+ const unsubProvision = this.deployer.onProgress((progress) => {
2365
+ this.emit('stack:deploy_progress', projectName, stackName, progress);
2366
+ });
2367
+ const target = targets.get(nodeKey);
2368
+ const master = await resolveMasterHost({ advertiseHost: _dc.advertiseHost, bindHost: _dc.host }, target);
2369
+ if (master.host) {
2370
+ this.logger.info({ node: nodeKey, masterHost: master.host, from: master.source }, 'Resolved the master address this slave will dial');
2371
+ }
2372
+ else {
2373
+ // Not an obstacle: the mesh connects the other way. Said once, at
2374
+ // warn level, because a deployment that hands out no master address
2375
+ // is worth noticing if somebody expected the push path to exist.
2376
+ this.logger.warn({ node: nodeKey, because: master.reason }, 'Provisioning this node without a master address — it will be pulled from, not dial in');
2377
+ }
2378
+ await leases.confirm(nodeKey, `provisioning ${node.host}`);
2379
+ const lock = await this.holdForDeployment(target, projectName, stackName, stackConfig, ecosystemConfig, release, phases);
2380
+ if (lock)
2381
+ locks.set(nodeKey, lock);
2382
+ phases.enter(`provisioning ${node.host}`);
2383
+ const provisioned = await this.deployer.provisionSlaveNode(target, master.host, masterPort, projectName);
2384
+ unsubProvision();
2385
+ if (!provisioned) {
2386
+ skipped.push(nodeKey);
2387
+ this.logger.error({ node: nodeKey }, 'Failed to provision slave — skipping');
2388
+ continue;
2389
+ }
2390
+ reached.push(nodeKey);
2391
+ // 2. Bring up the stack's infrastructure, ON the node, before the
2392
+ // applications that need it arrive.
2393
+ //
2394
+ // This step did not exist. A remote stack provisioned the daemon,
2395
+ // shipped the artifacts and opened the mesh connection, and the
2396
+ // stack's `infrastructure` block — its Postgres, its Redis, its
2397
+ // MinIO — was read by the master and never left it. The
2398
+ // applications started on the node and had nothing to connect to,
2399
+ // which surfaces as every one of them failing its first query
2400
+ // rather than as a missing deployment step.
2401
+ //
2402
+ // Ordered before the artifacts deliberately: an app that starts
2403
+ // against a database that is not there yet spends its startup
2404
+ // budget retrying, and a supervisor's deadline turns that into a
2405
+ // crash loop over a condition that would have resolved.
2406
+ // The same infrastructure a local stack would get: the ecosystem's
2407
+ // services — gateway, Tor, tiles — plus whatever this stack declares.
2408
+ // Sending only the stack's own block is why a remote deployment had
2409
+ // databases and no gateway, and no onion address to reach it by.
2410
+ const nodeInfra = mergeInfrastructure(ecosystemConfig, stackConfig);
2411
+ if (nodeInfra || Object.keys(declaredServices).length > 0) {
2412
+ await leases.confirm(nodeKey, `bringing up infrastructure on ${node.host}`);
2413
+ phases.enter(`bringing up infrastructure on ${node.host}`);
2414
+ const infrastructure = await this.provisionNodeInfrastructure(connector, node, (nodeInfra ?? {}), declaredServices,
2415
+ // Resolved, as `resolveNodeAppEnv` resolves them: the node renders
2416
+ // a host service's config from these, and a reference it cannot
2417
+ // resolve — it has no copy of this vault — left the bare-metal
2418
+ // templates with nothing but the declaration's own credentials.
2419
+ {
2420
+ project: projectName,
2421
+ stack: stackName,
2422
+ overrides: await this.resolveOverrideSecrets(projectName, stackConfig.serviceOverrides),
2423
+ }, project?.path, release?.staticsDir ?? undefined);
2424
+ if (!infrastructure.ready) {
2425
+ // Not fatal: a node whose infrastructure is incomplete can still
2426
+ // be looked at, and stopping here would leave the fleet in a state
2427
+ // no command describes. It is said at error level with the node —
2428
+ // and carried to the start's answer and its audit row, where it
2429
+ // used to stop: bitcoind failed to start on the test node and the
2430
+ // deployment reported «6/6 apps online», exit 0, `outcome: ok`.
2431
+ this.logger.error({ node: nodeKey, stack: stackName, detail: infrastructure.detail }, 'Node infrastructure is not ready — deploying anyway, applications may not reach their databases');
2432
+ notReady.push(`${nodeKey}: ${infrastructure.detail}`);
2433
+ }
2434
+ }
2435
+ // 2b. Ask the node what it actually provisioned.
2436
+ //
2437
+ // The credentials are generated ON THE NODE — `provisionStack` runs
2438
+ // `withGeneratedCredentials` against the node's own vault — so the
2439
+ // master's copy of the stack's infrastructure carries whatever was
2440
+ // DECLARED, which for a stack that declares none is nothing at all.
2441
+ // Writing that into the node's config left `resolveStackAddresses`
2442
+ // with no `infrastructure` block, and its fallback chain ends in a
2443
+ // literal:
2444
+ //
2445
+ // infra?.postgres?.password ?? getEnv().POSTGRES_PASSWORD ?? 'postgres'
2446
+ //
2447
+ // Six apps were handed `postgres://postgres:postgres@localhost:5432/…`
2448
+ // against a container holding a 43-character generated secret, and
2449
+ // every one of them died with `password authentication failed for user
2450
+ // "postgres" (28P01)` after five retries.
2451
+ //
2452
+ // Only the secrets are taken from the node: ports, database names and
2453
+ // the rest stay as this stack declared them, because those are the
2454
+ // master's to decide and the node merely carried them out.
2455
+ phases.enter(`reading credentials from ${node.host}`);
2456
+ const nodeCredentials = await this.readNodeCredentials(connector, node, CREDENTIALED_SERVICES.filter((service) => Boolean(nodeInfra?.[service])));
2457
+ const deployedInfra = overlayCredentials(nodeInfra, nodeCredentials);
2458
+ // Resolved as a LOCAL stack, because from the node's side these
2459
+ // services are: containers on that machine, ports on its loopback. The
2460
+ // remote branch of `resolveStackAddresses` answers with the node's
2461
+ // public host, which is the master's way of reaching it and not the
2462
+ // app's — and omnitron publishes every managed port on 127.0.0.1, so
2463
+ // that address reaches nothing from inside the node.
2464
+ //
2465
+ // Per app, and NOT as an `infrastructure` block in the node's config.
2466
+ // Writing one was the first attempt and it was worse than the problem:
2467
+ // a stack with an infrastructure block is a stack the node PROVISIONS,
2468
+ // so the node autostarted its own `deployed` stack, built a second
2469
+ // complete set of containers under `sample-deployed-*` on empty volumes,
2470
+ // and swept the master's `sample-test-*` as orphans. An address is not an
2471
+ // instruction to build what it points at.
2472
+ phases.enter(`resolving the environment for ${node.host}`);
2473
+ const appEnv = await this.resolveNodeAppEnv(ecosystemConfig, projectName, stackConfig, appEntries, deployedInfra);
2474
+ // The stack's own, over the computed — as on a local stack, where
2475
+ // `settings.env` wins over both.
2476
+ for (const [app, env] of Object.entries(stackEnv))
2477
+ appEnv[app] = { ...(appEnv[app] ?? {}), ...env };
2478
+ // 3. Deploy app artifacts via SSH
2479
+ if (artifacts.length > 0) {
2480
+ const unsubDeploy = this.deployer.onProgress((progress) => {
2481
+ this.emit('stack:deploy_progress', projectName, stackName, progress);
2482
+ });
2483
+ // The definitions travel with the artifacts. A node that receives
2484
+ // one without the other has files it cannot run, and says so only if
2485
+ // someone asks it directly.
2486
+ await leases.confirm(nodeKey, `delivering ${artifacts.length} artifact(s) to ${node.host}`);
2487
+ phases.enter(`delivering ${artifacts.length} artifact(s) to ${node.host}`);
2488
+ const sink = ecosystemConfig.monitoring?.alertSink;
2489
+ const results = await this.deployer.deployToStack([target], artifacts, projectName, {
2490
+ apps: appEntries,
2491
+ appEnv,
2492
+ stack: stackName,
2493
+ // Past the node's record of what each app runs: ship and restart all.
2494
+ ...(reinstall ? { force: true } : {}),
2495
+ // The node counts its own alarms and hands them to this project's
2496
+ // sink on the stack it runs; named as the master names the stack.
2497
+ ...(sink && { alertSink: { ...sink, host: `${projectName}/${stackName}` } }),
2498
+ });
2499
+ unsubDeploy();
2500
+ const failed = results.filter((r) => r.status === 'failed');
2501
+ if (failed.length > 0) {
2502
+ // The other half of the same invariant. Building every artifact
2503
+ // and then failing to DELIVER some of them leaves exactly the
2504
+ // split state the throw above exists to prevent, and it was a
2505
+ // WARN — a level nothing acts on, under a `Stack started` that
2506
+ // follows regardless.
2507
+ this.logger.error({ node: nodeKey, failed: failed.map((f) => f.app) }, 'App deployments failed');
2508
+ throw new Error(`Deployment to ${nodeKey} failed for ${failed.length} of ${results.length} app(s): ` +
2509
+ failed.map((f) => f.app).join(', ') +
2510
+ '. The node is in a mixed state — re-run the deployment once the cause is fixed.');
2511
+ }
2512
+ }
2513
+ // 3b. Let the platform go — once it answers through the gateway, as
2514
+ // a person's request will. One that does not keeps the lock,
2515
+ // which then expires by itself; said, and carried to the answer.
2516
+ const held = locks.get(nodeKey);
2517
+ if (held) {
2518
+ locks.delete(nodeKey);
2519
+ phases.enter(`lifting maintenance on ${node.host}`);
2520
+ const lifted = await held.liftWhenAnswering(MAINTENANCE_PROBE_PATHS);
2521
+ if (!lifted.lifted) {
2522
+ notReady.push(`${nodeKey}: the platform did not answer through the gateway (${lifted.failed.join(', ')}) — ` +
2523
+ 'it stays in maintenance until the lock expires, or `omnitron stack maintenance … off`');
2524
+ }
2525
+ }
2526
+ // 4. Connect master to slave daemon via Netron TCP
2527
+ await leases.confirm(nodeKey, `joining ${node.host} to the mesh`);
2528
+ phases.enter(`joining ${node.host} to the mesh`);
2529
+ await connector.addSlave({
2530
+ host: node.host,
2531
+ port: node.port ?? 9700,
2532
+ label: node.label,
2533
+ stack: stackName,
2534
+ project: projectName,
2535
+ });
2536
+ }
2537
+ // Not one node took the deployment. Nothing was installed, nothing was
2538
+ // started, and the applications on those machines — if any are still up
2539
+ // — are running whatever they were running before. Marking the stack
2540
+ // `running` and recording a `stack.start` after that is how a trail
2541
+ // acquires a deployment that did not happen.
2542
+ if (reached.length === 0 && nodes.length > 0) {
2543
+ throw new Error(`Refusing to call ${stackName} started: none of its ${nodes.length} node(s) could be provisioned — ` +
2544
+ `${skipped.join(', ')}. The per-node errors are above.`);
2545
+ }
2546
+ return { nodes: nodes.length, reached: reached.length, skipped, notReady };
857
2547
  }
858
- // 2. Deploy app artifacts via SSH
859
- if (artifacts.length > 0) {
860
- const unsubDeploy = this.deployer.onProgress((progress) => {
861
- this.emit('stack:deploy_progress', projectName, stackName, progress);
862
- });
863
- const results = await this.deployer.deployToStack([node], artifacts, projectName);
864
- unsubDeploy();
865
- const failed = results.filter((r) => r.status === 'failed');
866
- if (failed.length > 0) {
867
- this.logger.warn({ node: nodeKey, failed: failed.map((f) => f.app) }, 'Some app deployments failed');
2548
+ finally {
2549
+ // A node whose deployment threw or was skipped: its platform is not
2550
+ // held past this deployment — the state it is in is the one to see.
2551
+ for (const [nodeKey, lock] of locks) {
2552
+ await lock.lift('failed').catch((err) => this.logger.error({ node: nodeKey, err: err.message }, 'Could not lift maintenance — it expires by itself'));
868
2553
  }
869
2554
  }
870
- // 3. Connect master to slave daemon via Netron TCP
871
- await connector.addSlave({
872
- host: node.host,
873
- port: node.port ?? 9700,
874
- label: node.label,
875
- stack: stackName,
876
- project: projectName,
2555
+ });
2556
+ }
2557
+ /**
2558
+ * Warn the platform on this node, then hold it at its gateway for the
2559
+ * deployment (`maintenance-lock.ts`). `null` when the stack opted out, or
2560
+ * when the node has no running gateway and Redis to hold with — a first
2561
+ * deployment, which nobody is using yet.
2562
+ */
2563
+ async holdForDeployment(target, projectName, stackName, stackConfig, ecosystemConfig, release, phases) {
2564
+ const settings = stackConfig.settings?.maintenance;
2565
+ if (settings?.disabled) {
2566
+ this.logger.warn({ stack: stackName }, 'Maintenance lock disabled for this stack — deploying under its users');
2567
+ return null;
2568
+ }
2569
+ const timing = {
2570
+ ...DEFAULT_MAINTENANCE_TIMING,
2571
+ ...(settings?.noticeSeconds !== undefined ? { noticeSeconds: settings.noticeSeconds } : {}),
2572
+ ...(settings?.etaSeconds !== undefined ? { etaSeconds: settings.etaSeconds } : {}),
2573
+ };
2574
+ // The database by the one rule the gateway on the node used for its own
2575
+ // REDIS_DB — from the same merged infrastructure the node was sent.
2576
+ const infra = mergeInfrastructure(ecosystemConfig, stackConfig);
2577
+ if (timing.noticeSeconds > 0)
2578
+ phases.enter(`warning the platform: maintenance in ${timing.noticeSeconds} s`);
2579
+ return MaintenanceLock.open({
2580
+ run: this.deployer.leaseRunner(target),
2581
+ prefix: stackConfig.settings?.containerPrefix ?? `${projectName}-${stackName}`,
2582
+ db: gatewayRedisDb(infra?.redis?.databases),
2583
+ release: release?.id ?? 'unreleased',
2584
+ }, this.logger, timing);
2585
+ }
2586
+ /**
2587
+ * Ask a node to bring up the infrastructure a stack declares.
2588
+ *
2589
+ * Over the mesh connection rather than over SSH: the node already runs a
2590
+ * reconciler that knows how to wait on a container's health, how to detect
2591
+ * a drifted spec, and how to clean up a phantom endpoint. Driving `docker`
2592
+ * over SSH from here would be a second implementation of all of it, on the
2593
+ * side of the wire with the least information.
2594
+ *
2595
+ * The connection is opened for this and kept — it is the same one the mesh
2596
+ * uses afterwards, so the node is joined by the time its applications
2597
+ * start rather than at the next heartbeat.
2598
+ */
2599
+ /**
2600
+ * Deliver each service's static content to the node, and report where.
2601
+ *
2602
+ * Only services that declare a `staticDir`, which today is the gateway
2603
+ * serving the portal. A stack that declares none sends nothing and this
2604
+ * costs nothing.
2605
+ *
2606
+ * A failure is reported and not thrown: a node whose databases came up and
2607
+ * whose frontend did not is a node worth looking at, and stopping the whole
2608
+ * provisioning over a frontend leaves less working, not more.
2609
+ */
2610
+ /**
2611
+ * What each deployed app should connect to, once it is on the node.
2612
+ *
2613
+ * The same `resolveStack` + `resolvedConfigToEnv` pair a local stack uses,
2614
+ * fed the infrastructure the node actually provisioned — so there is one
2615
+ * implementation of "what is DATABASE_URL" rather than a second one written
2616
+ * for nodes, which would drift from the first the first time either
2617
+ * changed.
2618
+ *
2619
+ * `type: 'local'` is not a pretence: the apps run as host processes on the
2620
+ * node, beside containers whose ports are published on that node's
2621
+ * loopback. Local is what they are from where they stand.
2622
+ */
2623
+ async resolveNodeAppEnv(ecosystemConfig, projectName, stackConfig, appEntries, infrastructure) {
2624
+ if (!infrastructure)
2625
+ return {};
2626
+ try {
2627
+ // The app DEFINITIONS, not an empty map. `resolveStack` reads each
2628
+ // app's `omnitron.infrastructure` to learn which database it wants,
2629
+ // which redis index, which bucket — and without them it produces an
2630
+ // app config with no `database` at all, so `resolvedConfigToEnv` emits
2631
+ // `APP_NAME`, `STACK_NAME` and `JWT_SECRET` and no `DATABASE_URL`.
2632
+ //
2633
+ // Measured: the node's config carried five environment variables per
2634
+ // app and not the one the apps were failing for. An empty map is not a
2635
+ // map of nothing declared; it is a map nobody filled.
2636
+ const definitions = await this.loadAppDefinitions(projectName, stackConfig, ecosystemConfig);
2637
+ // The stack's answers about services it does not provision.
2638
+ //
2639
+ // `serviceOverrides` is where a stack says that bitcoin and monero are
2640
+ // not containers here — they are daemons already running on the node,
2641
+ // on mainnet, at an address only this stack knows. It was left out of
2642
+ // the config this resolver was given, so a deployed app was configured
2643
+ // with the defaults its own declaration carries. Measured on the test
2644
+ // node, whose chains run at 192.168.100.2 with mainnet credentials:
2645
+ //
2646
+ // BITCOIN_RPC_URL http://localhost:18443 (regtest)
2647
+ // MONERO_DAEMON_URL http://localhost:38081 (stagenet)
2648
+ // MONERO_RPC_PASS omni_stagenet_dev_password
2649
+ //
2650
+ // — three facts about a laptop, written into the configuration of a
2651
+ // server, in the one subsystem where being wrong costs money.
2652
+ const serviceOverrides = await this.resolveOverrideSecrets(projectName, stackConfig.serviceOverrides);
2653
+ const resolved = resolveStack({ ...ecosystemConfig, infrastructure }, projectName, NODE_STACK, {
2654
+ type: 'local',
2655
+ apps: appEntries.map((a) => a.name),
2656
+ ...(serviceOverrides ? { serviceOverrides } : {}),
2657
+ }, definitions, undefined);
2658
+ const out = {};
2659
+ for (const entry of appEntries) {
2660
+ const appConfig = resolved.appConfigs.get(entry.name);
2661
+ if (!appConfig)
2662
+ continue;
2663
+ out[entry.name] = resolvedConfigToEnv(appConfig, entry.name, NODE_STACK);
2664
+ }
2665
+ return out;
2666
+ }
2667
+ catch (err) {
2668
+ // Not fatal, and loud: the apps would start with whatever their own
2669
+ // definitions carry, which is how they came to be started with
2670
+ // `postgres:postgres` in the first place.
2671
+ this.logger.error({ project: projectName, error: err.message }, 'Could not resolve what the deployed apps connect to — they will start with their declared env only');
2672
+ return {};
2673
+ }
2674
+ }
2675
+ /**
2676
+ * A stack's service overrides with their secret references resolved.
2677
+ *
2678
+ * An override names its credentials rather than carrying them:
2679
+ *
2680
+ * "secrets": { "rpc_password": { "secret": "monero.mainnet.rpc_password" } }
2681
+ *
2682
+ * `resolveSecretRefs` has existed for that since it was written and had no
2683
+ * caller at all, so the synchronous resolver downstream turned every one of
2684
+ * these into the literal string `<secret:monero.mainnet.rpc_password>` and
2685
+ * handed it to the application as a password.
2686
+ *
2687
+ * A key the vault does not hold resolves to an empty string, which an
2688
+ * application reports as an authentication failure against a credential it
2689
+ * was never given. That is worth a line naming the key.
2690
+ */
2691
+ async resolveOverrideSecrets(projectName, overrides) {
2692
+ if (!overrides || Object.keys(overrides).length === 0)
2693
+ return overrides;
2694
+ if (!this.secrets) {
2695
+ this.logger.warn({ project: projectName, services: Object.keys(overrides) }, 'No secrets store here — service overrides keep their references, and an app given one as a password cannot authenticate');
2696
+ return overrides;
2697
+ }
2698
+ const missing = [];
2699
+ const { resolveSecretRefs } = await import('../project/config-resolver.js');
2700
+ const resolved = await resolveSecretRefs(overrides, async (key) => {
2701
+ const value = await this.secrets.get(key);
2702
+ if (value === null)
2703
+ missing.push(key);
2704
+ return value;
2705
+ });
2706
+ if (missing.length > 0) {
2707
+ this.logger.error({ project: projectName, missing }, 'These secrets are named by the stack and absent from the vault — the apps will be given an empty credential');
2708
+ }
2709
+ return resolved;
2710
+ }
2711
+ /**
2712
+ * The credentials the NODE resolved, read back from the node.
2713
+ *
2714
+ * `getConnectionInfo` answers from the config `provisionStack` stored after
2715
+ * running `withGeneratedCredentials` against the node's vault, so it is the
2716
+ * only account of these secrets that is true on the machine the apps will
2717
+ * run on.
2718
+ *
2719
+ * `null` is an answer — the node runs no such service — and leaves it as
2720
+ * the stack declared it. A read that did not happen is not an answer. This
2721
+ * was «best-effort per service»: a failed read fell back to the declared
2722
+ * credentials, and for a secret the node generated those are wrong by
2723
+ * construction. Test node, 2026-09-22 21:17:51 UTC: the heartbeat dropped
2724
+ * the peer mid-read («manual disconnect»), the apps were configured with
2725
+ * the declared postgres password, 6 of 6 migrations failed on it, and the
2726
+ * node was left in a mixed state. So a dropped connection is reconnected
2727
+ * and asked once more — this is a read, it can be — and a read that still
2728
+ * fails stops the deployment HERE, before any application is configured
2729
+ * with a password that is not the node's.
2730
+ */
2731
+ async readNodeCredentials(connector, node,
2732
+ /** The services this stack declares — each must be answered for. */
2733
+ declared = []) {
2734
+ const out = {};
2735
+ for (const service of CREDENTIALED_SERVICES) {
2736
+ let info;
2737
+ try {
2738
+ info = (await connector.invokeOnSlave(node.host, node.port ?? 9700, 'OmnitronInfra', 'getConnectionInfo', [{ service }], { retryOnDisconnect: true }));
2739
+ }
2740
+ catch (err) {
2741
+ throw new Error(`Could not read ${service}'s credentials from ${node.host}: ${err.message}. ` +
2742
+ 'A secret the node generated is known only there, and the one this stack declares would be wrong — ' +
2743
+ 'stopping before any application is configured with it.', { cause: err });
2744
+ }
2745
+ if (info)
2746
+ out[service] = info;
2747
+ }
2748
+ // Nothing is an answer only for a service the stack does not run. For one
2749
+ // it does, it means the node's daemon holds no infrastructure for it — it
2750
+ // restarted since it last provisioned, and the step that would have
2751
+ // provisioned it did not reach it. The declared password in its place is
2752
+ // wrong by construction: on sample/test (2026-09-25) a deployment right
2753
+ // after a `fleet upgrade` found the node 66 s into a 60 s mesh wait,
2754
+ // «deployed anyway», configured six apps with `postgres`, and was stopped
2755
+ // only because each app's migrations had to log in first.
2756
+ const missing = declared.filter((service) => !out[service]);
2757
+ if (missing.length > 0) {
2758
+ throw new Error(`${node.host} answered nothing for ${missing.join(', ')}, which this stack runs there: its daemon holds no ` +
2759
+ 'infrastructure for them — it restarted since it last provisioned, and this deployment did not reach it to ' +
2760
+ 'provision them again (see the infrastructure step above). Their generated credentials are known only there, ' +
2761
+ 'so no application is configured with the declared ones. Run the deployment again once the node is in the mesh.');
2762
+ }
2763
+ return out;
2764
+ }
2765
+ async shipStackStatics(infrastructure, projectRoot, node,
2766
+ /**
2767
+ * A release's bundle, built in its clean clone with this stack's
2768
+ * `staticEnv`. Shipped as it is: rebuilding it from this disk would put
2769
+ * the developer's tree back into a release.
2770
+ */
2771
+ releasedStatics,
2772
+ /**
2773
+ * Whose stack this is. Given, the delivery also points
2774
+ * `current-<project>-<stack>` at what it unpacked, and the roots come back
2775
+ * beside the build directories so the gateway can mount the ROOT instead
2776
+ * of the build — which is what stops it being recreated, and with it the
2777
+ * second of 000 a deployment costs.
2778
+ */
2779
+ owner) {
2780
+ // Both spellings, like the config reader beside it: a stack may declare
2781
+ // the gateway as a preset service or through the legacy top-level block,
2782
+ // and reading only one is how `serviceOverrides` was honoured for half
2783
+ // the services in this codebase once already.
2784
+ const legacy = infrastructure
2785
+ .gateway;
2786
+ const preset = infrastructure.services?.['gateway'];
2787
+ const staticDir = preset?.config?.staticDir ?? legacy?.staticDir;
2788
+ if (!staticDir)
2789
+ return { dirs: {}, roots: {} };
2790
+ // How to build it, when it turns out to be stale. See `staticEnv`.
2791
+ const staticEnv = preset?.config?.staticEnv ??
2792
+ legacy?.staticEnv ??
2793
+ {};
2794
+ const abs = releasedStatics ??
2795
+ (staticDir.startsWith('/')
2796
+ ? staticDir
2797
+ : `${projectRoot.replace(/\/$/, '')}/${staticDir.replace(/^\.\//, '')}`);
2798
+ try {
2799
+ // Build it if it is behind, for the same reason a vendored package is
2800
+ // built if its `dist` is behind: "deploy" has to mean "deploy what is
2801
+ // in the tree". Measured on the test stack before this existed — the
2802
+ // `index.html` the gateway was serving had been built ten days and 879
2803
+ // source files earlier, so the test portal was not a test of anything
2804
+ // in the working tree, and nothing said so because nothing looked.
2805
+ //
2806
+ // Best-effort: a frontend that will not build is still worth shipping
2807
+ // as it stands, beside an error that says which it is. Refusing would
2808
+ // block a deployment whose BACKENDS are what changed.
2809
+ const stale = releasedStatics ? null : staleBuild(`${path.dirname(abs)}/src`, abs);
2810
+ if (stale) {
2811
+ this.logger.info({ dir: abs, stale }, 'The built frontend is behind its sources — rebuilding');
2812
+ try {
2813
+ const { resolvePnpm } = await import('../shared/pnpm.js');
2814
+ const { execFile } = await import('node:child_process');
2815
+ const { promisify } = await import('node:util');
2816
+ await promisify(execFile)(resolvePnpm(), ['run', 'build'], {
2817
+ cwd: path.dirname(abs),
2818
+ timeout: 900_000,
2819
+ maxBuffer: 16 * 1024 * 1024,
2820
+ env: { ...process.env, ...staticEnv },
2821
+ });
2822
+ }
2823
+ catch (err) {
2824
+ this.logger.error({
2825
+ dir: abs,
2826
+ error: err.message.slice(0, 800),
2827
+ staticEnv: Object.keys(staticEnv),
2828
+ }, 'Could not rebuild the frontend — shipping the build that is there. ' +
2829
+ 'A build that refuses to guess needs `staticEnv` on the gateway service.');
2830
+ }
2831
+ }
2832
+ // The same target the deployer uses for artifacts — with the node's SSH
2833
+ // user and credential. Passing a bare `{ host }` is how the first
2834
+ // attempt failed: `Failed to connect to 37.27.130.185`, an error about
2835
+ // credentials that were sitting in the registry all along.
2836
+ const target = await this.targetForStackNode(node);
2837
+ // Shared by every stack on the NODE — the literal names no project and
2838
+ // no stack — which is why the link inside it is per stack.
2839
+ const gatewayRoot = '/opt/omnitron/stack-static/gateway';
2840
+ const link = owner ? gatewayStaticLinkName(owner.project, owner.stack) : undefined;
2841
+ const deliver = () => this.deployer.uploadStaticBundle(target, abs, gatewayRoot, link);
2842
+ // Once more before giving up: `uploadStaticBundle` starts from nothing
2843
+ // each time — it removes what a failed attempt left — so a second try
2844
+ // is a clean one, and a transfer that broke once on a loaded link
2845
+ // usually does not break twice.
2846
+ const { remoteDir, bytes } = await deliver().catch((first) => {
2847
+ this.logger.warn({ node: node.host, service: 'gateway', dir: abs, error: first.message.slice(0, 400) }, 'The frontend did not reach the node whole — sending it once more');
2848
+ return deliver();
877
2849
  });
2850
+ this.logger.info({ node: node.host, service: 'gateway', from: abs, remoteDir, bytes }, bytes === 0 ? 'The node already has this build' : 'Frontend delivered to the node');
2851
+ // The root travels only when the link was made: a node that gets a root
2852
+ // mounts it and serves through the link, and one that does not behaves
2853
+ // exactly as it did before. An ADDITIVE field for the same reason — the
2854
+ // master's omni may legitimately be newer than the node's, so a changed
2855
+ // value type would break both mixing directions while a field an old
2856
+ // node ignores breaks neither.
2857
+ return { dirs: { gateway: remoteDir }, roots: link ? { gateway: gatewayRoot } : {} };
2858
+ }
2859
+ catch (err) {
2860
+ // Fatal, and before anything on the node changes. This returned `{}`,
2861
+ // and the node was then provisioned with no static root: the gateway,
2862
+ // recreated without its `/var/www/portal`, fell back to proxying `/` to
2863
+ // a Vite that is not there, and answered 504 after 60 s to every page
2864
+ // — while the deployment reported «6/6 apps online» and exited 0.
2865
+ // Measured on test, 2026-09-22 20:12–20:20 UTC. Refusing here leaves
2866
+ // the gateway serving the frontend it had.
2867
+ this.logger.error({ node: node.host, service: 'gateway', dir: abs, error: err.message }, 'Could not deliver the frontend — refusing to provision a gateway that would serve nothing at /');
2868
+ throw new FrontendNotDeliveredError(node.host, err.message);
2869
+ }
2870
+ }
2871
+ /**
2872
+ * Where the stack's static build lives, absolute, or undefined.
2873
+ *
2874
+ * Both spellings, and the same resolution `shipStackStatics` performs —
2875
+ * named once because two questions ask it: what to ship, and whether the
2876
+ * trees it is built from are the commits they claim.
2877
+ */
2878
+ staticDirOf(infrastructure, projectRoot) {
2879
+ if (!infrastructure)
2880
+ return undefined;
2881
+ const legacy = infrastructure.gateway;
2882
+ const preset = infrastructure
2883
+ .services?.['gateway'];
2884
+ const staticDir = preset?.config?.staticDir ?? legacy?.staticDir;
2885
+ if (!staticDir)
2886
+ return undefined;
2887
+ return staticDir.startsWith('/')
2888
+ ? staticDir
2889
+ : `${projectRoot.replace(/\/$/, '')}/${staticDir.replace(/^\.\//, '')}`;
2890
+ }
2891
+ /**
2892
+ * The config files the stack's services need, read from this master.
2893
+ *
2894
+ * Only services that name a directory to ship (`shippedDirOf`) — the
2895
+ * gateway's nginx tree, Nominatim's import scripts. A stack that names none
2896
+ * sends nothing, so this costs nothing where it is not needed.
2897
+ */
2898
+ async readStackConfigFiles(infrastructure, projectRoot,
2899
+ /** Filled with each service whose directory was sent → that directory's absolute path here. */
2900
+ shippedDirs,
2901
+ /** The services the stack's apps require — each may name a directory of its own, as the stack's may. */
2902
+ services) {
2903
+ const { readConfigDirectory } = await import('../infrastructure/config-payload.js');
2904
+ const { shippedDirsOf } = await import('../infrastructure/shipped-config.js');
2905
+ const fsp = await import('node:fs/promises');
2906
+ const payload = {};
2907
+ // The stack's own services first — an app's requirement of the same name
2908
+ // is the same service, and one directory is shipped for it.
2909
+ const candidates = shippedDirsOf(infrastructure, services);
2910
+ for (const [name, configDir] of candidates) {
2911
+ const abs = configDir.startsWith('/')
2912
+ ? configDir
2913
+ : `${projectRoot.replace(/\/$/, '')}/${configDir.replace(/^\.\//, '')}`;
2914
+ try {
2915
+ const { files, skipped } = await readConfigDirectory(abs, {
2916
+ readdir: (d) => fsp.readdir(d, { withFileTypes: true }),
2917
+ readFile: (f) => fsp.readFile(f, 'utf-8'),
2918
+ stat: (f) => fsp.stat(f),
2919
+ });
2920
+ if (files.length > 0) {
2921
+ payload[name] = files;
2922
+ shippedDirs?.set(name, abs);
2923
+ }
2924
+ if (skipped.length > 0) {
2925
+ // Said out loud: a file left behind changes what the service does,
2926
+ // and silence here would make the node's behaviour unexplainable
2927
+ // from the master.
2928
+ this.logger.warn({ service: name, dir: abs, skipped }, 'Some files were not sent to the node');
2929
+ }
2930
+ }
2931
+ catch (err) {
2932
+ // Not fatal. A stack whose gateway config is missing still gets its
2933
+ // databases, and the gateway fails visibly rather than the whole
2934
+ // provisioning stopping before anything was done.
2935
+ this.logger.error({ service: name, dir: abs, error: err.message }, 'Could not read this service\'s config directory — the node will run it unconfigured');
2936
+ }
2937
+ }
2938
+ return payload;
2939
+ }
2940
+ async provisionNodeInfrastructure(connector, node, infrastructure, services, owner,
2941
+ /**
2942
+ * This project's root on the MASTER, for reading the config files a
2943
+ * service needs on the node.
2944
+ *
2945
+ * The gateway is configured by files — an nginx template, an entrypoint,
2946
+ * Lua modules — and `resolveGateway` mounts them from this path. A node
2947
+ * has no copy of the project, so on a node those mounts do not exist and
2948
+ * the container comes up as bare openresty: measured on the test server,
2949
+ * an empty `Mounts` array, a null entrypoint, zero UPSTREAM variables,
2950
+ * and an onion serving `Welcome to OpenResty!` over Tor.
2951
+ */
2952
+ projectRoot,
2953
+ /** A release's static bundle, shipped as it was built — see `shipStackStatics`. */
2954
+ releasedStatics) {
2955
+ const host = node.host;
2956
+ const port = node.port ?? 9700;
2957
+ await connector.addSlave({ host, port, label: node.label });
2958
+ // `addSlave` starts the connection and returns; invoking straight after
2959
+ // races it. Measured: "Slave 37.27.130.185:9700 not connected", against
2960
+ // a node the master connected to successfully two seconds later.
2961
+ if (!(await connector.waitUntilConnected(host, port, 60_000))) {
2962
+ this.logger.error({ node: `${host}:${port}` }, 'This node did not join the mesh within a minute — its infrastructure cannot be brought up from here');
2963
+ return { ready: false, detail: 'the node did not join the mesh within a minute' };
2964
+ }
2965
+ try {
2966
+ const shippedDirs = new Map();
2967
+ const configFiles = projectRoot ? await this.readStackConfigFiles(infrastructure, projectRoot, shippedDirs, services) : {};
2968
+ // A service's bind mounts inside its shipped directory name the node's
2969
+ // copy, not this machine's path — the stack's own services and the ones
2970
+ // its apps require alike. See `shipped-config.ts`.
2971
+ const { pointMountsAtShippedConfig } = await import('../infrastructure/shipped-config.js');
2972
+ const configForNode = projectRoot && infrastructure.services
2973
+ ? { ...infrastructure, services: pointMountsAtShippedConfig(infrastructure.services, shippedDirs, projectRoot) }
2974
+ : infrastructure;
2975
+ const servicesForNode = projectRoot && services ? pointMountsAtShippedConfig(services, shippedDirs, projectRoot) : services;
2976
+ // The frontend the gateway serves, if this stack declares one. It goes
2977
+ // by SSH rather than inside the call: 32 MB is the wrong size for an
2978
+ // RPC argument, which is held whole on both sides and blocks the call
2979
+ // it rides on. Config files are 65 KB and ride along; a build does not.
2980
+ const shipped = projectRoot
2981
+ ? await this.shipStackStatics(infrastructure, projectRoot, node, releasedStatics, owner)
2982
+ : { dirs: {}, roots: {} };
2983
+ const staticRoots = shipped.dirs;
2984
+ const staticLinkRoots = shipped.roots;
2985
+ // Retry once if the connection turns out to be gone: the deployer just
2986
+ // restarted this node's daemon, so the mesh connection the master holds
2987
+ // was established to the process that exited. `provisionStack` is a
2988
+ // reconciler, so applying it twice is applying it once.
2989
+ const report = (await connector.invokeOnSlave(host, port, 'OmnitronInfra', 'provisionStack', [{ config: configForNode, services: servicesForNode, ...(owner ?? {}), configFiles, staticRoots, staticLinkRoots }], { retryOnDisconnect: true }));
2990
+ this.logger.info({
2991
+ node: `${host}:${port}`,
2992
+ ready: report?.ready ?? false,
2993
+ running: report?.running?.length ?? 0,
2994
+ missing: report?.missing ?? [],
2995
+ failed: report?.failed ?? [],
2996
+ }, report?.detail ?? 'Node infrastructure provisioned');
2997
+ // The node's own words travel with the verdict: «not ready» without
2998
+ // them is a sentence nobody downstream can act on.
2999
+ return { ready: report?.ready === true, detail: report?.detail ?? 'the node did not say' };
3000
+ }
3001
+ catch (err) {
3002
+ // Not «infrastructure incomplete, deploying anyway»: the node was not
3003
+ // touched, and going on would put new backends behind the old frontend.
3004
+ if (err instanceof FrontendNotDeliveredError)
3005
+ throw err;
3006
+ this.logger.error({ node: `${host}:${port}`, error: err.message }, 'Could not bring up this node\'s infrastructure');
3007
+ return { ready: false, detail: `could not ask the node: ${err.message}` };
878
3008
  }
879
3009
  }
880
3010
  async startClusterStack(projectName, stackName, stackConfig, ecosystemConfig) {
@@ -891,37 +3021,70 @@ export class ProjectService extends EventEmitter {
891
3021
  if (project) {
892
3022
  try {
893
3023
  const { ArtifactBuilder } = await import('../project/artifact-builder.js');
894
- const builder = new ArtifactBuilder(project.path);
895
- artifacts = await builder.buildAll(appEntries);
3024
+ const builder = new ArtifactBuilder(project.path, undefined, {
3025
+ info: (msg) => this.logger.info({ project: project.name }, msg),
3026
+ });
3027
+ const outcome = await builder.buildAll(appEntries);
3028
+ artifacts = outcome.built;
3029
+ for (const f of outcome.failed) {
3030
+ this.logger.error({ app: f.app, error: f.error }, 'Artifact build failed');
3031
+ }
896
3032
  }
897
3033
  catch (err) {
898
3034
  this.logger.error({ error: err.message }, 'Artifact build failed');
899
3035
  }
900
3036
  }
901
- // Resolve master address
3037
+ // Resolve master address (from the SLAVE's perspective — what it dials)
902
3038
  const { DEFAULT_DAEMON_CONFIG: _dc } = await import('../config/defaults.js');
903
- const masterHost = _dc.host === '0.0.0.0' ? 'auto' : _dc.host;
3039
+ const { resolveMasterHost } = await import('./master-address.js');
904
3040
  const masterPort = _dc.port;
905
3041
  // Provision all slave nodes in parallel (install runtime + omnitron + config)
906
3042
  const appNodes = nodes.filter((n) => n.role !== 'database' && n.role !== 'cache');
3043
+ // One writer per machine, as for a remote stack — see `deployRemoteStack`
3044
+ // and `node-deploy-lease.ts`.
3045
+ const targets = new Map();
907
3046
  for (const node of appNodes) {
908
- const unsubProvision = this.deployer.onProgress((progress) => {
909
- this.emit('stack:deploy_progress', projectName, stackName, progress);
910
- });
911
- await this.deployer.provisionSlaveNode(node, masterHost === 'auto' ? node.host : masterHost, masterPort, projectName);
912
- unsubProvision();
913
- }
914
- // Deploy app artifacts to all provisioned nodes
915
- if (artifacts.length > 0 && appNodes.length > 0) {
916
- const unsubDeploy = this.deployer.onProgress((progress) => {
917
- this.emit('stack:deploy_progress', projectName, stackName, progress);
918
- });
919
- const results = await this.deployer.deployToStack(appNodes, artifacts, projectName, { concurrency: 5 });
920
- unsubDeploy();
921
- const successful = results.filter((r) => r.status === 'success').length;
922
- const failed = results.filter((r) => r.status === 'failed').length;
923
- this.logger.info({ successful, failed, total: results.length }, 'Cluster deployment complete');
924
- }
3047
+ targets.set(`${node.host}:${node.port ?? 9700}`, await this.targetForStackNode(node));
3048
+ }
3049
+ await withNodeLeases([...targets].map(([node, target]) => ({
3050
+ node,
3051
+ machine: `${target.host}:${target.sshPort ?? 22}`,
3052
+ run: this.deployer.leaseRunner(target),
3053
+ })), `${projectName}/${stackName}`, this.logger, async (leases) => {
3054
+ const leased = appNodes.filter((n) => leases.has(`${n.host}:${n.port ?? 9700}`));
3055
+ for (const [node, because] of leases.unreachable) {
3056
+ this.logger.error({ node, because }, 'Could not reach this node to lease it — skipping');
3057
+ }
3058
+ for (const node of leased) {
3059
+ const nodeKey = `${node.host}:${node.port ?? 9700}`;
3060
+ const unsubProvision = this.deployer.onProgress((progress) => {
3061
+ this.emit('stack:deploy_progress', projectName, stackName, progress);
3062
+ });
3063
+ const target = targets.get(nodeKey);
3064
+ const master = await resolveMasterHost({ advertiseHost: _dc.advertiseHost, bindHost: _dc.host }, target);
3065
+ this.logger.info({ node: node.host, masterHost: master.host ?? '(none — this node is pulled from)', from: master.source }, 'Resolved the master address this slave will dial');
3066
+ await leases.confirm(nodeKey, `provisioning ${node.host}`);
3067
+ await this.deployer.provisionSlaveNode(target, master.host, masterPort, projectName);
3068
+ unsubProvision();
3069
+ }
3070
+ // Deploy app artifacts to all provisioned nodes
3071
+ if (artifacts.length > 0 && leased.length > 0) {
3072
+ for (const node of leased) {
3073
+ await leases.confirm(`${node.host}:${node.port ?? 9700}`, `delivering to ${node.host}`);
3074
+ }
3075
+ const unsubDeploy = this.deployer.onProgress((progress) => {
3076
+ this.emit('stack:deploy_progress', projectName, stackName, progress);
3077
+ });
3078
+ const results = await this.deployer.deployToStack(leased.map((n) => targets.get(`${n.host}:${n.port ?? 9700}`)), artifacts, projectName,
3079
+ // Same on the cluster path: artifacts without definitions are files a
3080
+ // node cannot run.
3081
+ { concurrency: 5, apps: appEntries });
3082
+ unsubDeploy();
3083
+ const successful = results.filter((r) => r.status === 'success').length;
3084
+ const failed = results.filter((r) => r.status === 'failed').length;
3085
+ this.logger.info({ successful, failed, total: results.length }, 'Cluster deployment complete');
3086
+ }
3087
+ });
925
3088
  // Connect to all slave daemons
926
3089
  for (const node of nodes) {
927
3090
  await connector.addSlave({
@@ -953,13 +3116,19 @@ export class ProjectService extends EventEmitter {
953
3116
  const port = pgConfig.port ?? 5432;
954
3117
  const user = pgConfig.user ?? 'postgres';
955
3118
  const password = typeof pgConfig.password === 'string' ? pgConfig.password : 'postgres';
956
- // Wait for postgres to actually accept connections before running ANY migrations.
957
- // Containers can be reported "running" by Docker before pg_isready would
958
- // pass, especially right after a fresh provision. Without this we hit
959
- // ECONNREFUSED on first migration and fail-fast bubbles into the user's
960
- // face. The probe is cheap (a short TCP connect + readiness query) and
961
- // bounded.
962
- await this.waitForPostgres('localhost', port, user, password, 60_000);
3119
+ // Wait for postgres to actually accept connections before running ANY
3120
+ // migrations. Containers can be reported "running" by Docker before
3121
+ // pg_isready would pass, especially right after a fresh provision. Without
3122
+ // this we hit ECONNREFUSED on the first migration and fail-fast bubbles
3123
+ // into the user's face.
3124
+ //
3125
+ // Three minutes, not one. A minute fits a container that already has its
3126
+ // data directory; it does not fit a cold one, which has to initdb and
3127
+ // install extensions before it listens — and on a loaded machine that is
3128
+ // several minutes. When the wait gives up the whole `stack start` aborts
3129
+ // and no app is started at all, so a deadline set for the warm case turns
3130
+ // a slow first boot into a stack that will not come up.
3131
+ await waitForPostgres('localhost', port, user, password, 180_000);
963
3132
  for (const [dbName, dbConfig] of Object.entries(pgConfig.databases)) {
964
3133
  // Only run if migrate flag is set AND a matching app exists
965
3134
  if (!dbConfig || typeof dbConfig !== 'object' || !dbConfig.migrate)
@@ -984,8 +3153,16 @@ export class ProjectService extends EventEmitter {
984
3153
  while (attempt < maxAttempts) {
985
3154
  attempt++;
986
3155
  try {
987
- const { execFileSync } = await import('node:child_process');
988
- execFileSync(process.execPath, ['--import', 'tsx/esm', migrateScript], {
3156
+ // Asynchronously. This was `execFileSync`, and a synchronous spawn
3157
+ // holds the daemon's whole event loop for as long as the child runs
3158
+ // — no RPC answered, no heartbeat, no mesh — which the event-loop
3159
+ // watch caught at a master boot on 2026-09-23: six migrations back
3160
+ // to back, 1 539 / 871 / 727 / 941 / 740 / 782 ms, 5.6 s of a daemon
3161
+ // that answered nothing, on every restart. And with the retries
3162
+ // below, a migration that hangs held it up to 60 s per attempt.
3163
+ const { execFile } = await import('node:child_process');
3164
+ const { promisify } = await import('node:util');
3165
+ const running = promisify(execFile)(process.execPath, ['--import', 'tsx/esm', migrateScript], {
989
3166
  cwd: path.join(projectPath, 'apps', dbName),
990
3167
  env: {
991
3168
  ...process.env,
@@ -998,8 +3175,15 @@ export class ProjectService extends EventEmitter {
998
3175
  [`${dbName.toUpperCase()}__DATABASE__PASSWORD`]: password,
999
3176
  },
1000
3177
  timeout: 60_000,
1001
- stdio: ['ignore', 'pipe', 'pipe'],
3178
+ maxBuffer: 16 * 1024 * 1024,
1002
3179
  });
3180
+ // Nothing to read from us, as `stdio: 'ignore'` said before.
3181
+ running.child.stdin?.end();
3182
+ const { stdout } = await running;
3183
+ // What the migrations said, in order — see `migrationOutput`.
3184
+ for (const line of migrationOutput(String(stdout))) {
3185
+ this.logger.info({ database: dbName, line }, 'Migration said');
3186
+ }
1003
3187
  this.logger.info({ database: dbName, attempts: attempt }, 'Migrations applied');
1004
3188
  lastErr = null;
1005
3189
  break;
@@ -1026,50 +3210,6 @@ export class ProjectService extends EventEmitter {
1026
3210
  }
1027
3211
  }
1028
3212
  }
1029
- /**
1030
- * Block until a postgres instance accepts connections, with bounded retries.
1031
- * Used before running migrations to avoid ECONNREFUSED on freshly-provisioned
1032
- * containers.
1033
- */
1034
- async waitForPostgres(host, port, user, password, timeoutMs) {
1035
- const deadline = Date.now() + timeoutMs;
1036
- const { Client } = await import('pg').catch(() => ({ Client: null }));
1037
- if (!Client) {
1038
- // pg not available in this build — fall back to a TCP connect probe.
1039
- const net = await import('node:net');
1040
- while (Date.now() < deadline) {
1041
- const ok = await new Promise((resolve) => {
1042
- const sock = net.createConnection({ host, port }, () => {
1043
- sock.end();
1044
- resolve(true);
1045
- });
1046
- sock.once('error', () => resolve(false));
1047
- sock.setTimeout(2_000, () => {
1048
- sock.destroy();
1049
- resolve(false);
1050
- });
1051
- });
1052
- if (ok)
1053
- return;
1054
- await new Promise((r) => setTimeout(r, 500));
1055
- }
1056
- throw new Error(`Postgres at ${host}:${port} not reachable after ${timeoutMs}ms`);
1057
- }
1058
- while (Date.now() < deadline) {
1059
- const client = new Client({ host, port, user, password, database: 'postgres', connectionTimeoutMillis: 2_000 });
1060
- try {
1061
- await client.connect();
1062
- await client.query('SELECT 1');
1063
- await client.end().catch(() => { });
1064
- return;
1065
- }
1066
- catch {
1067
- await client.end().catch(() => { });
1068
- await new Promise((r) => setTimeout(r, 500));
1069
- }
1070
- }
1071
- throw new Error(`Postgres at ${host}:${port} did not become ready within ${timeoutMs}ms`);
1072
- }
1073
3213
  /**
1074
3214
  * Resolve stacks from config + user stacks.
1075
3215
  * User-created stacks (from omnitron.stacks.json) are merged with config stacks.
@@ -1084,6 +3224,161 @@ export class ProjectService extends EventEmitter {
1084
3224
  }
1085
3225
  return configStacks;
1086
3226
  }
3227
+ /**
3228
+ * The environment a stack gives its apps, vault references resolved
3229
+ * (`project/stack-env.ts`). Throws — the start is refused — for a key the
3230
+ * vault does not hold, an `appEnv` for an app the stack does not run, or a
3231
+ * malformed entry. Values never leave this call except into the apps' env.
3232
+ */
3233
+ async stackEnvFor(projectName, stackName, stackConfig, apps) {
3234
+ const { resolveStackEnv } = await import('../project/stack-env.js');
3235
+ const secrets = this.secrets;
3236
+ return resolveStackEnv({
3237
+ settings: stackConfig.settings,
3238
+ apps,
3239
+ getSecret: secrets ? (key) => secrets.get(key) : undefined,
3240
+ where: `${projectName}/${stackName}`,
3241
+ });
3242
+ }
3243
+ /**
3244
+ * How each app of a local stack is started: the entry handed to the
3245
+ * orchestrator — named into its stack, its environment computed from the
3246
+ * stack's resolved configuration and then the project config's own.
3247
+ *
3248
+ * One builder for the start and for a re-read of the project
3249
+ * (`rereadProject`), so a running app is redefined exactly as it would be
3250
+ * started.
3251
+ */
3252
+ stackEntryBuilder(projectName, stackName, stackConfig, ecosystemConfig, appDefinitions, stackEnv) {
3253
+ const project = this.registry.get(projectName);
3254
+ const portAllocation = this.infraManager.getPortAllocation(projectName, stackName);
3255
+ const normalizedSvcs = this.infraManager.getNormalizedServices(projectName, stackName);
3256
+ const resolvedStack = resolveStack(ecosystemConfig, projectName, stackName, stackConfig, appDefinitions, portAllocation ?? undefined, normalizedSvcs ?? undefined);
3257
+ return (entry) => {
3258
+ const appConfig = resolvedStack.appConfigs.get(entry.name);
3259
+ const infraEnv = appConfig ? resolvedConfigToEnv(appConfig, entry.name, stackName) : {};
3260
+ return {
3261
+ ...entry,
3262
+ name: ProjectService.handleKey(projectName, stackName, entry.name),
3263
+ ...(entry.bootstrap && project
3264
+ ? { bootstrap: path.resolve(project.path, entry.bootstrap) }
3265
+ : {}),
3266
+ ...(project ? { cwd: project.path } : {}),
3267
+ env: {
3268
+ // Derived first, stated second. `infraEnv` is COMPUTED from the
3269
+ // stack's configuration; `entry.env` is WRITTEN — by a developer
3270
+ // in the project config, or, on a node, by the master that
3271
+ // provisioned the services and read their credentials back from
3272
+ // the machine they run on.
3273
+ //
3274
+ // The other order made the computed value win, and on a node the
3275
+ // computation has nothing to compute from: the generated config
3276
+ // carries no `infrastructure` block, so `resolveStackAddresses`
3277
+ // ends at its literal and overwrote a correct
3278
+ // `postgres://postgres:<43-char secret>@…` with
3279
+ // `postgres://postgres:postgres@…`. The apps then failed with
3280
+ // `password authentication failed for user "postgres" (28P01)`
3281
+ // against credentials that had been handed to them correctly and
3282
+ // thrown away one line later.
3283
+ //
3284
+ // It also meant an operator could not override a computed address
3285
+ // at all, which is not a thing a config system should refuse.
3286
+ // The stack's own (`settings.env`, then `settings.appEnv[app]`,
3287
+ // vault references resolved by `stackEnvFor`) still wins over both.
3288
+ ...infraEnv,
3289
+ ...entry.env,
3290
+ ...stackEnv[entry.name],
3291
+ OMNITRON_PROJECT: projectName,
3292
+ OMNITRON_STACK: stackName,
3293
+ OMNITRON_STACK_TYPE: 'local',
3294
+ },
3295
+ };
3296
+ };
3297
+ }
3298
+ /**
3299
+ * Every application in a stack, with what it declares it needs.
3300
+ *
3301
+ * Extracted because a remote stack needs exactly this and had no way to
3302
+ * get it: `startRemoteStack` built its own list of app ENTRIES and never
3303
+ * their definitions, so the `infrastructure` an application declares —
3304
+ * its chain daemon, its cache — was invisible on that path. The local
3305
+ * path had it all along, twenty lines up from where it was needed.
3306
+ */
3307
+ async loadAppDefinitions(projectName, stackConfig, ecosystemConfig) {
3308
+ const appDefinitions = new Map();
3309
+ const appEntries = this.resolveStackApps(stackConfig, ecosystemConfig);
3310
+ const project = this.registry.get(projectName);
3311
+ for (const entry of appEntries) {
3312
+ if (entry.bootstrap && project) {
3313
+ try {
3314
+ const bootstrapAbsPath = path.resolve(project.path, entry.bootstrap);
3315
+ const { loadBootstrapConfig } = await import('../orchestrator/bootstrap-loader.js');
3316
+ const loaded = await loadBootstrapConfig(bootstrapAbsPath, { devMode: false });
3317
+ // The app's `config/default.json`, read now and put on a COPY of
3318
+ // the definition: the loaded one is the loader's cache entry, and
3319
+ // writing to it made the first read the only one until the daemon
3320
+ // restarted (`project/declared-config.ts`).
3321
+ const declared = readDeclaredConfig(bootstrapAbsPath);
3322
+ if (declared.malformed) {
3323
+ this.logger.error({ app: entry.name, configPath: declared.configPath, error: declared.malformed }, 'config/default.json does not parse — its `omnitron` section is being ignored');
3324
+ }
3325
+ const definition = withDeclaredConfig(loaded, declared);
3326
+ appDefinitions.set(entry.name, definition);
3327
+ }
3328
+ catch (err) {
3329
+ // An app can start without its definition; a STACK cannot be
3330
+ // provisioned without it. The definition is where an application
3331
+ // declares what it needs — its database, its cache, its chain
3332
+ // daemon — so a swallowed failure here does not degrade the
3333
+ // deployment, it silently removes a whole category from it.
3334
+ //
3335
+ // Measured: a remote stack reported `declared by apps: (none)` and
3336
+ // provisioned no infrastructure, because every bootstrap import
3337
+ // had failed in a process that could not load TypeScript. Nothing
3338
+ // said so at any level.
3339
+ this.logger.warn({ app: entry.name, bootstrap: entry.bootstrap, error: err.message }, 'Could not load this app\'s definition — anything it declares it needs will not be provisioned');
3340
+ }
3341
+ }
3342
+ }
3343
+ return appDefinitions;
3344
+ }
3345
+ /**
3346
+ * The infrastructure a stack's applications declare, merged and overridden.
3347
+ *
3348
+ * One entry per service name: two applications that both declare
3349
+ * `postgres` mean one Postgres, which is what `serviceOverrides` keys on
3350
+ * and what the container resolver has always assumed. A later declaration
3351
+ * that disagrees with an earlier one is not merged silently — the first
3352
+ * wins and the disagreement is said, because two applications wanting
3353
+ * different versions of one service is a fact about the stack, not a
3354
+ * detail to resolve by ordering.
3355
+ */
3356
+ async collectDeclaredServices(projectName, stackConfig, ecosystemConfig,
3357
+ /** Keep the ones reached elsewhere too — to ask whether they answer, not to provision them. */
3358
+ options = {}) {
3359
+ const merged = {};
3360
+ const definitions = await this.loadAppDefinitions(projectName, stackConfig, ecosystemConfig);
3361
+ for (const [appName, definition] of definitions) {
3362
+ const declared = definition.omnitronConfig?.infrastructure;
3363
+ if (!declared)
3364
+ continue;
3365
+ for (const [name, requirement] of Object.entries(declared)) {
3366
+ const override = stackConfig.serviceOverrides?.[`${appName}/${name}`] ?? stackConfig.serviceOverrides?.[name];
3367
+ // Disabled, or pointed at something that already exists: there is
3368
+ // nothing to provision, and an address reaches the application
3369
+ // through its environment instead.
3370
+ const { provisioning } = bindService(requirement, override);
3371
+ if (provisioning === 'disabled' || (provisioning === 'external' && !options.external))
3372
+ continue;
3373
+ if (merged[name]) {
3374
+ this.logger.debug({ service: name, app: appName }, 'Service already declared by another app in this stack — the first declaration stands');
3375
+ continue;
3376
+ }
3377
+ merged[name] = requirement;
3378
+ }
3379
+ }
3380
+ return merged;
3381
+ }
1087
3382
  resolveStackApps(stackConfig, ecosystemConfig) {
1088
3383
  const allApps = ecosystemConfig.apps.filter((a) => a.enabled !== false);
1089
3384
  if (!stackConfig.apps || stackConfig.apps === 'all') {
@@ -1125,16 +3420,182 @@ export class ProjectService extends EventEmitter {
1125
3420
  totalStacks: Object.keys(stacks).length,
1126
3421
  };
1127
3422
  }
3423
+ /**
3424
+ * A remote stack's apps, as the NODES report them.
3425
+ *
3426
+ * `toStackInfo` reads this daemon's own orchestrator handles. For a remote
3427
+ * stack there are none — the applications run on the nodes, under the
3428
+ * node's own naming (`<project>/deployed/<app>`) — so every app came back
3429
+ * `stopped` and the report was:
3430
+ *
3431
+ * Stack sample/test: only 0/6 apps came online.
3432
+ * Not online: main (stopped), storage (stopped), quotes (stopped),
3433
+ * payments (stopped), messaging (stopped), geo (stopped)
3434
+ *
3435
+ * measured against a node answering `appsTotal: 6, appsOnline: 6` with
3436
+ * every port listening, thirty seconds after this same master installed,
3437
+ * migrated and started them. The CLI exits 1 on that count, so a correct
3438
+ * deployment fails a script; and the console's stack page shows the same
3439
+ * six rows, stopped.
3440
+ *
3441
+ * A node that cannot be asked leaves its apps exactly as they were, with a
3442
+ * line saying why. "We could not ask" is not "they are down" — and the
3443
+ * local rows are at least honest about being local.
3444
+ */
3445
+ async withRemoteAppStatuses(projectName, info) {
3446
+ return (await this.askNodes(projectName, info)).info;
3447
+ }
3448
+ /**
3449
+ * The same question, with the count of nodes that answered it.
3450
+ *
3451
+ * `connectedNodes` used to be read from the connector's registry, which
3452
+ * said 0 about a node that was answering RPCs at that moment — measured
3453
+ * 2026-09-22, three times in three seconds, beside a `stack status` that
3454
+ * listed six apps online from the very same node. A registry is
3455
+ * bookkeeping; an answer is a fact, and this is where the answers are.
3456
+ */
3457
+ async askNodes(projectName, info) {
3458
+ if (info.type !== 'remote' && info.type !== 'cluster')
3459
+ return { info, answered: 0 };
3460
+ const nodes = info.config.nodes ?? [];
3461
+ if (nodes.length === 0 || !this.slaveConnector)
3462
+ return { info, answered: 0 };
3463
+ const reported = new Map();
3464
+ let remoteInfra = null;
3465
+ let answered = 0;
3466
+ for (const node of nodes) {
3467
+ try {
3468
+ const status = (await this.slaveConnector.invokeOnSlave(node.host, node.port ?? 9700, 'OmnitronDaemon', 'status', []));
3469
+ answered += 1;
3470
+ // The containers are on the node too — `infraManager.getInstance`
3471
+ // answers about THIS machine, where a remote stack has none, so the
3472
+ // stack read "Infrastructure: not provisioned" beside a node running
3473
+ // postgres, redis, minio, the gateway and tor.
3474
+ remoteInfra ??= await this.remoteInfraStatus(projectName, info.name, node);
3475
+ for (const app of status?.apps ?? []) {
3476
+ // The node names them `<project>/deployed/<app>`; the stack knows
3477
+ // them by the app's own name.
3478
+ const bare = app.name.includes('/') ? app.name.slice(app.name.lastIndexOf('/') + 1) : app.name;
3479
+ reported.set(bare, app);
3480
+ }
3481
+ }
3482
+ catch (err) {
3483
+ this.logger.warn({ project: projectName, stack: info.name, node: node.host, error: err.message }, 'Could not ask this node what it is running — its apps are reported as this master sees them');
3484
+ }
3485
+ }
3486
+ if (answered === 0)
3487
+ return { info, answered };
3488
+ const apps = info.apps.map((app) => {
3489
+ const running = reported.get(app.name);
3490
+ if (!running)
3491
+ return app;
3492
+ return {
3493
+ ...app,
3494
+ handleKey: running.name,
3495
+ status: running.status,
3496
+ pid: running.pid,
3497
+ instances: running.instances,
3498
+ uptime: running.uptime,
3499
+ restarts: running.restarts,
3500
+ cpu: running.cpu,
3501
+ memory: running.memory,
3502
+ port: running.port ?? null,
3503
+ };
3504
+ });
3505
+ // The stack's own status is this master's memory of having started it,
3506
+ // and a daemon restart forgets it — so a stack whose six applications
3507
+ // are running on a node read `stopped` a minute after an upgrade. The
3508
+ // apps are the fact here too.
3509
+ const online = apps.filter((a) => a.status === 'online').length;
3510
+ const status = online === apps.length && apps.length > 0
3511
+ ? 'running'
3512
+ : online > 0
3513
+ ? 'degraded'
3514
+ : info.status;
3515
+ // And when the stack started is when its applications did: the
3516
+ // longest-running one a node reports online. An uptime is a duration the
3517
+ // node measured on its own clock, so taking it from this one needs no
3518
+ // agreement between the two clocks.
3519
+ const longest = apps.reduce((max, a) => (reported.has(a.name) && a.status === 'online' && a.uptime > max ? a.uptime : max), 0);
3520
+ return {
3521
+ info: {
3522
+ ...info,
3523
+ status,
3524
+ infrastructure: remoteInfra ?? info.infrastructure,
3525
+ apps,
3526
+ startedAt: longest > 0 ? new Date(Date.now() - longest).toISOString() : null,
3527
+ uptime: longest,
3528
+ },
3529
+ answered,
3530
+ };
3531
+ }
3532
+ /**
3533
+ * One node's infrastructure, named the way the stack names it.
3534
+ *
3535
+ * The node's container names carry ITS prefix (`sample-test-postgres`), and
3536
+ * a reader wants the service — `postgres` — so the same stripping the
3537
+ * local path does is done here. `null` when the node cannot answer or has
3538
+ * no infrastructure of its own, which leaves the master's view in place.
3539
+ */
3540
+ async remoteInfraStatus(projectName, stackName, node) {
3541
+ if (!this.slaveConnector)
3542
+ return null;
3543
+ try {
3544
+ const state = (await this.slaveConnector.invokeOnSlave(node.host, node.port ?? 9700, 'OmnitronInfra', 'getState', []));
3545
+ const prefix = `${projectName}-${stackName}-`;
3546
+ const services = {};
3547
+ // A node's infra state is IN MEMORY. After its daemon restarts it
3548
+ // answers `null` until a master provisions the stack again, while the
3549
+ // containers keep running — so the stack read "not provisioned" about
3550
+ // six healthy containers. The containers are the fact; the state is a
3551
+ // cache of it.
3552
+ const entries = state?.services
3553
+ ? Object.entries(state.services)
3554
+ : ((await this.slaveConnector.invokeOnSlave(node.host, node.port ?? 9700, 'OmnitronInfra', 'listContainers', [])) ?? [])
3555
+ .filter((c) => c.name.startsWith(prefix))
3556
+ .map((c) => [c.name, c]);
3557
+ if (entries.length === 0)
3558
+ return null;
3559
+ for (const [containerName, svc] of entries) {
3560
+ const name = containerName.startsWith(prefix) ? containerName.slice(prefix.length) : containerName;
3561
+ services[name] = {
3562
+ status: svc.status === 'running' ? 'running' : svc.error ? 'error' : 'stopped',
3563
+ containerName: svc.name,
3564
+ port: svc.ports ? Object.values(svc.ports)[0] ?? null : null,
3565
+ };
3566
+ }
3567
+ // Without the node's own verdict, "ready" is what the containers say:
3568
+ // every one of them running.
3569
+ const ready = state?.ready ?? Object.values(services).every((svc) => svc.status === 'running');
3570
+ return { ready, services };
3571
+ }
3572
+ catch (err) {
3573
+ this.logger.warn({ project: projectName, stack: stackName, node: node.host, error: err.message }, 'Could not ask this node about its infrastructure — the stack shows this master\'s view');
3574
+ return null;
3575
+ }
3576
+ }
1128
3577
  toStackInfo(projectName, stackName, config) {
1129
3578
  const stateKey = `${projectName}/${stackName}`;
1130
3579
  const state = this.stackStates.get(stateKey);
1131
3580
  const prefix = `${projectName}/${stackName}/`;
1132
- // Get apps running in this stack
1133
- const stackApps = this.orchestrator.list({ prefix });
1134
- const runningByName = new Map(stackApps.map((a) => [a.name.replace(prefix, ''), a]));
1135
- // Build app list from config — include both running and stopped apps
3581
+ // Build app list from config — include both running and stopped apps.
1136
3582
  const ecosystemConfig = this.getLoadedConfig(projectName);
1137
3583
  const configuredApps = ecosystemConfig ? this.resolveStackApps(config, ecosystemConfig) : [];
3584
+ // A bare handle carries no project/stack ownership. Assigning it by basename
3585
+ // admitted the same process into every project that configured "api". Only an
3586
+ // exact namespace proves ownership; ad-hoc bare processes remain visible in list.
3587
+ const runningByName = new Map();
3588
+ for (const a of this.orchestrator.list()) {
3589
+ const effective = effectiveAppName(a.name);
3590
+ const inThisPrefix = a.name.startsWith(prefix);
3591
+ if (!inThisPrefix)
3592
+ continue;
3593
+ // An exact qualified identity belongs to this stack only.
3594
+ const existing = runningByName.get(effective);
3595
+ if (!existing || a.name.startsWith(prefix)) {
3596
+ runningByName.set(effective, a);
3597
+ }
3598
+ }
1138
3599
  const apps = configuredApps.map((entry) => {
1139
3600
  const running = runningByName.get(entry.name);
1140
3601
  if (running) {
@@ -1146,6 +3607,10 @@ export class ProjectService extends EventEmitter {
1146
3607
  pid: running.pid,
1147
3608
  instances: running.instances,
1148
3609
  uptime: running.uptime,
3610
+ restarts: running.restarts,
3611
+ cpu: running.cpu,
3612
+ memory: running.memory,
3613
+ port: running.port ?? null,
1149
3614
  };
1150
3615
  }
1151
3616
  // App is configured but not running
@@ -1156,6 +3621,10 @@ export class ProjectService extends EventEmitter {
1156
3621
  pid: null,
1157
3622
  instances: 0,
1158
3623
  uptime: 0,
3624
+ restarts: 0,
3625
+ cpu: 0,
3626
+ memory: 0,
3627
+ port: null,
1159
3628
  };
1160
3629
  });
1161
3630
  // Include any running apps not in config (shouldn't happen, but be safe)
@@ -1167,6 +3636,10 @@ export class ProjectService extends EventEmitter {
1167
3636
  pid: running.pid,
1168
3637
  instances: running.instances,
1169
3638
  uptime: running.uptime,
3639
+ restarts: running.restarts,
3640
+ cpu: running.cpu,
3641
+ memory: running.memory,
3642
+ port: running.port ?? null,
1170
3643
  });
1171
3644
  }
1172
3645
  // Populate real node connectivity from SlaveConnector
@@ -1183,7 +3656,7 @@ export class ProjectService extends EventEmitter {
1183
3656
  daemonRole: n.role === 'master' ? 'master' : 'slave',
1184
3657
  connected,
1185
3658
  lastSeen: conn?.lastHeartbeat ?? null,
1186
- syncStatus: null,
3659
+ syncStatus: (conn?.syncStatus ?? null),
1187
3660
  };
1188
3661
  });
1189
3662
  // For local stacks, add implicit local node
@@ -1222,6 +3695,15 @@ export class ProjectService extends EventEmitter {
1222
3695
  ready: infraState?.ready ?? false,
1223
3696
  services: infraServices,
1224
3697
  };
3698
+ // A remote stack's applications run on its nodes, and when they started
3699
+ // is the nodes' to say (`askNodes`). What this master remembers is when
3700
+ // IT took charge — deployed the stack, or attached to it after its own
3701
+ // restart — and that was printed as the stack's start: «13:03:46», 25
3702
+ // minutes of uptime, on a stack whose six applications had been running
3703
+ // on the node since 12:20:48 (measured 2026-09-23, after a master
3704
+ // restart). It is reported as what it is.
3705
+ const onNodes = config.type === 'remote' || config.type === 'cluster';
3706
+ const since = state?.startedAt ?? null;
1225
3707
  return {
1226
3708
  name: stackName,
1227
3709
  type: config.type,
@@ -1231,9 +3713,36 @@ export class ProjectService extends EventEmitter {
1231
3713
  apps,
1232
3714
  infrastructure,
1233
3715
  portRange: config.portRange ?? null,
1234
- startedAt: state?.startedAt ? new Date(state.startedAt).toISOString() : null,
1235
- uptime: state?.startedAt ? Date.now() - state.startedAt : 0,
3716
+ startedAt: !onNodes && since ? new Date(since).toISOString() : null,
3717
+ uptime: !onNodes && since ? Date.now() - since : 0,
3718
+ attachedAt: onNodes && since ? new Date(since).toISOString() : null,
1236
3719
  };
1237
3720
  }
1238
3721
  }
3722
+ /**
3723
+ * How a master's daemon builds this service: the tokens it resolves, in
3724
+ * order, and the factory that receives them.
3725
+ *
3726
+ * Here and not inline in daemon.module.ts so that the ORDER can be held to
3727
+ * account: `inject` and the factory's parameters are two lists that must
3728
+ * agree position by position, and the container hands the values over as
3729
+ * `any`. Swapped there, the vault arrives as the audit trail and neither
3730
+ * complains until a deployment needs one of them. See
3731
+ * `test/unit/a-factory-that-passed-its-arguments-by-position.test.ts`.
3732
+ */
3733
+ export const MASTER_PROJECT_SERVICE_PROVIDER = {
3734
+ inject: [LOGGER_SERVICE_TOKEN, ORCHESTRATOR_TOKEN, DAEMON_STATE_STORE_TOKEN, SECRETS_SERVICE_TOKEN, AUDIT_SERVICE_TOKEN],
3735
+ useFactory: (loggerModule, orchestrator, daemonStateStore, secrets, audit) =>
3736
+ // `syncService` has never been passed here; `secrets` is what a stack's
3737
+ // service overrides name their credentials with, and without it a
3738
+ // deployed app is handed `<secret:…>` as a password. `audit` is here
3739
+ // because two of the three callers of `startStack` never pass through the
3740
+ // RPC layer that used to record it.
3741
+ new ProjectService(loggerModule.logger, orchestrator, daemonStateStore, { secrets, audit }),
3742
+ };
3743
+ /** A slave has its own encrypted vault even though it has no master database/audit service. */
3744
+ export const SLAVE_PROJECT_SERVICE_PROVIDER = {
3745
+ inject: [LOGGER_SERVICE_TOKEN, ORCHESTRATOR_TOKEN, DAEMON_STATE_STORE_TOKEN, SECRETS_SERVICE_TOKEN],
3746
+ useFactory: (loggerModule, orchestrator, daemonStateStore, secrets) => new ProjectService(loggerModule.logger, orchestrator, daemonStateStore, { secrets }),
3747
+ };
1239
3748
  //# sourceMappingURL=project.service.js.map