@serfab/cadre-host 1.13.0 → 1.14.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 (337) hide show
  1. package/README.md +332 -423
  2. package/dist/bin/host.d.ts +6 -3
  3. package/dist/bin/host.js +301 -505
  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 +49 -0
  9. package/dist/bin/nat-output.js +127 -0
  10. package/dist/bin/nat-output.js.map +1 -0
  11. package/dist/hosted/claim-details.d.ts +44 -0
  12. package/dist/hosted/claim-details.js +66 -0
  13. package/dist/hosted/claim-details.js.map +1 -0
  14. package/dist/hosted/hosted-node-service.d.ts +318 -0
  15. package/dist/hosted/hosted-node-service.js +666 -0
  16. package/dist/hosted/hosted-node-service.js.map +1 -0
  17. package/dist/hosted/hosted-node-store.d.ts +40 -0
  18. package/dist/hosted/hosted-node-store.js +126 -0
  19. package/dist/hosted/hosted-node-store.js.map +1 -0
  20. package/dist/{donation/donation-supervisor.d.ts → hosted/hosted-node-supervisor.d.ts} +70 -53
  21. package/dist/hosted/hosted-node-supervisor.js +396 -0
  22. package/dist/hosted/hosted-node-supervisor.js.map +1 -0
  23. package/dist/hosted/hosted-node-watcher.d.ts +83 -0
  24. package/dist/hosted/hosted-node-watcher.js +224 -0
  25. package/dist/hosted/hosted-node-watcher.js.map +1 -0
  26. package/dist/hosted/index.d.ts +16 -0
  27. package/dist/hosted/index.js +13 -0
  28. package/dist/hosted/index.js.map +1 -0
  29. package/dist/hosted/node-status.d.ts +35 -0
  30. package/dist/hosted/node-status.js +61 -0
  31. package/dist/hosted/node-status.js.map +1 -0
  32. package/dist/hosted/types.d.ts +134 -0
  33. package/dist/hosted/types.js +22 -0
  34. package/dist/hosted/types.js.map +1 -0
  35. package/dist/index.d.ts +12 -24
  36. package/dist/index.js +9 -19
  37. package/dist/index.js.map +1 -1
  38. package/dist/installer/config.d.ts +4 -34
  39. package/dist/installer/config.js +3 -18
  40. package/dist/installer/config.js.map +1 -1
  41. package/dist/installer/index.d.ts +7 -19
  42. package/dist/installer/index.js +14 -69
  43. package/dist/installer/index.js.map +1 -1
  44. package/dist/installer/paths.d.ts +0 -2
  45. package/dist/installer/paths.js +0 -4
  46. package/dist/installer/paths.js.map +1 -1
  47. package/dist/installer/wizard.d.ts +0 -7
  48. package/dist/installer/wizard.js +1 -8
  49. package/dist/installer/wizard.js.map +1 -1
  50. package/dist/nat/address-resolver.d.ts +18 -17
  51. package/dist/nat/address-resolver.js +79 -25
  52. package/dist/nat/address-resolver.js.map +1 -1
  53. package/dist/nat/address-watch.d.ts +37 -0
  54. package/dist/nat/address-watch.js +84 -0
  55. package/dist/nat/address-watch.js.map +1 -0
  56. package/dist/nat/ddns/duckdns.d.ts +2 -1
  57. package/dist/nat/ddns/duckdns.js +5 -1
  58. package/dist/nat/ddns/duckdns.js.map +1 -1
  59. package/dist/nat/ddns/index.d.ts +6 -0
  60. package/dist/nat/ddns/index.js.map +1 -1
  61. package/dist/nat/index.d.ts +12 -9
  62. package/dist/nat/index.js +6 -5
  63. package/dist/nat/index.js.map +1 -1
  64. package/dist/nat/nat-service.d.ts +203 -77
  65. package/dist/nat/nat-service.js +602 -237
  66. package/dist/nat/nat-service.js.map +1 -1
  67. package/dist/nat/nat-store.d.ts +12 -5
  68. package/dist/nat/nat-store.js +114 -30
  69. package/dist/nat/nat-store.js.map +1 -1
  70. package/dist/nat/port-mapper.d.ts +83 -79
  71. package/dist/nat/port-mapper.js +148 -189
  72. package/dist/nat/port-mapper.js.map +1 -1
  73. package/dist/nat/reachability.d.ts +31 -15
  74. package/dist/nat/reachability.js +97 -22
  75. package/dist/nat/reachability.js.map +1 -1
  76. package/dist/nat/types.d.ts +78 -27
  77. package/dist/nat/types.js +8 -5
  78. package/dist/nat/types.js.map +1 -1
  79. package/dist/orchestrator/host-process-orchestrator.d.ts +29 -71
  80. package/dist/orchestrator/host-process-orchestrator.js +78 -218
  81. package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
  82. package/dist/{installer/identity.d.ts → orchestrator/identity-file.d.ts} +5 -5
  83. package/dist/{installer/identity.js → orchestrator/identity-file.js} +6 -6
  84. package/dist/orchestrator/identity-file.js.map +1 -0
  85. package/dist/orchestrator/index.d.ts +2 -3
  86. package/dist/orchestrator/index.js +1 -1
  87. package/dist/orchestrator/index.js.map +1 -1
  88. package/dist/orchestrator/node-identity.d.ts +6 -6
  89. package/dist/orchestrator/node-identity.js +7 -7
  90. package/dist/orchestrator/node-identity.js.map +1 -1
  91. package/dist/orchestrator/port-allocator.d.ts +5 -6
  92. package/dist/orchestrator/port-allocator.js +9 -11
  93. package/dist/orchestrator/port-allocator.js.map +1 -1
  94. package/dist/orchestrator/state-store.d.ts +3 -5
  95. package/dist/orchestrator/state-store.js +2 -10
  96. package/dist/orchestrator/state-store.js.map +1 -1
  97. package/dist/orchestrator/types.d.ts +33 -22
  98. package/dist/orchestrator/types.js.map +1 -1
  99. package/dist/server/error-handler.d.ts +5 -25
  100. package/dist/server/error-handler.js +11 -74
  101. package/dist/server/error-handler.js.map +1 -1
  102. package/dist/server/events/types.d.ts +7 -11
  103. package/dist/server/events/types.js +2 -1
  104. package/dist/server/events/types.js.map +1 -1
  105. package/dist/server/index.d.ts +11 -37
  106. package/dist/server/index.js +30 -48
  107. package/dist/server/index.js.map +1 -1
  108. package/dist/server/routes/hosted-nodes.d.ts +28 -0
  109. package/dist/server/routes/hosted-nodes.js +68 -0
  110. package/dist/server/routes/hosted-nodes.js.map +1 -0
  111. package/dist/server/routes/nat.d.ts +4 -1
  112. package/dist/server/routes/nat.js +13 -2
  113. package/dist/server/routes/nat.js.map +1 -1
  114. package/dist/server/routes/nodes.d.ts +6 -20
  115. package/dist/server/routes/nodes.js +7 -86
  116. package/dist/server/routes/nodes.js.map +1 -1
  117. package/dist/server/routes/settings.d.ts +2 -7
  118. package/dist/server/routes/settings.js +3 -7
  119. package/dist/server/routes/settings.js.map +1 -1
  120. package/dist/server/routes/status.d.ts +2 -25
  121. package/dist/server/routes/status.js +1 -11
  122. package/dist/server/routes/status.js.map +1 -1
  123. package/dist/server/server.js +8 -8
  124. package/dist/server/settings-store.d.ts +2 -2
  125. package/dist/server/settings-store.js +2 -2
  126. package/dist/server/static.js +1 -2
  127. package/dist/server/static.js.map +1 -1
  128. package/dist/ui/assets/index-4FWuU8ye.js +19 -0
  129. package/dist/ui/assets/index-4FWuU8ye.js.map +1 -0
  130. package/dist/ui/assets/index-Di9Q4d01.css +1 -0
  131. package/dist/ui/index.html +2 -2
  132. package/dist/update/index.d.ts +2 -2
  133. package/dist/update/index.js +2 -2
  134. package/dist/update/store.d.ts +1 -1
  135. package/dist/update/store.js +1 -1
  136. package/dist/update/types.d.ts +1 -1
  137. package/dist/update/types.js +1 -1
  138. package/package.json +5 -6
  139. package/src/__tests__/cli-nat.smoke.test.ts +219 -212
  140. package/src/__tests__/cli.smoke.test.ts +1 -1
  141. package/src/__tests__/global-setup.ts +2 -3
  142. package/src/__tests__/orchestrator-claim-secret.test.ts +112 -0
  143. package/src/__tests__/orchestrator-handle-contract.ts +134 -134
  144. package/src/__tests__/orchestrator-node-identity.test.ts +182 -203
  145. package/src/__tests__/orchestrator-ports.test.ts +124 -143
  146. package/src/__tests__/orchestrator-push.test.ts +138 -146
  147. package/src/__tests__/orchestrator.test.ts +1019 -1063
  148. package/src/bin/host.ts +368 -553
  149. package/src/bin/join-output.ts +120 -0
  150. package/src/bin/nat-output.ts +177 -0
  151. package/src/{donation → hosted}/__tests__/fake-orchestrator.test.ts +173 -173
  152. package/src/{donation → hosted}/__tests__/fake-orchestrator.ts +265 -243
  153. package/src/hosted/__tests__/hosted-node-service.test.ts +609 -0
  154. package/src/hosted/__tests__/hosted-node-supervisor.test.ts +474 -0
  155. package/src/hosted/__tests__/test-invitation.ts +33 -0
  156. package/src/hosted/claim-details.ts +96 -0
  157. package/src/hosted/hosted-node-service.ts +795 -0
  158. package/src/hosted/hosted-node-store.ts +144 -0
  159. package/src/hosted/hosted-node-supervisor.ts +439 -0
  160. package/src/hosted/hosted-node-watcher.ts +250 -0
  161. package/src/hosted/index.ts +53 -0
  162. package/src/hosted/node-status.ts +71 -0
  163. package/src/hosted/types.ts +153 -0
  164. package/src/index.ts +72 -91
  165. package/src/installer/__tests__/config.test.ts +15 -40
  166. package/src/installer/__tests__/installer.smoke.test.ts +5 -86
  167. package/src/installer/__tests__/wizard.test.ts +56 -64
  168. package/src/installer/config.ts +135 -175
  169. package/src/installer/index.ts +15 -83
  170. package/src/installer/paths.ts +104 -109
  171. package/src/installer/wizard.ts +87 -103
  172. package/src/nat/__tests__/address-resolver.test.ts +107 -82
  173. package/src/nat/__tests__/nat-service.test.ts +433 -408
  174. package/src/nat/__tests__/nat-store.test.ts +127 -93
  175. package/src/nat/__tests__/port-mapper.test.ts +30 -191
  176. package/src/nat/__tests__/reachability.test.ts +96 -13
  177. package/src/nat/address-resolver.ts +94 -52
  178. package/src/nat/address-watch.ts +92 -0
  179. package/src/nat/ddns/duckdns.ts +5 -1
  180. package/src/nat/ddns/index.ts +6 -0
  181. package/src/nat/index.ts +87 -71
  182. package/src/nat/nat-service.ts +889 -501
  183. package/src/nat/nat-store.ts +237 -154
  184. package/src/nat/port-mapper.ts +241 -329
  185. package/src/nat/reachability.ts +134 -25
  186. package/src/nat/types.ts +209 -159
  187. package/src/{installer/__tests__/identity.test.ts → orchestrator/__tests__/identity-file.test.ts} +34 -34
  188. package/src/orchestrator/__tests__/node-identity.test.ts +62 -62
  189. package/src/orchestrator/host-process-orchestrator.ts +81 -243
  190. package/src/{installer/identity.ts → orchestrator/identity-file.ts} +56 -56
  191. package/src/orchestrator/index.ts +9 -10
  192. package/src/orchestrator/node-identity.ts +41 -41
  193. package/src/orchestrator/port-allocator.ts +71 -73
  194. package/src/orchestrator/state-store.ts +76 -90
  195. package/src/orchestrator/types.ts +34 -23
  196. package/src/server/__tests__/bus.test.ts +38 -38
  197. package/src/server/__tests__/error-handler.test.ts +81 -118
  198. package/src/server/__tests__/fakes.ts +40 -44
  199. package/src/server/__tests__/hosted-nodes-route.test.ts +160 -0
  200. package/src/server/__tests__/nodes-route.test.ts +114 -283
  201. package/src/server/__tests__/publishers.test.ts +193 -180
  202. package/src/server/__tests__/server.smoke.test.ts +134 -223
  203. package/src/server/__tests__/settings-route.test.ts +179 -195
  204. package/src/server/__tests__/sse-route.test.ts +119 -119
  205. package/src/server/__tests__/status-route.test.ts +82 -121
  206. package/src/server/error-handler.ts +112 -178
  207. package/src/server/events/types.ts +20 -24
  208. package/src/server/index.ts +223 -267
  209. package/src/server/routes/hosted-nodes.ts +83 -0
  210. package/src/server/routes/nat.ts +75 -63
  211. package/src/server/routes/nodes.ts +96 -188
  212. package/src/server/routes/settings.ts +115 -123
  213. package/src/server/routes/status.ts +102 -134
  214. package/src/server/server.ts +8 -8
  215. package/src/server/settings-store.ts +2 -2
  216. package/src/server/static.ts +1 -2
  217. package/src/update/index.ts +2 -2
  218. package/src/update/store.ts +1 -1
  219. package/src/update/types.ts +1 -1
  220. package/dist/auth/duration.d.ts +0 -1
  221. package/dist/auth/duration.js +0 -32
  222. package/dist/auth/duration.js.map +0 -1
  223. package/dist/auth/index.d.ts +0 -11
  224. package/dist/auth/index.js +0 -9
  225. package/dist/auth/index.js.map +0 -1
  226. package/dist/auth/self-label.d.ts +0 -36
  227. package/dist/auth/self-label.js +0 -57
  228. package/dist/auth/self-label.js.map +0 -1
  229. package/dist/auth/trust-circle-store.d.ts +0 -26
  230. package/dist/auth/trust-circle-store.js +0 -129
  231. package/dist/auth/trust-circle-store.js.map +0 -1
  232. package/dist/auth/trust-circle.d.ts +0 -156
  233. package/dist/auth/trust-circle.js +0 -307
  234. package/dist/auth/trust-circle.js.map +0 -1
  235. package/dist/auth/types.d.ts +0 -84
  236. package/dist/auth/types.js +0 -24
  237. package/dist/auth/types.js.map +0 -1
  238. package/dist/donation/donation-service.d.ts +0 -389
  239. package/dist/donation/donation-service.js +0 -828
  240. package/dist/donation/donation-service.js.map +0 -1
  241. package/dist/donation/donation-store.d.ts +0 -51
  242. package/dist/donation/donation-store.js +0 -152
  243. package/dist/donation/donation-store.js.map +0 -1
  244. package/dist/donation/donation-supervisor.js +0 -364
  245. package/dist/donation/donation-supervisor.js.map +0 -1
  246. package/dist/donation/grant-service.d.ts +0 -80
  247. package/dist/donation/grant-service.js +0 -210
  248. package/dist/donation/grant-service.js.map +0 -1
  249. package/dist/donation/grant-store.d.ts +0 -38
  250. package/dist/donation/grant-store.js +0 -135
  251. package/dist/donation/grant-store.js.map +0 -1
  252. package/dist/donation/index.d.ts +0 -20
  253. package/dist/donation/index.js +0 -17
  254. package/dist/donation/index.js.map +0 -1
  255. package/dist/donation/types.d.ts +0 -224
  256. package/dist/donation/types.js +0 -40
  257. package/dist/donation/types.js.map +0 -1
  258. package/dist/installer/identity.js.map +0 -1
  259. package/dist/owner/index.d.ts +0 -11
  260. package/dist/owner/index.js +0 -11
  261. package/dist/owner/index.js.map +0 -1
  262. package/dist/owner/owner-node-client.d.ts +0 -110
  263. package/dist/owner/owner-node-client.js +0 -184
  264. package/dist/owner/owner-node-client.js.map +0 -1
  265. package/dist/server/routes/bootstrap-node-validation.d.ts +0 -115
  266. package/dist/server/routes/bootstrap-node-validation.js +0 -197
  267. package/dist/server/routes/bootstrap-node-validation.js.map +0 -1
  268. package/dist/server/routes/grants-admin.d.ts +0 -29
  269. package/dist/server/routes/grants-admin.js +0 -58
  270. package/dist/server/routes/grants-admin.js.map +0 -1
  271. package/dist/server/routes/grants.d.ts +0 -28
  272. package/dist/server/routes/grants.js +0 -165
  273. package/dist/server/routes/grants.js.map +0 -1
  274. package/dist/server/routes/provision-request-validation.d.ts +0 -45
  275. package/dist/server/routes/provision-request-validation.js +0 -97
  276. package/dist/server/routes/provision-request-validation.js.map +0 -1
  277. package/dist/server/routes/strands.d.ts +0 -20
  278. package/dist/server/routes/strands.js +0 -38
  279. package/dist/server/routes/strands.js.map +0 -1
  280. package/dist/server/routes/trust-circle.d.ts +0 -17
  281. package/dist/server/routes/trust-circle.js +0 -35
  282. package/dist/server/routes/trust-circle.js.map +0 -1
  283. package/dist/strands/index.d.ts +0 -7
  284. package/dist/strands/index.js +0 -6
  285. package/dist/strands/index.js.map +0 -1
  286. package/dist/strands/strand-service.d.ts +0 -50
  287. package/dist/strands/strand-service.js +0 -103
  288. package/dist/strands/strand-service.js.map +0 -1
  289. package/dist/strands/types.d.ts +0 -93
  290. package/dist/strands/types.js +0 -23
  291. package/dist/strands/types.js.map +0 -1
  292. package/dist/ui/assets/index-C6nELNfC.js +0 -23
  293. package/dist/ui/assets/index-C6nELNfC.js.map +0 -1
  294. package/dist/ui/assets/index-TtIYSeZO.css +0 -1
  295. package/src/__tests__/cli-invite.smoke.test.ts +0 -192
  296. package/src/__tests__/host-owner.smoke.test.ts +0 -123
  297. package/src/__tests__/orchestrator-owner.test.ts +0 -234
  298. package/src/__tests__/orchestrator-pin-keys.test.ts +0 -152
  299. package/src/auth/__tests__/duration.test.ts +0 -28
  300. package/src/auth/__tests__/self-label.test.ts +0 -85
  301. package/src/auth/__tests__/trust-circle-integration.test.ts +0 -152
  302. package/src/auth/__tests__/trust-circle-store.test.ts +0 -115
  303. package/src/auth/__tests__/trust-circle.test.ts +0 -445
  304. package/src/auth/duration.ts +0 -33
  305. package/src/auth/index.ts +0 -21
  306. package/src/auth/self-label.ts +0 -74
  307. package/src/auth/trust-circle-store.ts +0 -155
  308. package/src/auth/trust-circle.ts +0 -395
  309. package/src/auth/types.ts +0 -99
  310. package/src/donation/__tests__/donation-service.test.ts +0 -1193
  311. package/src/donation/__tests__/donation-supervisor.test.ts +0 -548
  312. package/src/donation/__tests__/grant-service.test.ts +0 -213
  313. package/src/donation/__tests__/grant-store.test.ts +0 -113
  314. package/src/donation/donation-service.ts +0 -1033
  315. package/src/donation/donation-store.ts +0 -176
  316. package/src/donation/donation-supervisor.ts +0 -411
  317. package/src/donation/grant-service.ts +0 -252
  318. package/src/donation/grant-store.ts +0 -152
  319. package/src/donation/index.ts +0 -65
  320. package/src/donation/types.ts +0 -260
  321. package/src/owner/__tests__/owner-node-client.test.ts +0 -194
  322. package/src/owner/index.ts +0 -12
  323. package/src/owner/owner-node-client.ts +0 -259
  324. package/src/server/__tests__/bootstrap-node-validation.test.ts +0 -138
  325. package/src/server/__tests__/grants-admin-route.test.ts +0 -171
  326. package/src/server/__tests__/grants-route.test.ts +0 -394
  327. package/src/server/__tests__/strands-route.test.ts +0 -234
  328. package/src/server/routes/bootstrap-node-validation.ts +0 -206
  329. package/src/server/routes/grants-admin.ts +0 -74
  330. package/src/server/routes/grants.ts +0 -193
  331. package/src/server/routes/provision-request-validation.ts +0 -105
  332. package/src/server/routes/strands.ts +0 -54
  333. package/src/server/routes/trust-circle.ts +0 -49
  334. package/src/strands/__tests__/strand-service.test.ts +0 -165
  335. package/src/strands/index.ts +0 -14
  336. package/src/strands/strand-service.ts +0 -135
  337. package/src/strands/types.ts +0 -102
