@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,609 @@
1
+ /**
2
+ * HostedNodeService unit tests — "Join a cadre" and what follows, on the host
3
+ * side, exercised against a fake orchestrator (no real child processes), a real
4
+ * on-disk HostedNodeStore, a stub NAT layer and a stubbed `globalThis.fetch` for
5
+ * the child's `/status`.
6
+ *
7
+ * Covers: join → unclaimed (claim secret threaded to the child, persisted,
8
+ * redacted), the claim details, the status watcher (claim → joined, liveness, and
9
+ * a row removed mid-poll), a join by invitation through a failure, Retry and the
10
+ * member's acceptance, respawn (same party, same secret, same ports), remove,
11
+ * reset, and the stuck-spawning reap.
12
+ *
13
+ * A recurring theme: **an ending that lands mid-operation wins.** `join`,
14
+ * `respawn` and the watcher each hold an entry-time copy of the record across a
15
+ * slow `await`, and `HostedNodeStore.put` replaces the whole row — so each must
16
+ * re-read before writing or it resurrects a node the admin just removed. The
17
+ * suites drive that race through `FakeOrchestrator.onCreate` / `onSpawned` (the
18
+ * spawn window) and the `fetch` stub (the status window).
19
+ */
20
+
21
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
22
+ import { mkdtempSync, rmSync } from 'node:fs';
23
+ import { tmpdir } from 'node:os';
24
+ import { join } from 'node:path';
25
+
26
+ import { decodeNodeClaimPayload } from '@serfab/cadre-core';
27
+
28
+ import type { NodeReachability } from '../../nat/types.js';
29
+ import type { HostedNodeAddressSource } from '../claim-details.js';
30
+ import { HostedNodeService, HOSTED_NODE_SPAWNING_TTL_MS } from '../hosted-node-service.js';
31
+ import { HostedNodeStore } from '../hosted-node-store.js';
32
+ import type { HostedNode, HostedNodeChange } from '../types.js';
33
+ import { HostedNodeError } from '../types.js';
34
+ import { FakeOrchestrator } from './fake-orchestrator.js';
35
+ import { ISSUER_KEY, MEMBER_PEER_ID, encodedTestInvitation } from './test-invitation.js';
36
+
37
+ /** A peer id the child reports; the payload codec checks it parses. */
38
+ const PEER_ID = '12D3KooWA9hbnKrRnPRSPTRkzXqTHzGE8YpJ3JHZmQ5tGwLRTMmp';
39
+ /** The owner key `/status.node.claimedBy` reports after a claim. */
40
+ const OWNER_KEY = Buffer.alloc(32, 7).toString('base64url');
41
+
42
+ /** A NAT layer with no public address and no verdicts. */
43
+ const NO_ADDRESSES: HostedNodeAddressSource = {
44
+ publicAddressesFor: () => [],
45
+ getStatus: () => ({ nodes: [] }),
46
+ };
47
+
48
+ /** A store whose next `put` fails once — the post-spawn write-failure path. */
49
+ class FlakyStore extends HostedNodeStore {
50
+ failNextPut = false;
51
+
52
+ override put(node: HostedNode): void {
53
+ if (this.failNextPut) {
54
+ this.failNextPut = false;
55
+ throw new HostedNodeError('storage_error', 'disk full');
56
+ }
57
+ super.put(node);
58
+ }
59
+ }
60
+
61
+ let tmpRoot: string;
62
+ const originalFetch = globalThis.fetch;
63
+
64
+ beforeEach(() => {
65
+ tmpRoot = mkdtempSync(join(tmpdir(), 'cadre-host-hosted-svc-'));
66
+ });
67
+
68
+ afterEach(() => {
69
+ globalThis.fetch = originalFetch;
70
+ try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
71
+ });
72
+
73
+ /** What a child's `/status` answers, in the parts the service reads. */
74
+ interface StatusAnswer {
75
+ peerId?: string | null;
76
+ multiaddrs?: string[];
77
+ claim?: 'awaiting' | 'claimed';
78
+ partyId?: string;
79
+ claimedBy?: string;
80
+ connections?: number;
81
+ invitation?: { state: 'pending' | 'accepted' | 'failed'; memberPeerId?: string; error?: string; retryable?: boolean };
82
+ }
83
+
84
+ function statusBody(answer: StatusAnswer): unknown {
85
+ const peerId = answer.peerId === undefined ? PEER_ID : answer.peerId;
86
+ return {
87
+ status: 'healthy',
88
+ peerId,
89
+ multiaddrs: answer.multiaddrs ?? [`/ip4/127.0.0.1/tcp/4101/ws/p2p/${peerId}`, `/ip4/192.168.1.20/tcp/4101/ws/p2p/${peerId}`],
90
+ node: {
91
+ running: true,
92
+ peerId,
93
+ partyId: answer.partyId ?? 'unclaimed',
94
+ profile: 'storage',
95
+ strands: { total: 0, syncing: 0, active: 0, idle: 0, hibernating: 0 },
96
+ connectionPaths: { total: answer.connections ?? 0, relayed: 0, direct: answer.connections ?? 0, stuckOnRelay: 0, byTransport: {} },
97
+ claim: answer.claim ?? 'awaiting',
98
+ ...(answer.claimedBy ? { claimedBy: answer.claimedBy } : {}),
99
+ ...(answer.invitation ? { invitation: answer.invitation } : {}),
100
+ },
101
+ };
102
+ }
103
+
104
+ /**
105
+ * Stub the child's `/status` with `answer`. `duringRequest` — when given — runs
106
+ * exactly once and is awaited before the response resolves, so whatever it starts
107
+ * lands *inside* the status window. It is the `fetch` analogue of
108
+ * `FakeOrchestrator.onCreate`.
109
+ */
110
+ function stubStatusFetch(answer: StatusAnswer | 'down', duringRequest?: () => unknown): void {
111
+ let ran: Promise<unknown> | undefined;
112
+ globalThis.fetch = (async () => {
113
+ ran ??= (async () => duringRequest?.())();
114
+ await ran;
115
+ if (answer === 'down') throw new Error('ECONNREFUSED');
116
+ return { ok: true, json: async () => statusBody(answer) } as unknown as Response;
117
+ }) as typeof globalThis.fetch;
118
+ }
119
+
120
+ interface Harness {
121
+ orch: FakeOrchestrator;
122
+ store: HostedNodeStore;
123
+ svc: HostedNodeService;
124
+ changes: HostedNodeChange[];
125
+ }
126
+
127
+ function makeHarness(opts: { store?: HostedNodeStore; addresses?: HostedNodeAddressSource; now?: () => Date } = {}): Harness {
128
+ const orch = new FakeOrchestrator();
129
+ const store = opts.store ?? new HostedNodeStore(join(tmpRoot, 'hosted'));
130
+ const svc = new HostedNodeService({
131
+ orchestrator: orch,
132
+ store,
133
+ addresses: opts.addresses ?? NO_ADDRESSES,
134
+ ...(opts.now ? { now: opts.now } : {}),
135
+ });
136
+ const changes: HostedNodeChange[] = [];
137
+ svc.onChange((c) => changes.push(c));
138
+ return { orch, store, svc, changes };
139
+ }
140
+
141
+ function requireNode(store: HostedNodeStore, id: string): HostedNode {
142
+ const node = store.get(id);
143
+ if (!node) throw new Error(`no hosted node ${id}`);
144
+ return node;
145
+ }
146
+
147
+ function secretOf(node: HostedNode): string {
148
+ if (node.join.kind !== 'claim') throw new Error(`hosted node ${node.id} joins by ${node.join.kind}, not by claim`);
149
+ return node.join.secret;
150
+ }
151
+
152
+ describe('HostedNodeService.join', () => {
153
+ it('spawns a storage node under the placeholder party with a fresh claim secret, then records it unclaimed', async () => {
154
+ const { orch, store, svc, changes } = makeHarness();
155
+
156
+ const view = await svc.join();
157
+
158
+ expect(view.id).toMatch(/^hn_[A-Za-z0-9_-]{16}$/);
159
+ expect(view).toMatchObject({ status: 'unclaimed', partyId: 'unclaimed', profile: 'storage', join: { kind: 'claim' }, dockerId: 'dock_1' });
160
+ // The secret never crosses the boundary.
161
+ expect((view.join as Record<string, unknown>).secret).toBeUndefined();
162
+
163
+ const stored = requireNode(store, view.id);
164
+ expect(secretOf(stored)).toMatch(/^[A-Za-z0-9_-]{43}$/);
165
+ expect(stored.statusEndpoint).toBe('http://127.0.0.1:9001/status');
166
+ // The child got exactly the record's secret, through the request and nothing else.
167
+ expect(orch.createCalls).toEqual([{
168
+ containerId: view.id,
169
+ partyId: 'unclaimed',
170
+ bootstrapNodes: [],
171
+ profile: 'storage',
172
+ claimSecret: secretOf(stored),
173
+ }]);
174
+ expect(changes).toEqual([{ kind: 'added', id: view.id }]);
175
+ });
176
+
177
+ it('gives two joins at once their own id, secret and child', async () => {
178
+ const { orch, store, svc } = makeHarness();
179
+ orch.createDelayMs = 10;
180
+
181
+ const [a, b] = await Promise.all([svc.join(), svc.join()]);
182
+
183
+ expect(a.id).not.toBe(b.id);
184
+ expect(secretOf(requireNode(store, a.id))).not.toBe(secretOf(requireNode(store, b.id)));
185
+ expect(orch.createCalls.map((c) => c.containerId).sort()).toEqual([a.id, b.id].sort());
186
+ });
187
+
188
+ it('lets a remove that lands mid-spawn win, and reclaims the new child', async () => {
189
+ const { orch, store, svc } = makeHarness();
190
+
191
+ // The admin's DELETE lands inside the spawn window. The record names no
192
+ // dockerId yet, so remove's own cleanup finds nothing to stop — the abandon
193
+ // path here is the only thing that can reclaim this child.
194
+ orch.createDelayMs = 20;
195
+ let removed: Promise<void> | undefined;
196
+ orch.onCreate = (request) => { removed ??= svc.remove(request.containerId); };
197
+
198
+ await expect(svc.join()).rejects.toMatchObject({ code: 'not_found' });
199
+ await removed;
200
+
201
+ expect(store.list()).toEqual([]);
202
+ expect(orch.removed).toEqual(['dock_1']);
203
+ });
204
+
205
+ it('marks the record error and reclaims nothing when the spawn itself throws', async () => {
206
+ const { orch, store, svc, changes } = makeHarness();
207
+ orch.failCreate = true;
208
+
209
+ await expect(svc.join()).rejects.toMatchObject({ code: 'orchestrator_error' });
210
+
211
+ const [stored] = store.list();
212
+ expect(stored?.status).toBe('error');
213
+ expect(stored?.error).toContain('spawn boom');
214
+ expect(orch.removed).toEqual([]);
215
+ // `createContainer`'s own unwind removed the directory its failed spawn created.
216
+ expect(orch.reclaimedWorkdirs).toEqual([]);
217
+ expect(changes).toEqual([{ kind: 'changed', id: stored!.id }]);
218
+ });
219
+
220
+ it('reclaims the child and marks error when the post-spawn write fails', async () => {
221
+ const store = new FlakyStore(join(tmpRoot, 'hosted'));
222
+ const { orch, svc } = makeHarness({ store });
223
+ // The first put (the spawning row) succeeds; the second (unclaimed) fails.
224
+ orch.onSpawned = () => { store.failNextPut = true; };
225
+
226
+ await expect(svc.join()).rejects.toMatchObject({ code: 'orchestrator_error' });
227
+
228
+ expect(orch.removed).toEqual(['dock_1']);
229
+ expect(store.list()[0]?.status).toBe('error');
230
+ });
231
+ });
232
+
233
+ describe('HostedNodeService.claimDetails', () => {
234
+ it('answers the QR payload: public addresses with the peer id first, then the LAN addresses, loopback dropped', async () => {
235
+ const reachability: NodeReachability = {
236
+ nodeId: '', running: true, verdict: 'mapped', reason: null,
237
+ tcp: { internalPort: 4001, externalPort: 4001, source: 'upnp', leaseExpiresAt: null, error: null },
238
+ ws: { internalPort: 4101, externalPort: 4101, source: 'upnp', leaseExpiresAt: null, error: null },
239
+ publicAddrs: [],
240
+ };
241
+ const asked: Array<{ nodeId: string; ports: { p2p: number; ws?: number } }> = [];
242
+ const { store, svc } = makeHarness({
243
+ addresses: {
244
+ publicAddressesFor: (nodeId, ports) => {
245
+ asked.push({ nodeId, ports });
246
+ return ['/ip4/203.0.113.5/tcp/4001', '/ip4/203.0.113.5/tcp/4101/ws'];
247
+ },
248
+ getStatus: () => ({ nodes: [{ ...reachability, nodeId: asked[0]?.nodeId ?? '' }] }),
249
+ },
250
+ });
251
+ const view = await svc.join();
252
+ stubStatusFetch({});
253
+
254
+ const details = await svc.claimDetails(view.id);
255
+
256
+ // The fake's ports for dock_1, straight from the orchestrator handle.
257
+ expect(asked).toEqual([{ nodeId: view.id, ports: { health: 9001, metrics: 9101, p2p: 4001, ws: 4101 } }]);
258
+ expect(details.peerId).toBe(PEER_ID);
259
+ expect(details.multiaddrs).toEqual([
260
+ `/ip4/203.0.113.5/tcp/4001/p2p/${PEER_ID}`,
261
+ `/ip4/203.0.113.5/tcp/4101/ws/p2p/${PEER_ID}`,
262
+ `/ip4/192.168.1.20/tcp/4101/ws/p2p/${PEER_ID}`,
263
+ ]);
264
+ expect(details.reachability?.verdict).toBe('mapped');
265
+ expect(decodeNodeClaimPayload(details.payload)).toEqual({
266
+ peerId: PEER_ID,
267
+ multiaddrs: details.multiaddrs,
268
+ secret: secretOf(requireNode(store, view.id)),
269
+ });
270
+ // The peer id is cached on the record; the view carries it.
271
+ expect(svc.get(view.id)?.peerId).toBe(PEER_ID);
272
+ });
273
+
274
+ it('is 503 until the child answers, and 409 once the node is no longer unclaimed', async () => {
275
+ const { store, svc } = makeHarness();
276
+ const view = await svc.join();
277
+
278
+ stubStatusFetch('down');
279
+ await expect(svc.claimDetails(view.id)).rejects.toMatchObject({ code: 'node_unavailable' });
280
+ stubStatusFetch({ peerId: null });
281
+ await expect(svc.claimDetails(view.id)).rejects.toMatchObject({ code: 'node_unavailable' });
282
+
283
+ store.put({ ...requireNode(store, view.id), status: 'joined', partyId: 'party-P' });
284
+ await expect(svc.claimDetails(view.id)).rejects.toMatchObject({ code: 'invalid_state' });
285
+ await expect(svc.claimDetails('hn_nobody')).rejects.toMatchObject({ code: 'not_found' });
286
+ });
287
+ });
288
+
289
+ describe('HostedNodeService status watcher', () => {
290
+ it('writes joined with the party and owner the claim named, once the node is in that party', async () => {
291
+ const { store, svc, changes } = makeHarness();
292
+ const view = await svc.join();
293
+ changes.length = 0;
294
+
295
+ // The node records the claim before it restarts into the party: `claimed`
296
+ // with the placeholder party is a claim in progress, not a joined node.
297
+ stubStatusFetch({ claim: 'claimed', claimedBy: OWNER_KEY, partyId: 'unclaimed' });
298
+ await svc.pollStatuses();
299
+ expect(requireNode(store, view.id).status).toBe('unclaimed');
300
+ expect(changes).toEqual([]);
301
+
302
+ stubStatusFetch({ claim: 'claimed', claimedBy: OWNER_KEY, partyId: 'party-P', connections: 1 });
303
+ await svc.pollStatuses();
304
+ expect(requireNode(store, view.id)).toMatchObject({
305
+ status: 'joined', partyId: 'party-P', ownerKey: OWNER_KEY, peerId: PEER_ID, connected: true,
306
+ });
307
+ expect(changes).toEqual([{ kind: 'claimed', id: view.id }]);
308
+
309
+ // A joined node's `connected` follows the count, published only on a change.
310
+ await svc.pollStatuses();
311
+ expect(changes).toHaveLength(1);
312
+ stubStatusFetch({ claim: 'claimed', claimedBy: OWNER_KEY, partyId: 'party-P', connections: 0 });
313
+ void svc.pollStatuses(); // the 15 s cadence: a second poll in the same tick is skipped
314
+ await svc.pollStatuses();
315
+ expect(requireNode(store, view.id).connected).toBe(true);
316
+ });
317
+
318
+ it('writes nothing for a row removed while its poll was in flight', async () => {
319
+ const { orch, store, svc, changes } = makeHarness();
320
+ const view = await svc.join();
321
+ changes.length = 0;
322
+
323
+ let removed: Promise<void> | undefined;
324
+ stubStatusFetch({ claim: 'claimed', claimedBy: OWNER_KEY, partyId: 'party-P' }, () => {
325
+ removed = svc.remove(view.id);
326
+ return removed;
327
+ });
328
+
329
+ await svc.pollStatuses();
330
+ await removed;
331
+
332
+ expect(store.get(view.id)).toBeUndefined();
333
+ expect(orch.removed).toEqual(['dock_1']);
334
+ expect(changes).toEqual([{ kind: 'removed', id: view.id }]);
335
+ });
336
+
337
+ it('retries a node that does not answer on the next pass', async () => {
338
+ const { store, svc } = makeHarness();
339
+ const view = await svc.join();
340
+
341
+ stubStatusFetch('down');
342
+ await svc.pollStatuses();
343
+ expect(requireNode(store, view.id).status).toBe('unclaimed');
344
+
345
+ stubStatusFetch({ claim: 'claimed', claimedBy: OWNER_KEY, partyId: 'party-P' });
346
+ await svc.pollStatuses();
347
+ expect(requireNode(store, view.id).status).toBe('joined');
348
+ });
349
+ });
350
+
351
+ describe('HostedNodeService join by invitation', () => {
352
+ it('follows the node from joining through a failure no member answered, a Retry, and the member\'s acceptance', async () => {
353
+ const { orch, store, svc, changes } = makeHarness();
354
+ const invitation = encodedTestInvitation('party-P');
355
+
356
+ const view = await svc.join({ invitation });
357
+
358
+ // The invitation's party from the start; the bundle reaches the child and nothing else.
359
+ expect(view).toMatchObject({ status: 'joining', partyId: 'party-P', join: { kind: 'invitation' } });
360
+ expect(JSON.stringify(view)).not.toContain(invitation);
361
+ expect(orch.createCalls).toEqual([{ containerId: view.id, partyId: 'party-P', bootstrapNodes: [], profile: 'storage', invitation }]);
362
+
363
+ stubStatusFetch({ partyId: 'party-P', invitation: { state: 'pending' } });
364
+ await svc.pollStatuses();
365
+ expect(requireNode(store, view.id).status).toBe('joining');
366
+
367
+ // No member reachable: `error`, kept for Retry, its child stopped but not reclaimed.
368
+ stubStatusFetch({ partyId: 'party-P', invitation: { state: 'failed', error: 'No member named in the invitation could be reached.', retryable: true } });
369
+ await svc.pollStatuses();
370
+ expect(requireNode(store, view.id)).toMatchObject({ status: 'error', error: 'No member named in the invitation could be reached.', retryable: true });
371
+ expect(orch.stopped).toEqual(['dock_1']);
372
+ expect(orch.removed).toEqual([]);
373
+
374
+ // The new child starts its redemption over.
375
+ stubStatusFetch({ partyId: 'party-P', invitation: { state: 'pending' } });
376
+ const retried = await svc.retry(view.id);
377
+ expect(retried).toMatchObject({ status: 'joining', dockerId: 'dock_2' });
378
+ expect(retried.error).toBeUndefined();
379
+ expect(retried.retryable).toBeUndefined();
380
+ expect(orch.createCalls[1]).toMatchObject({ containerId: view.id, invitation });
381
+ // An explicit Retry spends no respawn attempt.
382
+ expect(requireNode(store, view.id).respawn).toBeUndefined();
383
+
384
+ stubStatusFetch({ partyId: 'party-P', connections: 1, invitation: { state: 'accepted', memberPeerId: MEMBER_PEER_ID } });
385
+ await svc.pollStatuses();
386
+ expect(requireNode(store, view.id)).toMatchObject({
387
+ status: 'joined', partyId: 'party-P', ownerKey: ISSUER_KEY, memberPeerId: MEMBER_PEER_ID, peerId: PEER_ID, connected: true,
388
+ });
389
+ expect(changes.map((c) => c.kind)).toEqual(['added', 'changed', 'changed', 'changed', 'joined']);
390
+ });
391
+ });
392
+
393
+ // The orchestrator refuses to re-spawn a container whose child is still running, so
394
+ // each case that expects a spawn has the first child (`dock_1`) go down first.
395
+ describe('HostedNodeService.respawn', () => {
396
+ it('replays the record\'s party and secret, comes back on the same ports, swaps the handles and leaves status alone', async () => {
397
+ const { orch, store, svc, changes } = makeHarness();
398
+ const view = await svc.join();
399
+ const secret = secretOf(requireNode(store, view.id));
400
+ store.put({ ...requireNode(store, view.id), status: 'joined', partyId: 'party-P', ownerKey: OWNER_KEY, connected: true });
401
+ const portsBefore = orch.getNode(view.id)!.ports;
402
+ changes.length = 0;
403
+
404
+ orch.crash('dock_1');
405
+ const result = await svc.respawn(view.id);
406
+
407
+ expect(result).toMatchObject({ outcome: 'respawned', node: { status: 'joined', dockerId: 'dock_2' } });
408
+ // The claimed party now, and the same secret: cadre-cli honours claim.json and
409
+ // answers a rival `already-claimed` with the secret still set.
410
+ expect(orch.createCalls[1]).toEqual({
411
+ containerId: view.id, partyId: 'party-P', bootstrapNodes: [], profile: 'storage', claimSecret: secret,
412
+ });
413
+ expect(orch.getNode(view.id)!.ports).toEqual(portsBefore);
414
+
415
+ const stored = requireNode(store, view.id);
416
+ expect(stored).toMatchObject({ status: 'joined', partyId: 'party-P', ownerKey: OWNER_KEY, dockerId: 'dock_2', statusEndpoint: 'http://127.0.0.1:9001/status' });
417
+ expect(stored.respawn?.attempts).toBe(1);
418
+ // Unknown until the watcher reads the new child.
419
+ expect(stored.connected).toBeUndefined();
420
+ // Nothing was stopped or reclaimed — the workdir (identity key, node-local
421
+ // stores) has to survive for the respawn to be the same node.
422
+ expect(orch.stopped).toEqual([]);
423
+ expect(orch.removed).toEqual([]);
424
+ expect(changes).toEqual([{ kind: 'changed', id: view.id }]);
425
+ });
426
+
427
+ it('refuses an error or spawning record', async () => {
428
+ const { orch, store, svc } = makeHarness();
429
+ const view = await svc.join();
430
+
431
+ store.put({ ...requireNode(store, view.id), status: 'error', error: 'gave up' });
432
+ await expect(svc.respawn(view.id)).rejects.toMatchObject({ code: 'invalid_state' });
433
+ // A host that died mid-join leaves exactly this row; replaying it would race the join.
434
+ store.put({ ...requireNode(store, view.id), status: 'spawning' });
435
+ await expect(svc.respawn(view.id)).rejects.toMatchObject({ code: 'invalid_state' });
436
+ expect(orch.createCalls).toHaveLength(1);
437
+ });
438
+
439
+ it('records the attempt and throws when the spawn fails, keeping the old handles', async () => {
440
+ const { orch, store, svc } = makeHarness();
441
+ const view = await svc.join();
442
+ orch.failCreate = true;
443
+
444
+ await expect(svc.respawn(view.id)).rejects.toMatchObject({ code: 'orchestrator_error' });
445
+
446
+ const stored = requireNode(store, view.id);
447
+ expect(stored.respawn?.attempts).toBe(1);
448
+ expect(stored.dockerId).toBe('dock_1');
449
+ expect(stored.status).toBe('unclaimed');
450
+ });
451
+
452
+ it('stops — but never reclaims — the new child when the post-spawn write fails', async () => {
453
+ const store = new FlakyStore(join(tmpRoot, 'hosted'));
454
+ const { orch, svc } = makeHarness({ store });
455
+ const view = await svc.join();
456
+ store.failNextPut = true;
457
+
458
+ orch.crash('dock_1');
459
+ await expect(svc.respawn(view.id)).rejects.toMatchObject({ code: 'orchestrator_error' });
460
+
461
+ // Stopped, not removed: `removeContainer` deletes the workdir, and the
462
+ // workdir is the identity key that makes a later respawn the SAME node.
463
+ expect(orch.stopped).toEqual(['dock_2']);
464
+ expect(orch.removed).toEqual([]);
465
+ // The record names the child that actually exists; the attempt failed, so
466
+ // status and updatedAt are left alone.
467
+ const stored = requireNode(store, view.id);
468
+ expect(stored).toMatchObject({ dockerId: 'dock_2', status: 'unclaimed', updatedAt: view.updatedAt });
469
+ expect(stored.respawn?.attempts).toBe(1);
470
+ });
471
+
472
+ it('lets a remove that lands after the spawn dropped the old handle win, and reclaims the new child', async () => {
473
+ const { orch, store, svc } = makeHarness();
474
+ const view = await svc.join();
475
+
476
+ // The admin's DELETE lands in the window *after* the spawn dropped the old
477
+ // handle — the window `abandonRespawn` was written for. The remove aimed its
478
+ // stop and reclaim at dock_1, which the spawn had already dropped, so it
479
+ // cleaned up nothing at all; dock_2 is the only thing holding the ports and workdir.
480
+ let removed: Promise<void> | undefined;
481
+ orch.onSpawned = () => { removed ??= svc.remove(view.id); };
482
+
483
+ orch.crash('dock_1');
484
+ const result = await svc.respawn(view.id);
485
+ await removed;
486
+
487
+ expect(result).toEqual({ outcome: 'abandoned' });
488
+ expect(orch.stopped).toEqual(['dock_2']);
489
+ expect(orch.removed).toEqual(['dock_2']);
490
+ expect(store.get(view.id)).toBeUndefined();
491
+ });
492
+
493
+ it('stops but does not reclaim the new child when the record goes error mid-spawn', async () => {
494
+ const { orch, store, svc } = makeHarness();
495
+ const view = await svc.join();
496
+
497
+ // An `error` write landing inside the spawn window. Both spawns share one
498
+ // workdir, so reclaiming here would delete the identity key `error` keeps;
499
+ // the record is pointed at the new child instead, so a later remove reclaims it.
500
+ orch.createDelayMs = 20;
501
+ orch.onCreate = () => {
502
+ store.put({ ...requireNode(store, view.id), status: 'error', error: 'gave up' });
503
+ };
504
+
505
+ orch.crash('dock_1');
506
+ const result = await svc.respawn(view.id);
507
+
508
+ expect(result).toEqual({ outcome: 'abandoned', status: 'error' });
509
+ expect(orch.stopped).toContain('dock_2');
510
+ expect(orch.removed).toEqual([]);
511
+ expect(requireNode(store, view.id)).toMatchObject({ status: 'error', dockerId: 'dock_2' });
512
+ });
513
+ });
514
+
515
+ describe('HostedNodeService.remove', () => {
516
+ it('deletes the row BEFORE stopping the child, then reclaims it', async () => {
517
+ const { orch, store, svc, changes } = makeHarness();
518
+ const view = await svc.join();
519
+ changes.length = 0;
520
+ // The stop fires the orchestrator's state-change; the supervisor's pass must
521
+ // already find no record, or it brings back a node the admin just removed.
522
+ let rowAtStop: HostedNode | undefined;
523
+ orch.onStop = () => { rowAtStop = store.get(view.id); };
524
+
525
+ await svc.remove(view.id);
526
+
527
+ expect(rowAtStop).toBeUndefined();
528
+ expect(orch.stopped).toEqual(['dock_1']);
529
+ expect(orch.removed).toEqual(['dock_1']);
530
+ expect(orch.reclaimedWorkdirs).toEqual([]);
531
+ expect(changes).toEqual([{ kind: 'removed', id: view.id }]);
532
+ // Gone for good: the second remove finds neither a row nor a handle.
533
+ await expect(svc.remove(view.id)).rejects.toMatchObject({ code: 'not_found' });
534
+ });
535
+
536
+ it('reclaims the workdir by name for a record that never got a dockerId', async () => {
537
+ const { orch, store, svc } = makeHarness();
538
+ const at = '2025-01-01T00:00:00.000Z';
539
+ store.put({
540
+ id: 'hn_stuck', join: { kind: 'claim', secret: 's' }, partyId: 'unclaimed', profile: 'storage',
541
+ status: 'spawning', createdAt: at, updatedAt: at,
542
+ });
543
+
544
+ await svc.remove('hn_stuck');
545
+
546
+ expect(store.get('hn_stuck')).toBeUndefined();
547
+ expect(orch.reclaimedWorkdirs).toEqual(['hn_stuck']);
548
+ expect(orch.stopped).toEqual([]);
549
+ });
550
+
551
+ it('removes a handle no record names, so a lost hosted-nodes.json cannot strand a child', async () => {
552
+ const { orch, svc, changes } = makeHarness();
553
+ const spawn = await orch.createContainer({ containerId: 'hn_orphan', partyId: 'party-P', bootstrapNodes: [], profile: 'storage' });
554
+
555
+ await svc.remove('hn_orphan');
556
+
557
+ expect(orch.stopped).toEqual([spawn.dockerId]);
558
+ expect(orch.removed).toEqual([spawn.dockerId]);
559
+ expect(changes).toEqual([{ kind: 'removed', id: 'hn_orphan' }]);
560
+ });
561
+ });
562
+
563
+ describe('HostedNodeService.reset', () => {
564
+ it('removes the node and starts a fresh one with a new id and secret', async () => {
565
+ const { orch, store, svc } = makeHarness();
566
+ const first = await svc.join();
567
+ const firstSecret = secretOf(requireNode(store, first.id));
568
+
569
+ const second = await svc.reset(first.id);
570
+
571
+ expect(second.id).not.toBe(first.id);
572
+ expect(second.status).toBe('unclaimed');
573
+ expect(store.get(first.id)).toBeUndefined();
574
+ expect(secretOf(requireNode(store, second.id))).not.toBe(firstSecret);
575
+ expect(orch.removed).toEqual(['dock_1']);
576
+ expect(orch.createCalls[1]?.containerId).toBe(second.id);
577
+ });
578
+ });
579
+
580
+ describe('HostedNodeService.reapStuckSpawning', () => {
581
+ /** A record whose host died right after writing the `spawning` row. */
582
+ const stuckRecord = (id: string, at: string): HostedNode => ({
583
+ id, join: { kind: 'claim', secret: 's' }, partyId: 'unclaimed', profile: 'storage',
584
+ status: 'spawning', createdAt: at, updatedAt: at,
585
+ });
586
+
587
+ it('marks a record stuck past the TTL error and reclaims what the orchestrator can find', async () => {
588
+ let clock = new Date('2025-01-01T00:00:00.000Z');
589
+ const { orch, store, svc, changes } = makeHarness({ now: () => clock });
590
+ // One whose child was spawned before the host died (the orchestrator knows it
591
+ // by dockerId), one that never got that far.
592
+ const spawn = await orch.createContainer({ containerId: 'hn_spawned', partyId: 'unclaimed', bootstrapNodes: [], profile: 'storage' });
593
+ store.put(stuckRecord('hn_spawned', clock.toISOString()));
594
+ store.put(stuckRecord('hn_never', clock.toISOString()));
595
+ clock = new Date(clock.getTime() + HOSTED_NODE_SPAWNING_TTL_MS + 60_000);
596
+ store.put(stuckRecord('hn_fresh', clock.toISOString()));
597
+
598
+ const reaped = await svc.reapStuckSpawning();
599
+
600
+ expect(reaped.sort()).toEqual(['hn_never', 'hn_spawned']);
601
+ expect(requireNode(store, 'hn_spawned').status).toBe('error');
602
+ expect(requireNode(store, 'hn_never').error).toMatch(/stuck starting/);
603
+ expect(requireNode(store, 'hn_fresh').status).toBe('spawning');
604
+ expect(orch.stopped).toEqual([spawn.dockerId]);
605
+ expect(orch.removed).toEqual([spawn.dockerId]);
606
+ expect(orch.reclaimedWorkdirs).toEqual(['hn_never']);
607
+ expect(changes.map((c) => c.id).sort()).toEqual(['hn_never', 'hn_spawned']);
608
+ });
609
+ });