@omnitron-dev/omnitron 0.1.3 → 0.3.0

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