@@ -0,0 +1,144 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import debug from 'debug';
4
+
5
+ import type { HostedNode, HostedNodeFile } from './types.js';
6
+ import { HostedNodeError } from './types.js';
7
+
8
+ const log = debug('cadre:host:hosted-node-store');
9
+
10
+ const FILE_VERSION = 1;
11
+
12
+ /**
13
+ * Atomic JSON store for `hosted-nodes.json` — one row per hosted node. A single
14
+ * file written to `<path>.tmp` then renamed, with an in-memory cache keyed by id.
15
+ *
16
+ * **Persists the claim secret, or the invitation.** The orchestrator keeps nothing of
17
+ * either (`state.json` omits every per-spawn env), so this row is what lets a respawn
18
+ * start the child with the same secret, which keeps a QR code already shown valid and
19
+ * lets a claimed node answer a rival `already-claimed`, and lets a `joining` node redeem
20
+ * its invitation again.
21
+ *
22
+ * Concurrency assumption: only one cadre-host process owns a given rootDir
23
+ * (the orchestrator already enforces this). No file-locking primitives.
24
+ *
25
+ * NOTE: every method here is **synchronous**, and `HostedNodeService` depends on
26
+ * that for correctness, not just convenience — each of its long operations
27
+ * re-reads a record and writes it back with no `await` in between, which is only
28
+ * atomic against the event loop while `get`/`put` cannot yield. Giving this class
29
+ * an async write path would silently reopen the resurrect-a-removed-node races
30
+ * those re-reads exist to close, and the tests that guard them would still pass.
31
+ */
32
+ export class HostedNodeStore {
33
+ private readonly path: string;
34
+ private cache: HostedNodeFile | null = null;
35
+
36
+ constructor(rootDir: string) {
37
+ mkdirSync(rootDir, { recursive: true });
38
+ this.path = join(rootDir, 'hosted-nodes.json');
39
+ }
40
+
41
+ filePath(): string {
42
+ return this.path;
43
+ }
44
+
45
+ /**
46
+ * Load (with caching). A missing file returns an empty store. A
47
+ * present-but-malformed file throws rather than silently wiping — losing the
48
+ * records would orphan live child processes (their claim secrets gone).
49
+ */
50
+ load(): HostedNodeFile {
51
+ if (this.cache) return this.cache;
52
+ if (!existsSync(this.path)) {
53
+ this.cache = emptyFile();
54
+ return this.cache;
55
+ }
56
+ let raw: string;
57
+ try {
58
+ raw = readFileSync(this.path, 'utf8');
59
+ } catch (err) {
60
+ throw new HostedNodeError(
61
+ 'storage_error',
62
+ `failed to read hosted-nodes file at ${this.path}: ${(err as Error).message}`,
63
+ );
64
+ }
65
+ let parsed: unknown;
66
+ try {
67
+ parsed = JSON.parse(raw);
68
+ } catch (err) {
69
+ throw new HostedNodeError(
70
+ 'storage_error',
71
+ `hosted-nodes file at ${this.path} is not valid JSON: ${(err as Error).message}`,
72
+ );
73
+ }
74
+ if (!isHostedNodeFile(parsed)) {
75
+ throw new HostedNodeError(
76
+ 'storage_error',
77
+ `hosted-nodes file at ${this.path} has unexpected shape`,
78
+ );
79
+ }
80
+ this.cache = parsed;
81
+ return this.cache;
82
+ }
83
+
84
+ /** Persist the current state atomically (write then rename). */
85
+ save(state: HostedNodeFile): void {
86
+ // NOTE: rewrites the whole hosted-nodes.json on every mutation. Fine at
87
+ // household scale (a handful of nodes); if counts ever grow large, switch to
88
+ // an append/compact log or per-id files.
89
+ mkdirSync(dirname(this.path), { recursive: true });
90
+ const payload = JSON.stringify({ ...state, version: FILE_VERSION }, null, 2);
91
+ const tmp = `${this.path}.tmp`;
92
+ try {
93
+ writeFileSync(tmp, payload, { encoding: 'utf8' });
94
+ renameSync(tmp, this.path);
95
+ } catch (err) {
96
+ throw new HostedNodeError(
97
+ 'storage_error',
98
+ `failed to write hosted-nodes file at ${this.path}: ${(err as Error).message}`,
99
+ );
100
+ }
101
+ this.cache = { ...state, version: FILE_VERSION };
102
+ log('saved hosted nodes (%d) to %s', Object.keys(state.nodes).length, this.path);
103
+ }
104
+
105
+ /** Insert or replace a row. */
106
+ put(node: HostedNode): void {
107
+ const state = this.load();
108
+ const { id, ...rest } = node;
109
+ state.nodes[id] = rest;
110
+ this.save(state);
111
+ }
112
+
113
+ get(id: string): HostedNode | undefined {
114
+ const state = this.load();
115
+ const row = state.nodes[id];
116
+ if (!row) return undefined;
117
+ return { id, ...row };
118
+ }
119
+
120
+ list(): HostedNode[] {
121
+ const state = this.load();
122
+ return Object.entries(state.nodes).map(([id, row]) => ({ id, ...row }));
123
+ }
124
+
125
+ remove(id: string): boolean {
126
+ const state = this.load();
127
+ if (!(id in state.nodes)) return false;
128
+ delete state.nodes[id];
129
+ this.save(state);
130
+ return true;
131
+ }
132
+ }
133
+
134
+ function emptyFile(): HostedNodeFile {
135
+ return { version: FILE_VERSION, nodes: {} };
136
+ }
137
+
138
+ function isHostedNodeFile(v: unknown): v is HostedNodeFile {
139
+ if (!v || typeof v !== 'object') return false;
140
+ const obj = v as Record<string, unknown>;
141
+ if (obj.version !== FILE_VERSION) return false;
142
+ if (!obj.nodes || typeof obj.nodes !== 'object') return false;
143
+ return true;
144
+ }
@@ -0,0 +1,439 @@
1
+ import debug from 'debug';
2
+
3
+ import type { Orchestrator } from '@serfab/cadre-provider';
4
+
5
+ import type { ManagedNodeInfo, NodeStateListener } from '../orchestrator/types.js';
6
+ import type { HostedNodeService } from './hosted-node-service.js';
7
+ import type { HostedNodeStore } from './hosted-node-store.js';
8
+ import { errorMessage } from './node-status.js';
9
+ import type { HostedNode, HostedNodeStatus } from './types.js';
10
+
11
+ const log = debug('cadre:host:hosted-node-supervisor');
12
+
13
+ /**
14
+ * Unit of the doubling respawn backoff: the wait after `n` consecutive failed
15
+ * attempts is `base * 2^n`, capped at {@link HOSTED_NODE_RESPAWN_BACKOFF_MAX_MS}.
16
+ * So the first retry waits 10s (one attempt is already recorded by then), the
17
+ * second 20s, and so on. A node with no recorded attempt is respawned
18
+ * immediately.
19
+ */
20
+ export const HOSTED_NODE_RESPAWN_BACKOFF_BASE_MS = 5_000;
21
+
22
+ /**
23
+ * Ceiling on the doubling respawn backoff. 5 minutes.
24
+ *
25
+ * NOTE: not reachable at the current {@link HOSTED_NODE_RESPAWN_MAX_ATTEMPTS} of 5
26
+ * — the longest wait actually served is the one before the 5th attempt,
27
+ * `5s * 2^4` = 80s, and once that attempt fails or its node dies the supervisor
28
+ * gives up rather than waiting again. The cap only starts clamping
29
+ * once a record can reach 6 recorded attempts (`5s * 2^6` = 320s), so raising
30
+ * the attempt cap without revisiting this one changes nothing.
31
+ */
32
+ export const HOSTED_NODE_RESPAWN_BACKOFF_MAX_MS = 5 * 60_000;
33
+
34
+ /**
35
+ * Consecutive respawn attempts after which the host stops trying and marks the
36
+ * node `error` — a node that will not stay up is a host-side fault its cadre
37
+ * cannot see, so surface it instead of spinning forever.
38
+ *
39
+ * Every attempt counts, whether its spawn failed or succeeded: a node that spawns
40
+ * and then dies before {@link HOSTED_NODE_RESPAWN_HEALTHY_MS} refills the budget is
41
+ * as much a crash loop as one that never spawns. Give-up happens on whichever
42
+ * comes first — an attempt that throws with the cap reached, or a pass that finds
43
+ * the node down with the cap already reached.
44
+ */
45
+ export const HOSTED_NODE_RESPAWN_MAX_ATTEMPTS = 5;
46
+
47
+ /**
48
+ * How long a respawned node must stay up before its attempt budget is refilled.
49
+ * The last respawn produced something that survived, so the next crash starts
50
+ * from a fresh budget rather than inheriting an old crash loop's count.
51
+ */
52
+ export const HOSTED_NODE_RESPAWN_HEALTHY_MS = 10 * 60_000;
53
+
54
+ /** How often the supervisor sweep runs while cadre-host is up. 1 minute. */
55
+ export const HOSTED_NODE_RESPAWN_SWEEP_MS = 60_000;
56
+
57
+ /**
58
+ * Statuses the supervisor considers. `spawning` is a join still in flight (its
59
+ * own code path owns the child); `error` is a node the host gave up on, or whose
60
+ * invitation failed, which comes back only by an explicit Retry or reset.
61
+ */
62
+ const SUPERVISED_STATUSES: ReadonlySet<HostedNodeStatus> = new Set<HostedNodeStatus>([
63
+ 'unclaimed',
64
+ 'joining',
65
+ 'joined',
66
+ ]);
67
+
68
+ /**
69
+ * The orchestrator surface the supervisor needs: the shared `Orchestrator`
70
+ * (for `isRunning` / `stopContainer`) plus cadre-host's own exit-event
71
+ * subscription and node list, which are not part of that cross-package
72
+ * interface. `HostProcessOrchestrator` satisfies this.
73
+ */
74
+ export interface SupervisedOrchestrator extends Orchestrator {
75
+ onStateChange(listener: NodeStateListener): () => void;
76
+ listNodes(): ManagedNodeInfo[];
77
+ }
78
+
79
+ /** Constructor options. */
80
+ export interface HostedNodeSupervisorOptions {
81
+ /** Lifecycle service — the supervisor drives its `respawn`. */
82
+ service: HostedNodeService;
83
+ /** Persistent record store — the candidate list and the give-up write. */
84
+ store: HostedNodeStore;
85
+ /** Orchestrator that owns the hosted child processes. */
86
+ orchestrator: SupervisedOrchestrator;
87
+ /** Clock override for tests. */
88
+ now?: () => Date;
89
+ }
90
+
91
+ /**
92
+ * HostedNodeSupervisor — owns the invariant *an `unclaimed`, `joining` or `joined`
93
+ * node is expected to be running*.
94
+ *
95
+ * Nothing else re-spawns a hosted node: `/api/nodes` is read-only. So without
96
+ * this class a crashed, OOM-killed, or reboot-killed node stays dead with its
97
+ * record still reading `joined`, silently costing its cadre a node.
98
+ *
99
+ * One code path ({@link reconcile}) behind three triggers:
100
+ *
101
+ * - **host startup** — one pass after `orchestrator.init()`, which catches every
102
+ * node that died while the host was down;
103
+ * - **exit signal** — the orchestrator's `onStateChange`, so a crash is noticed
104
+ * in milliseconds rather than at the next sweep;
105
+ * - **periodic sweep** — the backstop for deaths no exit event covers (host was
106
+ * down, listener missed, orchestrator re-attached to an already-dead pid).
107
+ *
108
+ * Passes are serialized: `HostedNodeService.respawn` is not itself serialized, and
109
+ * two overlapping respawns of one id both spawn a child while the second drops
110
+ * the first's orchestrator handle (see its docstring). Every pass, and a deliberate
111
+ * {@link restart}, runs on the service's respawn queue
112
+ * (`HostedNodeService.serializeRespawns`), which the service's Retry shares.
113
+ *
114
+ * The supervisor reconciles records to handles, never handles to records: a
115
+ * handle no record names (`hosted-nodes.json` lost, or a handle an older build
116
+ * persisted) is logged at startup and left running, since stopping it could end
117
+ * a node that is serving its cadre. `cadre-host node remove <id>` reclaims it.
118
+ */
119
+ export class HostedNodeSupervisor {
120
+ private readonly service: HostedNodeService;
121
+ private readonly store: HostedNodeStore;
122
+ private readonly orchestrator: SupervisedOrchestrator;
123
+ private readonly now: () => Date;
124
+
125
+ private timer?: ReturnType<typeof setInterval>;
126
+ private unsubscribe?: () => void;
127
+ /** At most one exit-triggered pass queued at a time (crash storms coalesce). */
128
+ private exitPassQueued = false;
129
+ /** Set by `stop()` so an in-flight pass abandons its remaining records. */
130
+ private disposed = false;
131
+
132
+ constructor(opts: HostedNodeSupervisorOptions) {
133
+ this.service = opts.service;
134
+ this.store = opts.store;
135
+ this.orchestrator = opts.orchestrator;
136
+ this.now = opts.now ?? (() => new Date());
137
+ log('HostedNodeSupervisor initialized');
138
+ }
139
+
140
+ /**
141
+ * One reconcile pass over the store. Serialized against every other pass, so
142
+ * a caller may get a queued pass rather than an immediate one. Returns the ids
143
+ * it respawned in *its own* pass.
144
+ */
145
+ reconcile(): Promise<string[]> {
146
+ return this.service.serializeRespawns(() => this.reconcileOnce());
147
+ }
148
+
149
+ /**
150
+ * Stop a running node and start it again, for a change the node picks up
151
+ * only at start — its public addresses (`NatService.onNodeAddressesStale`).
152
+ * Serialized with the reconcile passes, so it never overlaps a respawn or a
153
+ * give-up of the same record, and it re-reads the record first: a record that
154
+ * is no longer supervised, has no handle, or whose node is not
155
+ * running is left alone (a node that is down is the crash path's).
156
+ *
157
+ * Does not spend the record's respawn budget. A restart whose respawn throws
158
+ * rejects; the node is then down, and the exit event its stop raised has queued
159
+ * a reconcile pass that takes it over through the crash path, with its counting.
160
+ */
161
+ restart(id: string): Promise<void> {
162
+ return this.service.serializeRespawns(() => this.restartOnce(id));
163
+ }
164
+
165
+ /**
166
+ * Subscribe to child exits, start the periodic sweep, and run the startup
167
+ * pass. Idempotent — a second call while started is a no-op.
168
+ */
169
+ start(): void {
170
+ if (this.timer) return;
171
+ this.disposed = false;
172
+ // Subscribe before the first pass so an exit during it is not missed.
173
+ this.unsubscribe = this.orchestrator.onStateChange((info) => { this.onNodeState(info); });
174
+ this.timer = setInterval(() => { this.sweep('timer'); }, HOSTED_NODE_RESPAWN_SWEEP_MS);
175
+ this.timer.unref();
176
+ this.logOrphanHandles();
177
+ this.sweep('startup');
178
+ }
179
+
180
+ /**
181
+ * Stop the timer and unsubscribe. A pass already in flight abandons its
182
+ * remaining records rather than being awaited — every record it has not
183
+ * reached is picked up by the next host start's startup pass.
184
+ */
185
+ stop(): void {
186
+ this.disposed = true;
187
+ if (this.timer) {
188
+ clearInterval(this.timer);
189
+ this.timer = undefined;
190
+ }
191
+ this.unsubscribe?.();
192
+ this.unsubscribe = undefined;
193
+ log('HostedNodeSupervisor stopped');
194
+ }
195
+
196
+ /**
197
+ * Name every orchestrator handle that no record claims, once at startup. Not
198
+ * reclaimed (see the class docstring): the admin ends it with
199
+ * `cadre-host node remove <id>`.
200
+ */
201
+ private logOrphanHandles(): void {
202
+ let known: Set<string>;
203
+ try {
204
+ known = new Set(this.store.list().map((n) => n.id));
205
+ } catch (err) {
206
+ log('orphan check could not list hosted nodes: %s', errorMessage(err));
207
+ return;
208
+ }
209
+ for (const handle of this.orchestrator.listNodes()) {
210
+ if (known.has(handle.id)) continue;
211
+ console.error(
212
+ `hosted node ${handle.id} (${handle.status}) has an orchestrator handle but no record in hosted-nodes.json; `
213
+ + 'it is left as it is. Run `cadre-host node remove ' + handle.id + '` to stop it and delete its data.',
214
+ );
215
+ }
216
+ }
217
+
218
+ /** Fire-and-forget pass for the timer / exit / startup triggers. */
219
+ private sweep(trigger: string): void {
220
+ void this.reconcile()
221
+ .then((ids) => {
222
+ if (ids.length) log('%s sweep respawned %d hosted node(s): %o', trigger, ids.length, ids);
223
+ })
224
+ .catch((err) => {
225
+ console.error(`hosted node respawn sweep failed: ${errorMessage(err)}`);
226
+ });
227
+ }
228
+
229
+ /**
230
+ * Exit-event trigger. Deliberately cheap: it does not map the handle back to a
231
+ * record (that needs a store read per event), it just asks for a pass.
232
+ */
233
+ private onNodeState(info: ManagedNodeInfo): void {
234
+ if (info.status !== 'stopped') return;
235
+ if (this.exitPassQueued) return;
236
+ this.exitPassQueued = true;
237
+ void this.reconcile()
238
+ .catch((err) => { console.error(`hosted node respawn sweep failed: ${errorMessage(err)}`); })
239
+ .finally(() => { this.exitPassQueued = false; });
240
+ }
241
+
242
+ /** The unserialized body of one pass. */
243
+ private async reconcileOnce(): Promise<string[]> {
244
+ const respawned: string[] = [];
245
+ let candidates: HostedNode[];
246
+ try {
247
+ candidates = this.store.list().filter((n) => SUPERVISED_STATUSES.has(n.status));
248
+ } catch (err) {
249
+ // A malformed hosted-nodes.json throws on every load; log rather than let an
250
+ // unhandled rejection escape the timer.
251
+ log('reconcile could not list hosted nodes: %s', errorMessage(err));
252
+ return respawned;
253
+ }
254
+
255
+ for (const node of candidates) {
256
+ if (this.disposed) break;
257
+ try {
258
+ const id = await this.reconcileOne(node);
259
+ if (id) respawned.push(id);
260
+ } catch (err) {
261
+ // One bad record must never end the sweep — the others are still down.
262
+ log('reconcile of hosted node %s failed: %s', node.id, errorMessage(err));
263
+ }
264
+ }
265
+ return respawned;
266
+ }
267
+
268
+ private async restartOnce(id: string): Promise<void> {
269
+ if (this.disposed) return;
270
+ const node = this.store.get(id);
271
+ if (!node?.dockerId || !SUPERVISED_STATUSES.has(node.status)) return;
272
+ if (!(await this.orchestrator.isRunning(node.dockerId))) return;
273
+ await this.orchestrator.stopContainer(node.dockerId);
274
+ const result = await this.service.respawn(id, { countAttempt: false });
275
+ log('restarted hosted node %s: %s', id, result.outcome);
276
+ }
277
+
278
+ /** Reconcile one record. Returns its id when this pass respawned it. */
279
+ private async reconcileOne(node: HostedNode): Promise<string | undefined> {
280
+ // No handle means nothing was ever spawned — `join` owns that record until
281
+ // it writes one, and replaying it here would race that join.
282
+ if (!node.dockerId) return undefined;
283
+
284
+ if (await this.orchestrator.isRunning(node.dockerId)) {
285
+ this.refillBudgetIfHealthy(node);
286
+ return undefined;
287
+ }
288
+ // The budget is spent, and the last attempt did not stay up long enough to
289
+ // refill it. Checked here and not only when `respawn` throws: a spawn that
290
+ // succeeds and then dies at once (a crash on boot, a port clash) never
291
+ // throws, so a catch-only check would respawn it forever.
292
+ // NOTE: a record at the cap whose last respawn was still up but not yet
293
+ // refilled when the host went down is given up on host restart rather than
294
+ // tried once more; if that is ever seen, skip this check when the host
295
+ // started after `respawn.lastAttemptAt`.
296
+ const attempts = node.respawn?.attempts ?? 0;
297
+ if (attempts >= HOSTED_NODE_RESPAWN_MAX_ATTEMPTS) {
298
+ await this.giveUp(node.id, attempts, 'the node did not stay running after its last respawn');
299
+ return undefined;
300
+ }
301
+ if (!this.backoffElapsed(node)) {
302
+ log('hosted node %s is down but still inside its respawn backoff', node.id);
303
+ return undefined;
304
+ }
305
+ return this.attemptRespawn(node);
306
+ }
307
+
308
+ /**
309
+ * Whether enough time has passed since the last attempt. A record with no
310
+ * attempt history (or an unparsable timestamp — never wedge a node on bad
311
+ * data) is eligible immediately.
312
+ */
313
+ private backoffElapsed(node: HostedNode): boolean {
314
+ const respawn = node.respawn;
315
+ if (!respawn) return true;
316
+ const last = Date.parse(respawn.lastAttemptAt);
317
+ if (Number.isNaN(last)) return true;
318
+ const delay = Math.min(
319
+ HOSTED_NODE_RESPAWN_BACKOFF_BASE_MS * 2 ** respawn.attempts,
320
+ HOSTED_NODE_RESPAWN_BACKOFF_MAX_MS,
321
+ );
322
+ return this.now().getTime() - last >= delay;
323
+ }
324
+
325
+ /**
326
+ * Clear the attempt count once a respawned node has been up long enough to
327
+ * count as healthy, so the next crash gets a full budget instead of inheriting
328
+ * the previous crash loop's.
329
+ */
330
+ private refillBudgetIfHealthy(node: HostedNode): void {
331
+ if (!this.budgetRefillDue(node)) return;
332
+ // Re-read before writing: `node` comes from the snapshot this pass started
333
+ // from, which can be several awaits old, and `store.put` replaces the whole
334
+ // row — writing the stale copy back would undo a claim or a removal that
335
+ // landed mid-pass.
336
+ const current = this.store.get(node.id);
337
+ if (!current?.respawn || !this.budgetRefillDue(current)) return;
338
+ try {
339
+ this.store.put({ ...current, respawn: { ...current.respawn, attempts: 0 } });
340
+ log('hosted node %s is healthy again — respawn budget refilled', node.id);
341
+ } catch (err) {
342
+ // Costs one attempt off the next crash's budget, nothing more.
343
+ log('failed to refill respawn budget for %s: %s', node.id, errorMessage(err));
344
+ }
345
+ }
346
+
347
+ /**
348
+ * Whether a record carries a crash-loop count old enough to clear. An
349
+ * unparsable timestamp is treated as not-due — unlike the backoff check,
350
+ * being wrong here would erase real attempt history rather than free a node.
351
+ */
352
+ private budgetRefillDue(node: HostedNode): boolean {
353
+ const respawn = node.respawn;
354
+ if (!respawn || respawn.attempts === 0) return false;
355
+ const last = Date.parse(respawn.lastAttemptAt);
356
+ if (Number.isNaN(last)) return false;
357
+ return this.now().getTime() - last > HOSTED_NODE_RESPAWN_HEALTHY_MS;
358
+ }
359
+
360
+ /**
361
+ * One respawn attempt, with the give-up check on failure. (The check for an
362
+ * attempt that spawned but then died lives in {@link reconcileOne}.)
363
+ */
364
+ private async attemptRespawn(node: HostedNode): Promise<string | undefined> {
365
+ try {
366
+ const result = await this.service.respawn(node.id);
367
+ switch (result.outcome) {
368
+ case 'abandoned':
369
+ // The node was removed (or given up on) while the spawn was in flight
370
+ // and the ending won. Nothing threw, so no attempt is persisted and the
371
+ // give-up path is never entered — correct: this is not a failed attempt.
372
+ log('respawn of hosted node %s was abandoned (record went %s mid-spawn)', node.id, result.status ?? 'missing');
373
+ return undefined;
374
+ case 'respawned':
375
+ log('respawned hosted node %s → %s', node.id, result.node.dockerId);
376
+ return node.id;
377
+ }
378
+ } catch (err) {
379
+ const message = errorMessage(err);
380
+ // `respawn` persists the incremented counter; re-read it rather than
381
+ // recomputing, so a give-up decision is made on what is actually on disk.
382
+ const attempts = this.store.get(node.id)?.respawn?.attempts ?? 0;
383
+ log('respawn attempt %d for hosted node %s failed: %s', attempts, node.id, message);
384
+ if (attempts >= HOSTED_NODE_RESPAWN_MAX_ATTEMPTS) {
385
+ await this.giveUp(node.id, attempts, message);
386
+ }
387
+ return undefined;
388
+ }
389
+ }
390
+
391
+ /**
392
+ * Stop trying: mark the node `error` and stop whatever child the record
393
+ * still names.
394
+ *
395
+ * The record is written BEFORE the stop for the same reason `remove` does
396
+ * it in that order — the stop fires `onStateChange`, and a pass triggered by
397
+ * it must not see "node gone, record still `joined`" and start respawning
398
+ * again.
399
+ *
400
+ * The child is stopped but NOT removed: `removeContainer` deletes the workdir,
401
+ * and the workdir holds the identity key the node's cadre approved. A later
402
+ * `remove()` (or `reset()`) on an `error` record reclaims it.
403
+ *
404
+ * NOTE: an `error`-after-give-up record therefore keeps its workdir until
405
+ * someone removes it. If stale `error` workdirs ever pile up on real hosts,
406
+ * extend the reap sweep in `hosted-node-service.ts` to cover them.
407
+ */
408
+ private async giveUp(id: string, attempts: number, lastError: string): Promise<void> {
409
+ const node = this.store.get(id);
410
+ if (!node) return;
411
+ // The record may have been removed or given up on while the failing attempt
412
+ // was in flight — a `remove` mid-pass is exactly why `respawn` threw. A stop
413
+ // here would fire against a child `remove` already reclaimed.
414
+ if (!SUPERVISED_STATUSES.has(node.status)) {
415
+ log('hosted node %s went %s before give-up — leaving it alone', id, node.status);
416
+ return;
417
+ }
418
+ this.store.put({
419
+ ...node,
420
+ status: 'error',
421
+ error: `respawn gave up after ${attempts} attempts: ${lastError}`,
422
+ updatedAt: this.now().toISOString(),
423
+ });
424
+ log('gave up respawning hosted node %s after %d attempts: %s', id, attempts, lastError);
425
+ this.service.emit({ kind: 'changed', id });
426
+ if (node.dockerId) {
427
+ try {
428
+ await this.orchestrator.stopContainer(node.dockerId);
429
+ } catch (err) {
430
+ // The record names a live handle in the ordinary case — a failed respawn
431
+ // restores the handle it dropped, and a respawn that spawned but failed
432
+ // to record writes the new one onto the record. Still guarded: a handle
433
+ // can be lost to a host restart (state.json gone, or the child already
434
+ // reaped), and the child is dead either way, which is why we are here.
435
+ log('failed to stop container %s while giving up: %s', node.dockerId, errorMessage(err));
436
+ }
437
+ }
438
+ }
439
+ }