@serfab/cadre-host 1.13.0 → 1.15.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 (352) hide show
  1. package/README.md +347 -423
  2. package/dist/bin/host.d.ts +6 -3
  3. package/dist/bin/host.js +332 -524
  4. package/dist/bin/host.js.map +1 -1
  5. package/dist/bin/join-output.d.ts +56 -0
  6. package/dist/bin/join-output.js +92 -0
  7. package/dist/bin/join-output.js.map +1 -0
  8. package/dist/bin/nat-output.d.ts +54 -0
  9. package/dist/bin/nat-output.js +138 -0
  10. package/dist/bin/nat-output.js.map +1 -0
  11. package/dist/bin/ui-access.d.ts +22 -0
  12. package/dist/bin/ui-access.js +44 -0
  13. package/dist/bin/ui-access.js.map +1 -0
  14. package/dist/hosted/claim-details.d.ts +66 -0
  15. package/dist/hosted/claim-details.js +60 -0
  16. package/dist/hosted/claim-details.js.map +1 -0
  17. package/dist/hosted/hosted-node-service.d.ts +325 -0
  18. package/dist/hosted/hosted-node-service.js +678 -0
  19. package/dist/hosted/hosted-node-service.js.map +1 -0
  20. package/dist/hosted/hosted-node-store.d.ts +40 -0
  21. package/dist/hosted/hosted-node-store.js +126 -0
  22. package/dist/hosted/hosted-node-store.js.map +1 -0
  23. package/dist/{donation/donation-supervisor.d.ts → hosted/hosted-node-supervisor.d.ts} +70 -53
  24. package/dist/hosted/hosted-node-supervisor.js +396 -0
  25. package/dist/hosted/hosted-node-supervisor.js.map +1 -0
  26. package/dist/hosted/hosted-node-watcher.d.ts +95 -0
  27. package/dist/hosted/hosted-node-watcher.js +260 -0
  28. package/dist/hosted/hosted-node-watcher.js.map +1 -0
  29. package/dist/hosted/index.d.ts +16 -0
  30. package/dist/hosted/index.js +13 -0
  31. package/dist/hosted/index.js.map +1 -0
  32. package/dist/hosted/node-status.d.ts +51 -0
  33. package/dist/hosted/node-status.js +77 -0
  34. package/dist/hosted/node-status.js.map +1 -0
  35. package/dist/hosted/types.d.ts +141 -0
  36. package/dist/hosted/types.js +22 -0
  37. package/dist/hosted/types.js.map +1 -0
  38. package/dist/index.d.ts +12 -24
  39. package/dist/index.js +9 -19
  40. package/dist/index.js.map +1 -1
  41. package/dist/installer/config.d.ts +27 -34
  42. package/dist/installer/config.js +46 -15
  43. package/dist/installer/config.js.map +1 -1
  44. package/dist/installer/index.d.ts +9 -19
  45. package/dist/installer/index.js +21 -85
  46. package/dist/installer/index.js.map +1 -1
  47. package/dist/installer/paths.d.ts +0 -2
  48. package/dist/installer/paths.js +0 -4
  49. package/dist/installer/paths.js.map +1 -1
  50. package/dist/installer/wizard.d.ts +12 -9
  51. package/dist/installer/wizard.js +15 -14
  52. package/dist/installer/wizard.js.map +1 -1
  53. package/dist/nat/address-resolver.d.ts +18 -17
  54. package/dist/nat/address-resolver.js +79 -25
  55. package/dist/nat/address-resolver.js.map +1 -1
  56. package/dist/nat/address-watch.d.ts +37 -0
  57. package/dist/nat/address-watch.js +84 -0
  58. package/dist/nat/address-watch.js.map +1 -0
  59. package/dist/nat/ddns/duckdns.d.ts +2 -1
  60. package/dist/nat/ddns/duckdns.js +5 -1
  61. package/dist/nat/ddns/duckdns.js.map +1 -1
  62. package/dist/nat/ddns/index.d.ts +6 -0
  63. package/dist/nat/ddns/index.js.map +1 -1
  64. package/dist/nat/index.d.ts +13 -9
  65. package/dist/nat/index.js +7 -5
  66. package/dist/nat/index.js.map +1 -1
  67. package/dist/nat/nat-service.d.ts +220 -77
  68. package/dist/nat/nat-service.js +653 -239
  69. package/dist/nat/nat-service.js.map +1 -1
  70. package/dist/nat/nat-store.d.ts +12 -5
  71. package/dist/nat/nat-store.js +124 -29
  72. package/dist/nat/nat-store.js.map +1 -1
  73. package/dist/nat/network-mode.d.ts +19 -0
  74. package/dist/nat/network-mode.js +32 -0
  75. package/dist/nat/network-mode.js.map +1 -0
  76. package/dist/nat/port-mapper.d.ts +83 -79
  77. package/dist/nat/port-mapper.js +148 -189
  78. package/dist/nat/port-mapper.js.map +1 -1
  79. package/dist/nat/reachability.d.ts +32 -15
  80. package/dist/nat/reachability.js +110 -22
  81. package/dist/nat/reachability.js.map +1 -1
  82. package/dist/nat/types.d.ts +98 -27
  83. package/dist/nat/types.js +8 -5
  84. package/dist/nat/types.js.map +1 -1
  85. package/dist/orchestrator/host-process-orchestrator.d.ts +32 -74
  86. package/dist/orchestrator/host-process-orchestrator.js +84 -233
  87. package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
  88. package/dist/{installer/identity.d.ts → orchestrator/identity-file.d.ts} +5 -5
  89. package/dist/{installer/identity.js → orchestrator/identity-file.js} +6 -6
  90. package/dist/orchestrator/identity-file.js.map +1 -0
  91. package/dist/orchestrator/index.d.ts +2 -3
  92. package/dist/orchestrator/index.js +1 -1
  93. package/dist/orchestrator/index.js.map +1 -1
  94. package/dist/orchestrator/node-identity.d.ts +6 -6
  95. package/dist/orchestrator/node-identity.js +7 -7
  96. package/dist/orchestrator/node-identity.js.map +1 -1
  97. package/dist/orchestrator/port-allocator.d.ts +5 -6
  98. package/dist/orchestrator/port-allocator.js +9 -11
  99. package/dist/orchestrator/port-allocator.js.map +1 -1
  100. package/dist/orchestrator/state-store.d.ts +3 -5
  101. package/dist/orchestrator/state-store.js +2 -10
  102. package/dist/orchestrator/state-store.js.map +1 -1
  103. package/dist/orchestrator/types.d.ts +33 -22
  104. package/dist/orchestrator/types.js.map +1 -1
  105. package/dist/package-version.d.ts +1 -0
  106. package/dist/package-version.js +4 -0
  107. package/dist/package-version.js.map +1 -0
  108. package/dist/server/error-handler.d.ts +5 -25
  109. package/dist/server/error-handler.js +11 -74
  110. package/dist/server/error-handler.js.map +1 -1
  111. package/dist/server/events/types.d.ts +7 -11
  112. package/dist/server/events/types.js +2 -1
  113. package/dist/server/events/types.js.map +1 -1
  114. package/dist/server/index.d.ts +11 -37
  115. package/dist/server/index.js +30 -48
  116. package/dist/server/index.js.map +1 -1
  117. package/dist/server/routes/hosted-nodes.d.ts +28 -0
  118. package/dist/server/routes/hosted-nodes.js +68 -0
  119. package/dist/server/routes/hosted-nodes.js.map +1 -0
  120. package/dist/server/routes/nat.d.ts +4 -1
  121. package/dist/server/routes/nat.js +13 -2
  122. package/dist/server/routes/nat.js.map +1 -1
  123. package/dist/server/routes/nodes.d.ts +6 -20
  124. package/dist/server/routes/nodes.js +7 -86
  125. package/dist/server/routes/nodes.js.map +1 -1
  126. package/dist/server/routes/settings.d.ts +3 -7
  127. package/dist/server/routes/settings.js +18 -8
  128. package/dist/server/routes/settings.js.map +1 -1
  129. package/dist/server/routes/status.d.ts +2 -25
  130. package/dist/server/routes/status.js +3 -43
  131. package/dist/server/routes/status.js.map +1 -1
  132. package/dist/server/server.js +8 -8
  133. package/dist/server/settings-store.d.ts +2 -2
  134. package/dist/server/settings-store.js +2 -2
  135. package/dist/server/static.js +1 -2
  136. package/dist/server/static.js.map +1 -1
  137. package/dist/ui/assets/index-BHd8CWZA.css +1 -0
  138. package/dist/ui/assets/index-Ccr2GjPT.js +23 -0
  139. package/dist/ui/assets/index-Ccr2GjPT.js.map +1 -0
  140. package/dist/ui/index.html +2 -2
  141. package/dist/update/index.d.ts +2 -2
  142. package/dist/update/index.js +2 -2
  143. package/dist/update/store.d.ts +1 -1
  144. package/dist/update/store.js +1 -1
  145. package/dist/update/types.d.ts +1 -1
  146. package/dist/update/types.js +1 -1
  147. package/package.json +5 -7
  148. package/src/__tests__/cli-nat.smoke.test.ts +219 -212
  149. package/src/__tests__/cli.smoke.test.ts +2 -2
  150. package/src/__tests__/global-setup.ts +2 -3
  151. package/src/__tests__/orchestrator-claim-secret.test.ts +111 -0
  152. package/src/__tests__/orchestrator-env-scrub.test.ts +160 -163
  153. package/src/__tests__/orchestrator-handle-contract.ts +134 -134
  154. package/src/__tests__/orchestrator-node-identity.test.ts +177 -203
  155. package/src/__tests__/orchestrator-ports.test.ts +124 -143
  156. package/src/__tests__/orchestrator-push.test.ts +138 -146
  157. package/src/__tests__/orchestrator.test.ts +1017 -1063
  158. package/src/__tests__/ui-access.test.ts +30 -0
  159. package/src/bin/host.ts +397 -572
  160. package/src/bin/join-output.ts +120 -0
  161. package/src/bin/nat-output.ts +188 -0
  162. package/src/bin/ui-access.ts +50 -0
  163. package/src/{donation → hosted}/__tests__/fake-orchestrator.test.ts +172 -173
  164. package/src/{donation → hosted}/__tests__/fake-orchestrator.ts +43 -23
  165. package/src/hosted/__tests__/hosted-node-service.test.ts +708 -0
  166. package/src/hosted/__tests__/hosted-node-supervisor.test.ts +475 -0
  167. package/src/hosted/__tests__/test-invitation.ts +33 -0
  168. package/src/hosted/claim-details.ts +103 -0
  169. package/src/hosted/hosted-node-service.ts +814 -0
  170. package/src/hosted/hosted-node-store.ts +144 -0
  171. package/src/hosted/hosted-node-supervisor.ts +439 -0
  172. package/src/hosted/hosted-node-watcher.ts +288 -0
  173. package/src/hosted/index.ts +53 -0
  174. package/src/hosted/node-status.ts +97 -0
  175. package/src/hosted/types.ts +160 -0
  176. package/src/index.ts +72 -91
  177. package/src/installer/__tests__/config.test.ts +15 -40
  178. package/src/installer/__tests__/installer.smoke.test.ts +184 -263
  179. package/src/installer/__tests__/wizard.test.ts +25 -13
  180. package/src/installer/config.ts +201 -175
  181. package/src/installer/index.ts +252 -327
  182. package/src/installer/paths.ts +104 -109
  183. package/src/installer/wizard.ts +24 -26
  184. package/src/nat/__tests__/address-resolver.test.ts +107 -82
  185. package/src/nat/__tests__/nat-service.test.ts +608 -515
  186. package/src/nat/__tests__/nat-store.test.ts +49 -12
  187. package/src/nat/__tests__/network-mode.test.ts +38 -0
  188. package/src/nat/__tests__/port-mapper.test.ts +30 -191
  189. package/src/nat/__tests__/reachability.test.ts +96 -13
  190. package/src/nat/address-resolver.ts +94 -52
  191. package/src/nat/address-watch.ts +92 -0
  192. package/src/nat/ddns/duckdns.ts +5 -1
  193. package/src/nat/ddns/index.ts +6 -0
  194. package/src/nat/index.ts +33 -13
  195. package/src/nat/nat-service.ts +942 -501
  196. package/src/nat/nat-store.ts +126 -31
  197. package/src/nat/network-mode.ts +36 -0
  198. package/src/nat/port-mapper.ts +241 -329
  199. package/src/nat/reachability.ts +155 -34
  200. package/src/nat/types.ts +110 -37
  201. package/src/{installer/__tests__/identity.test.ts → orchestrator/__tests__/identity-file.test.ts} +34 -34
  202. package/src/orchestrator/__tests__/node-identity.test.ts +62 -62
  203. package/src/orchestrator/host-process-orchestrator.ts +1089 -1260
  204. package/src/{installer/identity.ts → orchestrator/identity-file.ts} +56 -56
  205. package/src/orchestrator/index.ts +9 -10
  206. package/src/orchestrator/node-identity.ts +41 -41
  207. package/src/orchestrator/port-allocator.ts +71 -73
  208. package/src/orchestrator/state-store.ts +76 -90
  209. package/src/orchestrator/types.ts +34 -23
  210. package/src/package-version.ts +4 -0
  211. package/src/server/__tests__/bus.test.ts +38 -38
  212. package/src/server/__tests__/error-handler.test.ts +81 -118
  213. package/src/server/__tests__/fakes.ts +15 -18
  214. package/src/server/__tests__/hosted-nodes-route.test.ts +161 -0
  215. package/src/server/__tests__/nodes-route.test.ts +114 -283
  216. package/src/server/__tests__/publishers.test.ts +193 -180
  217. package/src/server/__tests__/server.smoke.test.ts +134 -223
  218. package/src/server/__tests__/settings-route.test.ts +210 -195
  219. package/src/server/__tests__/sse-route.test.ts +119 -119
  220. package/src/server/__tests__/status-route.test.ts +82 -121
  221. package/src/server/error-handler.ts +112 -178
  222. package/src/server/events/types.ts +20 -24
  223. package/src/server/index.ts +223 -267
  224. package/src/server/routes/hosted-nodes.ts +83 -0
  225. package/src/server/routes/nat.ts +75 -63
  226. package/src/server/routes/nodes.ts +96 -188
  227. package/src/server/routes/settings.ts +20 -15
  228. package/src/server/routes/status.ts +5 -67
  229. package/src/server/server.ts +8 -8
  230. package/src/server/settings-store.ts +2 -2
  231. package/src/server/static.ts +1 -2
  232. package/src/update/index.ts +2 -2
  233. package/src/update/store.ts +1 -1
  234. package/src/update/types.ts +1 -1
  235. package/dist/auth/duration.d.ts +0 -1
  236. package/dist/auth/duration.js +0 -32
  237. package/dist/auth/duration.js.map +0 -1
  238. package/dist/auth/index.d.ts +0 -11
  239. package/dist/auth/index.js +0 -9
  240. package/dist/auth/index.js.map +0 -1
  241. package/dist/auth/self-label.d.ts +0 -36
  242. package/dist/auth/self-label.js +0 -57
  243. package/dist/auth/self-label.js.map +0 -1
  244. package/dist/auth/trust-circle-store.d.ts +0 -26
  245. package/dist/auth/trust-circle-store.js +0 -129
  246. package/dist/auth/trust-circle-store.js.map +0 -1
  247. package/dist/auth/trust-circle.d.ts +0 -156
  248. package/dist/auth/trust-circle.js +0 -307
  249. package/dist/auth/trust-circle.js.map +0 -1
  250. package/dist/auth/types.d.ts +0 -84
  251. package/dist/auth/types.js +0 -24
  252. package/dist/auth/types.js.map +0 -1
  253. package/dist/donation/donation-service.d.ts +0 -389
  254. package/dist/donation/donation-service.js +0 -828
  255. package/dist/donation/donation-service.js.map +0 -1
  256. package/dist/donation/donation-store.d.ts +0 -51
  257. package/dist/donation/donation-store.js +0 -152
  258. package/dist/donation/donation-store.js.map +0 -1
  259. package/dist/donation/donation-supervisor.js +0 -364
  260. package/dist/donation/donation-supervisor.js.map +0 -1
  261. package/dist/donation/grant-service.d.ts +0 -80
  262. package/dist/donation/grant-service.js +0 -210
  263. package/dist/donation/grant-service.js.map +0 -1
  264. package/dist/donation/grant-store.d.ts +0 -38
  265. package/dist/donation/grant-store.js +0 -135
  266. package/dist/donation/grant-store.js.map +0 -1
  267. package/dist/donation/index.d.ts +0 -20
  268. package/dist/donation/index.js +0 -17
  269. package/dist/donation/index.js.map +0 -1
  270. package/dist/donation/types.d.ts +0 -224
  271. package/dist/donation/types.js +0 -40
  272. package/dist/donation/types.js.map +0 -1
  273. package/dist/installer/identity.js.map +0 -1
  274. package/dist/owner/index.d.ts +0 -11
  275. package/dist/owner/index.js +0 -11
  276. package/dist/owner/index.js.map +0 -1
  277. package/dist/owner/owner-node-client.d.ts +0 -110
  278. package/dist/owner/owner-node-client.js +0 -184
  279. package/dist/owner/owner-node-client.js.map +0 -1
  280. package/dist/server/routes/bootstrap-node-validation.d.ts +0 -115
  281. package/dist/server/routes/bootstrap-node-validation.js +0 -197
  282. package/dist/server/routes/bootstrap-node-validation.js.map +0 -1
  283. package/dist/server/routes/grants-admin.d.ts +0 -29
  284. package/dist/server/routes/grants-admin.js +0 -58
  285. package/dist/server/routes/grants-admin.js.map +0 -1
  286. package/dist/server/routes/grants.d.ts +0 -28
  287. package/dist/server/routes/grants.js +0 -165
  288. package/dist/server/routes/grants.js.map +0 -1
  289. package/dist/server/routes/provision-request-validation.d.ts +0 -45
  290. package/dist/server/routes/provision-request-validation.js +0 -97
  291. package/dist/server/routes/provision-request-validation.js.map +0 -1
  292. package/dist/server/routes/strands.d.ts +0 -20
  293. package/dist/server/routes/strands.js +0 -38
  294. package/dist/server/routes/strands.js.map +0 -1
  295. package/dist/server/routes/trust-circle.d.ts +0 -17
  296. package/dist/server/routes/trust-circle.js +0 -35
  297. package/dist/server/routes/trust-circle.js.map +0 -1
  298. package/dist/strands/index.d.ts +0 -7
  299. package/dist/strands/index.js +0 -6
  300. package/dist/strands/index.js.map +0 -1
  301. package/dist/strands/strand-service.d.ts +0 -50
  302. package/dist/strands/strand-service.js +0 -103
  303. package/dist/strands/strand-service.js.map +0 -1
  304. package/dist/strands/types.d.ts +0 -93
  305. package/dist/strands/types.js +0 -23
  306. package/dist/strands/types.js.map +0 -1
  307. package/dist/ui/assets/index-C6nELNfC.js +0 -23
  308. package/dist/ui/assets/index-C6nELNfC.js.map +0 -1
  309. package/dist/ui/assets/index-TtIYSeZO.css +0 -1
  310. package/src/__tests__/cli-invite.smoke.test.ts +0 -192
  311. package/src/__tests__/host-owner.smoke.test.ts +0 -123
  312. package/src/__tests__/orchestrator-owner.test.ts +0 -234
  313. package/src/__tests__/orchestrator-pin-keys.test.ts +0 -152
  314. package/src/auth/__tests__/duration.test.ts +0 -28
  315. package/src/auth/__tests__/self-label.test.ts +0 -85
  316. package/src/auth/__tests__/trust-circle-integration.test.ts +0 -152
  317. package/src/auth/__tests__/trust-circle-store.test.ts +0 -115
  318. package/src/auth/__tests__/trust-circle.test.ts +0 -445
  319. package/src/auth/duration.ts +0 -33
  320. package/src/auth/index.ts +0 -21
  321. package/src/auth/self-label.ts +0 -74
  322. package/src/auth/trust-circle-store.ts +0 -155
  323. package/src/auth/trust-circle.ts +0 -395
  324. package/src/auth/types.ts +0 -99
  325. package/src/donation/__tests__/donation-service.test.ts +0 -1193
  326. package/src/donation/__tests__/donation-supervisor.test.ts +0 -548
  327. package/src/donation/__tests__/grant-service.test.ts +0 -213
  328. package/src/donation/__tests__/grant-store.test.ts +0 -113
  329. package/src/donation/donation-service.ts +0 -1033
  330. package/src/donation/donation-store.ts +0 -176
  331. package/src/donation/donation-supervisor.ts +0 -411
  332. package/src/donation/grant-service.ts +0 -252
  333. package/src/donation/grant-store.ts +0 -152
  334. package/src/donation/index.ts +0 -65
  335. package/src/donation/types.ts +0 -260
  336. package/src/owner/__tests__/owner-node-client.test.ts +0 -194
  337. package/src/owner/index.ts +0 -12
  338. package/src/owner/owner-node-client.ts +0 -259
  339. package/src/server/__tests__/bootstrap-node-validation.test.ts +0 -138
  340. package/src/server/__tests__/grants-admin-route.test.ts +0 -171
  341. package/src/server/__tests__/grants-route.test.ts +0 -394
  342. package/src/server/__tests__/strands-route.test.ts +0 -234
  343. package/src/server/routes/bootstrap-node-validation.ts +0 -206
  344. package/src/server/routes/grants-admin.ts +0 -74
  345. package/src/server/routes/grants.ts +0 -193
  346. package/src/server/routes/provision-request-validation.ts +0 -105
  347. package/src/server/routes/strands.ts +0 -54
  348. package/src/server/routes/trust-circle.ts +0 -49
  349. package/src/strands/__tests__/strand-service.test.ts +0 -165
  350. package/src/strands/index.ts +0 -14
  351. package/src/strands/strand-service.ts +0 -135
  352. package/src/strands/types.ts +0 -102
@@ -0,0 +1,678 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import debug from 'debug';
3
+ import { CLAIM_SECRET_BYTES, decodeCadreInvitation } from '@serfab/cadre-core';
4
+ import { primaryLanAddress } from '@serfab/cadre-core/primary-lan-address';
5
+ import { buildClaimDetails, claimLanAddress } from './claim-details.js';
6
+ import { HostedNodeWatcher } from './hosted-node-watcher.js';
7
+ import { PLACEHOLDER_PARTY, errorMessage, readNodeStatus, statusUrlFor } from './node-status.js';
8
+ import { HostedNodeError } from './types.js';
9
+ const log = debug('cadre:host:hosted-node-service');
10
+ /**
11
+ * The only statuses a node may come back from. An allowlist, not a terminal
12
+ * denylist: `error` is a node the host gave up on, and `spawning` is a join still
13
+ * in flight — replaying its spawn would race that join and strand the record in
14
+ * `spawning`, which only the stuck-`spawning` reap collects.
15
+ *
16
+ * Checked twice in {@link HostedNodeService.respawn} — once on entry, once after
17
+ * the spawn — so the "may this node come back" rule is stated exactly once.
18
+ */
19
+ const RESPAWNABLE_STATUSES = new Set([
20
+ 'unclaimed',
21
+ 'joining',
22
+ 'joined',
23
+ ]);
24
+ /**
25
+ * Age after which a record still stuck in `spawning` is marked `error` and its
26
+ * child, if any, reclaimed: the host wrote the row before starting the child, and
27
+ * a crash/kill in that window (or during the spawn itself) leaves nothing to
28
+ * advance it across a restart. A real spawn (identity key read/gen, port
29
+ * allocation, process spawn) completes in well under a second normally, so 5
30
+ * minutes is generously past "any plausible spawn" without risking a false reap
31
+ * of one still genuinely in flight under heavy load.
32
+ */
33
+ export const HOSTED_NODE_SPAWNING_TTL_MS = 5 * 60 * 1000;
34
+ /** How often the stuck-`spawning` reap runs while cadre-host is up. 5 minutes. */
35
+ export const HOSTED_NODE_REAP_SWEEP_MS = 5 * 60 * 1000;
36
+ /**
37
+ * HostedNodeService — the host's one action, "Join a cadre", and what follows from it.
38
+ *
39
+ * `join` starts a child waiting to be claimed, or one that redeems a cadre invitation;
40
+ * `claimDetails` reads a waiting node's addresses and builds the QR payload; the status
41
+ * watcher ({@link startWatching}) notices the claim or the redemption's outcome, and the
42
+ * node's liveness; `respawn` brings a dead child back as the same node with the same
43
+ * secret and ports; `retry` starts an invitation node again after no member could be
44
+ * reached; `remove` and `reset` end it. The host contributes capacity only: it never
45
+ * holds an owner key.
46
+ *
47
+ * **An ending that lands mid-operation wins.** Spawning a child takes seconds, and
48
+ * a `remove` can land inside that window. `hosted-nodes.json` is written a whole row
49
+ * at a time, so every long operation re-reads the record after its wait, decides
50
+ * against what is actually stored, and merges forward only the fields it itself
51
+ * produced — never the entry-time copy.
52
+ *
53
+ * **Respawns run one at a time** ({@link serializeRespawns}): the supervisor's passes and
54
+ * restarts and `retry` share one queue, since two overlapping respawns of one id both spawn
55
+ * a child and the second drops the first's orchestrator handle.
56
+ */
57
+ export class HostedNodeService {
58
+ constructor(opts) {
59
+ this.listeners = new Set();
60
+ /** Serialization tail for `join`, so two joins at once cannot interleave their store writes. */
61
+ this.joinTail = Promise.resolve();
62
+ /** Serialization tail for every respawn — see {@link serializeRespawns}. */
63
+ this.respawnTail = Promise.resolve();
64
+ this.orchestrator = opts.orchestrator;
65
+ this.store = opts.store;
66
+ this.addresses = opts.addresses;
67
+ this.claimAddressSettings = opts.claimAddressSettings ?? (() => undefined);
68
+ this.primaryLan = opts.primaryLan ?? primaryLanAddress;
69
+ this.now = opts.now ?? (() => new Date());
70
+ this.watcher = new HostedNodeWatcher({
71
+ store: this.store,
72
+ orchestrator: this.orchestrator,
73
+ now: this.now,
74
+ emit: (change) => this.emit(change),
75
+ });
76
+ log('HostedNodeService initialized');
77
+ }
78
+ /** Register a change listener (the server publishes each change as an SSE event). Returns an unsubscribe fn. */
79
+ onChange(listener) {
80
+ this.listeners.add(listener);
81
+ return () => { this.listeners.delete(listener); };
82
+ }
83
+ /**
84
+ * Report a change to the listeners. Public for the supervisor's give-up, which
85
+ * writes the record itself; a listener that throws is logged, never propagated.
86
+ */
87
+ emit(change) {
88
+ for (const listener of this.listeners) {
89
+ try {
90
+ listener(change);
91
+ }
92
+ catch (err) {
93
+ log('change listener threw for %s: %s', change.id, errorMessage(err));
94
+ }
95
+ }
96
+ }
97
+ /** Start following every node's `/status` (claims and liveness). Idempotent. */
98
+ startWatching() {
99
+ this.watcher.start();
100
+ }
101
+ stopWatching() {
102
+ this.watcher.stop();
103
+ }
104
+ /** One status pass now, awaited — for tests and callers that need the result before going on. */
105
+ pollStatuses() {
106
+ return this.watcher.poll();
107
+ }
108
+ /**
109
+ * Start a node: write the record, spawn the child, and record its handles. Without an
110
+ * invitation the node waits to be claimed — a fresh claim secret, the placeholder
111
+ * party, `unclaimed`. With one it redeems the invitation — the invitation's party,
112
+ * `joining` — and an invitation that does not decode is `invalid_request` before
113
+ * anything is written. On any orchestrator failure the reserved resources are
114
+ * reclaimed and the record is marked `error`. Resolves once the child is spawned; its
115
+ * `/status` may take a few seconds more, which `claimDetails` reports as
116
+ * `node_unavailable`.
117
+ */
118
+ async join(opts = {}) {
119
+ const invitation = opts.invitation === undefined ? undefined : decodeInvitation(opts.invitation);
120
+ return this.serializeJoin(() => this.joinLocked(invitation));
121
+ }
122
+ async joinLocked(invitation) {
123
+ const id = generateHostedNodeId();
124
+ const nowIso = this.now().toISOString();
125
+ const join = invitation
126
+ ? { kind: 'invitation', encoded: invitation.encoded }
127
+ : { kind: 'claim', secret: generateClaimSecret() };
128
+ const record = {
129
+ id,
130
+ join,
131
+ partyId: invitation ? invitation.decoded.partyId : PLACEHOLDER_PARTY,
132
+ profile: 'storage',
133
+ status: 'spawning',
134
+ createdAt: nowIso,
135
+ updatedAt: nowIso,
136
+ };
137
+ this.store.put(record);
138
+ // The spawn is the only step that can fail before anything is allocated, so
139
+ // it gets a `try` of its own — a wider one would swallow the abandon decision
140
+ // below and mark a removal as a host-side `error`.
141
+ let result;
142
+ try {
143
+ result = await this.orchestrator.createContainer(spawnRequestFor(record));
144
+ }
145
+ catch (err) {
146
+ const message = errorMessage(err);
147
+ log('spawn of hosted node %s failed: %s', id, message);
148
+ this.markSpawnFailed(id, message);
149
+ throw new HostedNodeError('orchestrator_error', `Failed to start hosted node: ${message}`);
150
+ }
151
+ // Re-read: `record` predates the orchestrator round-trip (seconds of wall
152
+ // clock) and `store.put` replaces the whole row, so writing that copy back
153
+ // would resurrect a node removed while the spawn was in flight. The store is
154
+ // synchronous, so this read-decide-write is atomic against the event loop
155
+ // only as long as no `await` sneaks in between.
156
+ const current = this.store.get(id);
157
+ if (!current || current.status !== 'spawning') {
158
+ // The removal cleaned up nothing: the record named no `dockerId` yet, so
159
+ // `remove`'s stop-and-reclaim never ran and this child is the only thing
160
+ // holding the spawn's ports and workdir. Reclaim (not merely stop) — the
161
+ // workdir was created by this very spawn, so there is nothing to preserve.
162
+ log('hosted node %s went %s during its spawn — abandoning new child %s', id, current?.status ?? 'missing', result.dockerId);
163
+ await this.safeReclaim(result.dockerId);
164
+ throw current
165
+ ? new HostedNodeError('invalid_state', `Hosted node ${id} was ${current.status} before it finished starting`)
166
+ : new HostedNodeError('not_found', `No such hosted node: ${id}`);
167
+ }
168
+ const started = {
169
+ ...current,
170
+ ...handlesOf(result),
171
+ status: invitation ? 'joining' : 'unclaimed',
172
+ updatedAt: this.now().toISOString(),
173
+ };
174
+ try {
175
+ this.store.put(started);
176
+ }
177
+ catch (err) {
178
+ const message = errorMessage(err);
179
+ log('recording hosted node %s failed: %s', id, message);
180
+ await this.safeReclaim(result.dockerId);
181
+ this.markSpawnFailed(id, message);
182
+ throw new HostedNodeError('orchestrator_error', `Failed to start hosted node: ${message}`);
183
+ }
184
+ log(invitation ? 'hosted node %s is up and redeeming its invitation' : 'hosted node %s is up and waiting to be claimed', id);
185
+ this.emit({ kind: 'added', id });
186
+ this.watcher.poke(id);
187
+ return redact(started);
188
+ }
189
+ /**
190
+ * Mark a still-`spawning` record as failed — and ONLY such a record. A row that
191
+ * was removed while the spawn was in flight must not be recreated.
192
+ *
193
+ * Best-effort (mirroring {@link storeRespawnAttempt}): we are already unwinding a
194
+ * spawn failure, and a store error here must not mask the error being reported
195
+ * to the caller.
196
+ */
197
+ markSpawnFailed(id, message) {
198
+ try {
199
+ const current = this.store.get(id);
200
+ if (current?.status !== 'spawning')
201
+ return;
202
+ this.store.put({ ...current, status: 'error', error: message, updatedAt: this.now().toISOString() });
203
+ this.emit({ kind: 'changed', id });
204
+ }
205
+ catch (err) {
206
+ log('failed to mark hosted node %s as errored: %s', id, errorMessage(err));
207
+ }
208
+ }
209
+ /**
210
+ * What the QR code carries for a node waiting to be claimed: its peer id, the
211
+ * addresses a phone may dial it at, and its claim secret — the only place the
212
+ * secret ever leaves the host. `409 invalid_state` unless the node is `unclaimed`;
213
+ * `503 node_unavailable` until the child's `/status` answers (callers poll).
214
+ */
215
+ async claimDetails(id) {
216
+ const node = this.requireNode(id);
217
+ if (node.status !== 'unclaimed' || node.join.kind !== 'claim') {
218
+ throw new HostedNodeError('invalid_state', `Hosted node ${id} is ${node.status}; only a node waiting to be claimed has claim details`);
219
+ }
220
+ if (!node.statusEndpoint) {
221
+ throw new HostedNodeError('node_unavailable', `Hosted node ${id} has no status endpoint yet`);
222
+ }
223
+ const { secret } = node.join;
224
+ const status = await readNodeStatus(node.statusEndpoint);
225
+ this.recordPeerId(id, status.peerId);
226
+ const settings = this.claimAddressSettings();
227
+ const exactAddrs = settings?.addrs ?? [];
228
+ return buildClaimDetails({
229
+ id,
230
+ secret,
231
+ status,
232
+ ports: this.orchestrator.getNode?.(id)?.ports,
233
+ addresses: this.addresses,
234
+ exactAddrs,
235
+ lan: exactAddrs.length > 0 ? undefined : await claimLanAddress(settings, this.addresses, this.primaryLan),
236
+ });
237
+ }
238
+ /** Cache the peer id on the record, once. Best-effort: the claim details do not depend on it. */
239
+ recordPeerId(id, peerId) {
240
+ try {
241
+ // Re-read: the entry-time copy predates the `/status` round-trip.
242
+ const current = this.store.get(id);
243
+ if (!current || current.peerId === peerId)
244
+ return;
245
+ this.store.put({ ...current, peerId });
246
+ }
247
+ catch (err) {
248
+ log('failed to record peer id of hosted node %s: %s', id, errorMessage(err));
249
+ }
250
+ }
251
+ /**
252
+ * Re-spawn a node that is no longer running, replaying the record's party and
253
+ * claim secret, or its invitation while it is still `joining`. The node keeps its
254
+ * workdir — and with it its identity key and node-local stores — and comes back on
255
+ * the same ports, so it is the *same* peer at the same addresses: a QR code already
256
+ * shown stays valid, and a joined node's cadre finds it where it was. Keeping the
257
+ * secret after the claim is deliberate: `cadre-cli` honours `claim.json` and, with the
258
+ * secret still set, answers a rival `already-claimed`. A joined invitation node comes
259
+ * back without its invitation ({@link spawnRequestFor} says why).
260
+ *
261
+ * **Status is deliberately unchanged.** A `joined` node stays `joined`; an
262
+ * `unclaimed` one keeps waiting, a `joining` one redeems again. `connected` is
263
+ * dropped until the watcher reads the new child.
264
+ *
265
+ * **An ending that lands mid-spawn wins.** The record is re-read after the
266
+ * orchestrator returns; if it was removed (or went `error`) while the child was
267
+ * starting, that write stands and the new child is abandoned — see
268
+ * {@link abandonRespawn}. An abandoned respawn is a skip, not a failure, so a
269
+ * sweep over the store keeps going. Orchestrator failures DO throw; the caller
270
+ * owns backoff/give-up.
271
+ *
272
+ * Not serialized itself: callers run it on {@link serializeRespawns}. Two overlapping
273
+ * calls for the same id both spawn a child, and the second spawn drops the first's
274
+ * orchestrator handle — leaving an unmanaged process.
275
+ */
276
+ async respawn(id, opts = {}) {
277
+ const node = this.requireNode(id);
278
+ // Callers filter for this already; the guard is here so no future one can
279
+ // resurrect or double-spawn a node by omission.
280
+ if (!RESPAWNABLE_STATUSES.has(node.status)) {
281
+ throw new HostedNodeError('invalid_state', `Hosted node ${id} cannot be respawned in status ${node.status}`);
282
+ }
283
+ // Just the counters, never a whole row copy: both exits below merge these
284
+ // onto whatever the store holds *after* the spawn, and an entry-time record
285
+ // in scope is a standing invitation to write the stale row back.
286
+ const attempt = opts.countAttempt === false
287
+ ? undefined
288
+ : { attempts: (node.respawn?.attempts ?? 0) + 1, lastAttemptAt: this.now().toISOString() };
289
+ let spawned;
290
+ try {
291
+ const result = await this.orchestrator.createContainer(spawnRequestFor(node));
292
+ spawned = handlesOf(result);
293
+ // Re-read: see `joinLocked`. A removal or a give-up that landed while the
294
+ // spawn was in flight must stand.
295
+ const current = this.store.get(id);
296
+ if (!current || !RESPAWNABLE_STATUSES.has(current.status)) {
297
+ return this.abandonRespawn(id, spawned, current);
298
+ }
299
+ const { connected: _connected, ...rest } = current;
300
+ const respawned = {
301
+ ...rest,
302
+ ...(attempt ? { respawn: attempt, updatedAt: this.now().toISOString() } : {}),
303
+ ...spawned,
304
+ };
305
+ this.store.put(respawned);
306
+ log('respawned hosted node %s → %s (status %s)', id, result.dockerId, respawned.status);
307
+ this.emit({ kind: 'changed', id });
308
+ this.watcher.poke(id);
309
+ return { outcome: 'respawned', node: redact(respawned) };
310
+ }
311
+ catch (err) {
312
+ const message = errorMessage(err);
313
+ log('respawn of hosted node %s failed: %s', id, message);
314
+ // Stop — never reclaim — a child we spawned but failed to record:
315
+ // `removeContainer` deletes the workdir, which holds the identity key and
316
+ // node-local stores that are the whole reason a respawn is the same node.
317
+ // The new handles go onto the record below, so it names the child that
318
+ // actually exists and a later `remove()` reclaims that one.
319
+ if (spawned)
320
+ await this.safeStop(spawned.dockerId);
321
+ this.storeRespawnAttempt(id, attempt, spawned);
322
+ throw new HostedNodeError('orchestrator_error', `Failed to respawn hosted node: ${message}`);
323
+ }
324
+ }
325
+ /**
326
+ * Start an invitation node again after no member of its cadre could be reached: the
327
+ * `error` record goes back to `joining` and is respawned with its invitation, without
328
+ * spending a respawn attempt. On the respawn queue, so it never overlaps the
329
+ * supervisor's handling of the same node. `409 invalid_state` unless the node is `error`
330
+ * with `retryable` — a refused invitation stays refused, and the supervisor's give-up and
331
+ * the other failures are a reset's.
332
+ */
333
+ retry(id) {
334
+ return this.serializeRespawns(() => this.retryLocked(id));
335
+ }
336
+ async retryLocked(id) {
337
+ const node = this.requireNode(id);
338
+ if (node.status !== 'error' || node.retryable !== true) {
339
+ throw new HostedNodeError('invalid_state', `Hosted node ${id} is ${node.status}${node.status === 'error' ? ' and not retryable' : ''}; only an invitation no member could be reached for can be retried`);
340
+ }
341
+ // The watcher stops a child whose redemption failed; one still running (that stop
342
+ // failed, or a host restart re-attached it) would make the spawn below refuse.
343
+ if (node.dockerId && await this.orchestrator.isRunning(node.dockerId))
344
+ await this.safeStop(node.dockerId);
345
+ // Re-read: the stop is an await, and a removal may have landed in it.
346
+ const current = this.store.get(id);
347
+ if (current?.status !== 'error') {
348
+ throw current
349
+ ? new HostedNodeError('invalid_state', `Hosted node ${id} went ${current.status} before its retry started`)
350
+ : new HostedNodeError('not_found', `No such hosted node: ${id}`);
351
+ }
352
+ const { error: _error, retryable: _retryable, ...rest } = current;
353
+ this.store.put({ ...rest, status: 'joining', updatedAt: this.now().toISOString() });
354
+ log('retrying the invitation of hosted node %s', id);
355
+ this.emit({ kind: 'changed', id });
356
+ // A respawn that throws leaves the record `joining` with no child, which the
357
+ // supervisor's next pass respawns, counting the attempt.
358
+ const result = await this.respawn(id, { countAttempt: false });
359
+ if (result.outcome === 'abandoned') {
360
+ throw result.status
361
+ ? new HostedNodeError('invalid_state', `Hosted node ${id} went ${result.status} while its retry was starting`)
362
+ : new HostedNodeError('not_found', `No such hosted node: ${id}`);
363
+ }
364
+ return result.node;
365
+ }
366
+ /**
367
+ * Give up on a respawn whose record ended while the child was starting: stop
368
+ * the new child and, unless the record went `error`, reclaim it.
369
+ *
370
+ * Reclaim, not merely stop. The removal's own cleanup already ran and found
371
+ * nothing: `HostProcessOrchestrator.createContainer` calls `dropStaleHandle`
372
+ * *while* spawning, so by the time the concurrent `remove` reached its
373
+ * stop/reclaim the old handle was gone and both calls were swallowed as
374
+ * best-effort no-ops. The new handle is now the only thing holding that
375
+ * spawn's ports and workdir, so stopping alone would leak both.
376
+ *
377
+ * `error` is the deliberate exception: `removeContainer` deletes the workdir,
378
+ * and `HostedNodeSupervisor.giveUp` keeps it on purpose so the identity key the
379
+ * node's cadre approved survives. Since both spawns share one workdir
380
+ * (`<rootDir>/<containerId>`), reclaiming here would delete exactly what
381
+ * `giveUp` meant to keep.
382
+ *
383
+ * The `error` record is then pointed at the new child, the one that exists, so a
384
+ * later `remove` reclaims its ports and workdir. `giveUp` runs on the respawn queue
385
+ * and cannot overlap a respawn; the watcher's write of a failed invitation can, when
386
+ * a poll of the old child answers while the new one is starting.
387
+ */
388
+ async abandonRespawn(id, spawned, current) {
389
+ const status = current?.status;
390
+ log('hosted node %s went %s during respawn — abandoning new child %s', id, status ?? 'missing', spawned.dockerId);
391
+ await this.safeStop(spawned.dockerId);
392
+ if (status === 'error')
393
+ this.storeRespawnAttempt(id, undefined, spawned);
394
+ else
395
+ await this.safeReclaim(spawned.dockerId);
396
+ return status ? { outcome: 'abandoned', status } : { outcome: 'abandoned' };
397
+ }
398
+ /**
399
+ * Remove a hosted node: delete the row FIRST, then stop and reclaim the child
400
+ * (its workdir, identity key and data go with it). The node's cadre keeps the
401
+ * node's row until its owner removes it there.
402
+ *
403
+ * Order matters: stopping the child fires the orchestrator's `onStateChange`,
404
+ * and the supervisor's exit-triggered pass must find no record — otherwise it
405
+ * observes "node gone, record still `joined`" and brings back a node the admin
406
+ * just removed. The tradeoff: a crash between the delete and the stop leaves a
407
+ * running child no record names, which `remove` on its id still reclaims (below).
408
+ *
409
+ * A handle no record names — `hosted-nodes.json` lost, or a handle an older
410
+ * build persisted — is removed the same way, so `/api/nodes` cannot list a node
411
+ * for good that nothing can end.
412
+ */
413
+ async remove(id) {
414
+ const node = this.store.get(id);
415
+ if (!node) {
416
+ await this.removeOrphanHandle(id);
417
+ return;
418
+ }
419
+ this.store.remove(id);
420
+ if (node.dockerId) {
421
+ await this.safeStop(node.dockerId);
422
+ await this.safeReclaim(node.dockerId);
423
+ }
424
+ else {
425
+ // No `dockerId` — the spawn never got far enough to produce one, so the
426
+ // stop-and-reclaim above has nothing to aim at and the node's working
427
+ // directory would be stranded on disk with nothing able to name it.
428
+ //
429
+ // NOTE: this can race an in-flight `join` for the same record — the delete
430
+ // may land between that spawn's `mkdirSync` and its `spawn`. The end state
431
+ // is still correct (`joinLocked`'s post-spawn re-read sees no record and
432
+ // reclaims the new child, which deletes the workdir anyway); the interim
433
+ // delete can only make that child fail to start noisily, which is strictly
434
+ // better than a permanent strand.
435
+ this.safeReclaimWorkdir(id);
436
+ }
437
+ log('hosted node %s removed', id);
438
+ this.emit({ kind: 'removed', id });
439
+ }
440
+ async removeOrphanHandle(id) {
441
+ const dockerId = this.orchestrator.resolveDockerId?.(id);
442
+ if (!dockerId)
443
+ throw new HostedNodeError('not_found', `No such hosted node: ${id}`);
444
+ log('hosted node %s has a handle but no record — removing the handle', id);
445
+ await this.safeStop(dockerId);
446
+ await this.safeReclaim(dockerId);
447
+ this.emit({ kind: 'removed', id });
448
+ }
449
+ /**
450
+ * Remove a node and start a fresh one waiting to be claimed, with a new secret and
451
+ * identity. For the case where someone else photographed the QR code and claimed the
452
+ * node first, and for an `error` node the admin wants to try again. An invitation node
453
+ * is replaced by one waiting to be claimed too: its invitation may be spent, and the
454
+ * admin can join with a fresh one instead.
455
+ */
456
+ async reset(id) {
457
+ await this.remove(id);
458
+ return this.join();
459
+ }
460
+ /**
461
+ * Mark records stuck in `spawning` past `ttlMs` as `error` and reclaim whatever
462
+ * the orchestrator can still find for them — the record is written before the
463
+ * child spawn starts, so a host crash/kill anywhere in that window (including
464
+ * mid-spawn) leaves nothing to advance it across a restart. Run once at
465
+ * startup and on a periodic sweep. Age is measured from `updatedAt`, which for
466
+ * a genuinely-stuck row equals `createdAt` — `joinLocked` never touches a
467
+ * `spawning` record again except to advance it out of this predicate.
468
+ * Best-effort per record: a failed reap is logged and the sweep continues.
469
+ * Returns the reaped ids.
470
+ */
471
+ async reapStuckSpawning(ttlMs = HOSTED_NODE_SPAWNING_TTL_MS) {
472
+ const cutoff = this.now().getTime() - ttlMs;
473
+ const stale = this.store.list().filter((n) => isStuckSpawning(n, cutoff));
474
+ const reaped = [];
475
+ for (const node of stale) {
476
+ try {
477
+ // Re-read: an in-flight (same-process) `joinLocked` call can still
478
+ // advance this exact record between the snapshot above and now.
479
+ const current = this.store.get(node.id);
480
+ if (!current || !isStuckSpawning(current, cutoff))
481
+ continue;
482
+ await this.reclaimStuckSpawning(current);
483
+ reaped.push(node.id);
484
+ log('reaped stuck spawning hosted node %s (age > %dms)', node.id, ttlMs);
485
+ }
486
+ catch (err) {
487
+ log('failed to reap stuck spawning hosted node %s: %s', node.id, errorMessage(err));
488
+ }
489
+ }
490
+ return reaped;
491
+ }
492
+ /**
493
+ * Terminalize a stuck-`spawning` record as `error` and reclaim any child the
494
+ * orchestrator can still find for it. Status is written FIRST (same ordering
495
+ * rule as {@link remove}): reclaiming fires `onStateChange`, and anything
496
+ * listening must already see a terminal record.
497
+ *
498
+ * NOTE: the stop only reaches a child the orchestrator has a handle for.
499
+ * `HostProcessOrchestrator.launchChild` spawns the process and persists the
500
+ * handle in the same synchronous step, so the only child this misses is one
501
+ * whose host died between the OS spawn and that write — an orphan *process*
502
+ * this reap terminalizes the record for but cannot kill (its ports stay held
503
+ * until a reboot). Its working directory is reclaimed by container name when
504
+ * no handle resolves, which also covers the far commoner case of a host that
505
+ * died before any child existed at all.
506
+ */
507
+ async reclaimStuckSpawning(node) {
508
+ this.store.put({
509
+ ...node,
510
+ status: 'error',
511
+ error: 'stuck starting past its deadline — the host likely restarted mid-spawn',
512
+ updatedAt: this.now().toISOString(),
513
+ });
514
+ this.emit({ kind: 'changed', id: node.id });
515
+ const dockerId = this.orchestrator.resolveDockerId?.(node.id);
516
+ if (dockerId) {
517
+ await this.safeStop(dockerId);
518
+ await this.safeReclaim(dockerId);
519
+ }
520
+ else {
521
+ this.safeReclaimWorkdir(node.id);
522
+ }
523
+ }
524
+ /** One node (secret stripped), or undefined when unknown. */
525
+ get(id) {
526
+ const node = this.store.get(id);
527
+ return node ? redact(node) : undefined;
528
+ }
529
+ /** Every node (secret stripped). */
530
+ list() {
531
+ return this.store.list().map(redact);
532
+ }
533
+ requireNode(id) {
534
+ const node = this.store.get(id);
535
+ if (!node)
536
+ throw new HostedNodeError('not_found', `No such hosted node: ${id}`);
537
+ return node;
538
+ }
539
+ /** Best-effort stop; logs but never throws (cleanup path). */
540
+ async safeStop(dockerId) {
541
+ try {
542
+ await this.orchestrator.stopContainer(dockerId);
543
+ }
544
+ catch (err) {
545
+ log('failed to stop container %s: %s', dockerId, errorMessage(err));
546
+ }
547
+ }
548
+ /**
549
+ * Persist a failed respawn's attempt counters (none for an uncounted restart) —
550
+ * plus, when the spawn itself succeeded and it was the record write that failed,
551
+ * the new child's handles. Merged onto whatever is on disk now, never written
552
+ * wholesale: the caller's copy predates the orchestrator round-trip.
553
+ *
554
+ * `status` and `updatedAt` are deliberately untouched — the attempt did not succeed.
555
+ *
556
+ * Recording `spawned` is what keeps the record pointing at the child that
557
+ * actually exists: `createContainer` already dropped the handle the record
558
+ * previously named, so leaving the old `dockerId` there would send every later
559
+ * stop/reclaim at an id the orchestrator cannot resolve — stranding the node's
560
+ * workdir on disk forever.
561
+ *
562
+ * Best-effort: we are already unwinding a respawn failure, and a store error
563
+ * here must not mask it. Losing the write costs the caller one extra attempt
564
+ * before backoff; the supervisor's next pass re-reads the record, finds the
565
+ * named child not running, and respawns again — which drops whatever handle is
566
+ * current, so the ports self-heal either way.
567
+ */
568
+ storeRespawnAttempt(id, respawn, spawned) {
569
+ if (!respawn && !spawned)
570
+ return;
571
+ try {
572
+ // A row that vanished (a removal) must not be recreated.
573
+ const current = this.store.get(id);
574
+ if (!current)
575
+ return;
576
+ this.store.put({ ...current, ...(respawn ? { respawn } : {}), ...spawned });
577
+ }
578
+ catch (err) {
579
+ log('failed to record respawn attempt for %s: %s', id, errorMessage(err));
580
+ }
581
+ }
582
+ /** Best-effort remove; logs but never throws (cleanup path). */
583
+ async safeReclaim(dockerId) {
584
+ try {
585
+ await this.orchestrator.removeContainer(dockerId);
586
+ }
587
+ catch (err) {
588
+ log('failed to reclaim container %s: %s', dockerId, errorMessage(err));
589
+ }
590
+ }
591
+ /**
592
+ * Best-effort removal of the working directory of a node whose spawn never
593
+ * produced a `dockerId`; logs but never throws (cleanup path). A no-op on an
594
+ * orchestrator without the capability, and on one that refuses because a live
595
+ * handle still owns the directory.
596
+ */
597
+ safeReclaimWorkdir(id) {
598
+ try {
599
+ if (this.orchestrator.reclaimWorkdir?.(id)) {
600
+ log('reclaimed orphaned workdir for hosted node %s', id);
601
+ }
602
+ }
603
+ catch (err) {
604
+ log('failed to reclaim workdir for hosted node %s: %s', id, errorMessage(err));
605
+ }
606
+ }
607
+ /** Run `fn` after any in-flight join. */
608
+ serializeJoin(fn) {
609
+ const run = this.joinTail.then(fn, fn);
610
+ // The stored tail swallows outcomes — a rejected join must not reject the
611
+ // next caller's wait, only defer it.
612
+ this.joinTail = run.then(() => undefined, () => undefined);
613
+ return run;
614
+ }
615
+ /**
616
+ * Run `fn` after every respawn already queued. Every caller of {@link respawn} goes
617
+ * through here: the supervisor's reconcile passes and restarts, and {@link retry}.
618
+ */
619
+ serializeRespawns(fn) {
620
+ const run = this.respawnTail.then(fn, fn);
621
+ // As in `serializeJoin`: a failed pass only sequences the next one, never rejects it.
622
+ this.respawnTail = run.then(() => undefined, () => undefined);
623
+ return run;
624
+ }
625
+ }
626
+ /** `hn_<high-entropy base64url>` — also the node's orchestrator containerId and workdir name. */
627
+ function generateHostedNodeId() {
628
+ return `hn_${randomBytes(12).toString('base64url')}`;
629
+ }
630
+ function generateClaimSecret() {
631
+ return randomBytes(CLAIM_SECRET_BYTES).toString('base64url');
632
+ }
633
+ /** Decode a pasted invitation; one that does not decode is `invalid_request` with the decoder's reason. */
634
+ function decodeInvitation(encoded) {
635
+ const trimmed = encoded.trim();
636
+ try {
637
+ return { encoded: trimmed, decoded: decodeCadreInvitation(trimmed) };
638
+ }
639
+ catch (err) {
640
+ throw new HostedNodeError('invalid_request', `The invitation does not decode: ${errorMessage(err)}`);
641
+ }
642
+ }
643
+ /**
644
+ * The spawn a record describes: its party, profile, and how it gets into its cadre. Used
645
+ * by `join` and `respawn` alike.
646
+ *
647
+ * An invitation is passed until the node has joined, and never after: a joined node is a
648
+ * member whose rows are in its own control database, and an invitation that expired
649
+ * meanwhile must not turn a healthy member's restart into a refusal.
650
+ */
651
+ function spawnRequestFor(node) {
652
+ const base = {
653
+ containerId: node.id,
654
+ partyId: node.partyId,
655
+ profile: node.profile,
656
+ };
657
+ if (node.join.kind === 'claim')
658
+ return { ...base, claimSecret: node.join.secret };
659
+ return node.status === 'joined' ? base : { ...base, invitation: node.join.encoded };
660
+ }
661
+ /** The record fields a spawn result lands. */
662
+ function handlesOf(result) {
663
+ return { dockerId: result.dockerId, statusEndpoint: statusUrlFor(result.healthEndpoint) };
664
+ }
665
+ /** Strip the claim secret or the invitation. */
666
+ function redact(node) {
667
+ const { join, ...rest } = node;
668
+ return { ...rest, join: { kind: join.kind } };
669
+ }
670
+ /**
671
+ * Whether a record is still a stuck-`spawning` reap candidate. Stated once and
672
+ * applied twice in {@link HostedNodeService.reapStuckSpawning} — on the sweep's
673
+ * candidate list, and again per record before the terminal write.
674
+ */
675
+ function isStuckSpawning(node, cutoff) {
676
+ return node?.status === 'spawning' && Date.parse(node.updatedAt) < cutoff;
677
+ }
678
+ //# sourceMappingURL=hosted-node-service.js.map