@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
@@ -1,1193 +0,0 @@
1
- /**
2
- * DonationService unit tests — the donate-a-node lifecycle on the host side,
3
- * exercised against a fake orchestrator (no real child processes) and a real
4
- * on-disk DonationStore + GrantService.
5
- *
6
- * Covers: provision → awaiting_seed (pinned keys threaded, seedToken persisted +
7
- * redacted), seedToken survival across a store reconstruct, per-grant quota-race
8
- * serialization, reclaim-on-post-spawn-failure, seeding, respawn, terminate, the
9
- * stale-awaiting_seed reap, and the stuck-provisioning reap (the host died
10
- * between writing the row and finishing the spawn).
11
- *
12
- * A recurring theme: **an ending that lands mid-operation wins.** `provision`,
13
- * `applySeed`, and `respawn` each hold an entry-time copy of the record across a
14
- * slow `await`, and `DonationStore.put` replaces the whole row — so each must
15
- * re-read before writing or it resurrects a loan the borrower just ended. The
16
- * three suites drive that race through `FakeOrchestrator.onCreate` / `onSpawned`
17
- * (the spawn window, before and after the orchestrator drops the old handle) and
18
- * a stubbed `globalThis.fetch` (seed window).
19
- *
20
- * `applySeed` is exercised here against that `fetch` stub. `getPeer` still does a
21
- * real `fetch` to a live node, so its happy path lives in the cross-package
22
- * integration scenario (`cadre-host-node-donation`), not here.
23
- */
24
-
25
- import { afterEach, beforeEach, describe, expect, it } from 'vitest';
26
- import { mkdtempSync, rmSync } from 'node:fs';
27
- import { tmpdir } from 'node:os';
28
- import { join } from 'node:path';
29
-
30
- import {
31
- DonationService,
32
- DONATION_AWAITING_SEED_TTL_MS,
33
- DONATION_PROVISIONING_TTL_MS,
34
- } from '../donation-service.js';
35
- import { DonationStore } from '../donation-store.js';
36
- import { GrantService } from '../grant-service.js';
37
- import { GrantStore } from '../grant-store.js';
38
- import type { Donation } from '../types.js';
39
- import { DonationError } from '../types.js';
40
- import { FakeOrchestrator } from './fake-orchestrator.js';
41
-
42
- /**
43
- * A well-formed 32-byte base64url owner key. `provision` shape-checks the pins at
44
- * the boundary, so a placeholder string like `OWNER_KEY` is now a
45
- * rejection rather than an opaque fixture.
46
- */
47
- const OWNER_KEY = Buffer.alloc(32, 7).toString('base64url');
48
-
49
- /** A store whose next `put` fails once — the post-spawn write-failure path. */
50
- class FlakyDonationStore extends DonationStore {
51
- failNextPut = false;
52
-
53
- override put(donation: Donation): void {
54
- if (this.failNextPut) {
55
- this.failNextPut = false;
56
- throw new DonationError('storage_error', 'disk full');
57
- }
58
- super.put(donation);
59
- }
60
- }
61
-
62
- let tmpRoot: string;
63
- const originalFetch = globalThis.fetch;
64
-
65
- beforeEach(() => {
66
- tmpRoot = mkdtempSync(join(tmpdir(), 'cadre-host-donation-svc-'));
67
- });
68
-
69
- afterEach(() => {
70
- globalThis.fetch = originalFetch;
71
- try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
72
- });
73
-
74
- /**
75
- * Stub the donated node's `POST /seed` with `body`. `duringRequest` — when given
76
- * — runs exactly once and is awaited before the response resolves, so whatever
77
- * it starts lands *inside* the seed window. It is the `fetch` analogue of
78
- * `FakeOrchestrator.onCreate`.
79
- */
80
- function stubSeedFetch(body: unknown, duringRequest?: () => unknown): void {
81
- let ran: Promise<unknown> | undefined;
82
- globalThis.fetch = (async () => {
83
- ran ??= (async () => duringRequest?.())();
84
- await ran;
85
- return { ok: true, json: async () => body } as unknown as Response;
86
- }) as typeof globalThis.fetch;
87
- }
88
-
89
- function makeGrants(opts?: { maxNodes?: number }): { grants: GrantService; token: string } {
90
- const grants = new GrantService({ store: new GrantStore(join(tmpRoot, 'grants')) });
91
- const token = grants.issue({ label: 'Alice', ...(opts?.maxNodes ? { maxNodes: opts.maxNodes } : {}) }).token;
92
- return { grants, token };
93
- }
94
-
95
- const baseRequest = (grantToken: string) => ({
96
- grantToken,
97
- partyId: 'party-P',
98
- bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWA9hbnKrRnPRSPTRkzXqTHzGE8YpJ3JHZmQ5tGwLRTMmp'],
99
- ownerKeys: [OWNER_KEY],
100
- });
101
-
102
- describe('DonationService.provision', () => {
103
- it('spawns → awaiting_seed, threads pinned owner keys, persists (redacts) the seedToken', async () => {
104
- const orch = new FakeOrchestrator();
105
- const store = new DonationStore(join(tmpRoot, 'donations'));
106
- const { grants, token } = makeGrants();
107
- const svc = new DonationService({ orchestrator: orch, grants, store });
108
-
109
- const view = await svc.provision(baseRequest(token));
110
-
111
- expect(view.status).toBe('awaiting_seed');
112
- expect(view.partyId).toBe('party-P');
113
- expect(view.profile).toBe('storage'); // default
114
- expect(view.grantToken).toBe(token);
115
- // Redacted: host-only secret + loopback URL never cross the boundary.
116
- expect((view as Record<string, unknown>).seedToken).toBeUndefined();
117
- expect((view as Record<string, unknown>).seedEndpoint).toBeUndefined();
118
-
119
- // The orchestrator got the requester's pinned owner key (the seed-trust anchor).
120
- expect(orch.createCalls).toHaveLength(1);
121
- expect(orch.createCalls[0].pinnedOwnerKeys).toEqual([OWNER_KEY]);
122
- expect(orch.createCalls[0].containerId).toBe(view.id);
123
- expect(orch.createCalls[0].partyId).toBe('party-P');
124
-
125
- // But the record on disk DOES retain the seedToken (crash-recovery).
126
- const stored = store.get(view.id);
127
- expect(stored?.seedToken).toBe('seed-token-1');
128
- expect(stored?.seedEndpoint).toBe('http://127.0.0.1:9001/seed');
129
- expect(store.liveNodeCount(token)).toBe(1);
130
- });
131
-
132
- // A phone has no address to give: it sends no bootstrap nodes and dials the lent
133
- // node itself. The empty list is a real spawn input, forwarded and persisted as given.
134
- it('provisions with no bootstrap nodes, forwarding and persisting the empty list', async () => {
135
- const orch = new FakeOrchestrator();
136
- const store = new DonationStore(join(tmpRoot, 'donations'));
137
- const { grants, token } = makeGrants();
138
- const svc = new DonationService({ orchestrator: orch, grants, store });
139
-
140
- const view = await svc.provision({ ...baseRequest(token), bootstrapNodes: [] });
141
-
142
- expect(view.status).toBe('awaiting_seed');
143
- expect(orch.createCalls[0].bootstrapNodes).toEqual([]);
144
- expect(store.get(view.id)?.bootstrapNodes).toEqual([]);
145
- });
146
-
147
- /**
148
- * The pins are checked before the grant is even validated, so a typo costs the
149
- * requester nothing: no orchestrator call, no record, and — the point — no quota
150
- * slot burned on a node that could never have booted. Previously the request was
151
- * answered as provisioned and the child died at startup, leaving an `error`
152
- * record and a slot the grantee had to notice and reclaim.
153
- */
154
- it('rejects a malformed ownerKeys entry without provisioning or burning a quota slot', async () => {
155
- const orch = new FakeOrchestrator();
156
- const store = new DonationStore(join(tmpRoot, 'donations'));
157
- const { grants, token } = makeGrants();
158
- const svc = new DonationService({ orchestrator: orch, grants, store });
159
-
160
- await expect(
161
- svc.provision({ ...baseRequest(token), ownerKeys: [OWNER_KEY, 'this-is-not-a-key'] }),
162
- ).rejects.toMatchObject({ code: 'invalid_request', message: expect.stringContaining('this-is-not-a-key') });
163
-
164
- expect(orch.createCalls).toEqual([]);
165
- expect(store.list()).toEqual([]);
166
- expect(store.liveNodeCount(token)).toBe(0);
167
- });
168
-
169
- it('rejects a blank ownerKeys entry the same way', async () => {
170
- const orch = new FakeOrchestrator();
171
- const store = new DonationStore(join(tmpRoot, 'donations'));
172
- const { grants, token } = makeGrants();
173
- const svc = new DonationService({ orchestrator: orch, grants, store });
174
-
175
- await expect(
176
- svc.provision({ ...baseRequest(token), ownerKeys: [''] }),
177
- ).rejects.toBeInstanceOf(DonationError);
178
-
179
- expect(orch.createCalls).toEqual([]);
180
- expect(store.list()).toEqual([]);
181
- expect(store.liveNodeCount(token)).toBe(0);
182
- });
183
-
184
- it('threads the trimmed keys onto the record and the child, not the raw input', async () => {
185
- const orch = new FakeOrchestrator();
186
- const store = new DonationStore(join(tmpRoot, 'donations'));
187
- const { grants, token } = makeGrants();
188
- const svc = new DonationService({ orchestrator: orch, grants, store });
189
-
190
- const view = await svc.provision({ ...baseRequest(token), ownerKeys: [` ${OWNER_KEY}\n`] });
191
-
192
- expect(orch.createCalls[0].pinnedOwnerKeys).toEqual([OWNER_KEY]);
193
- expect(store.get(view.id)?.ownerKeys).toEqual([OWNER_KEY]);
194
- });
195
-
196
- it('honours an explicit transaction profile', async () => {
197
- const orch = new FakeOrchestrator();
198
- const { grants, token } = makeGrants();
199
- const svc = new DonationService({
200
- orchestrator: orch,
201
- grants,
202
- store: new DonationStore(join(tmpRoot, 'donations')),
203
- });
204
-
205
- const view = await svc.provision({ ...baseRequest(token), profile: 'transaction' });
206
- expect(view.profile).toBe('transaction');
207
- expect(orch.createCalls[0].profile).toBe('transaction');
208
- });
209
-
210
- it('denies an unknown grant token before spawning (unauthorized)', async () => {
211
- const orch = new FakeOrchestrator();
212
- const { grants } = makeGrants();
213
- const svc = new DonationService({
214
- orchestrator: orch,
215
- grants,
216
- store: new DonationStore(join(tmpRoot, 'donations')),
217
- });
218
-
219
- await expect(svc.provision(baseRequest('not-a-real-token')))
220
- .rejects.toMatchObject({ code: 'unauthorized' });
221
- expect(orch.createCalls).toHaveLength(0);
222
- });
223
-
224
- it('lets a borrower terminate that lands mid-spawn win, and reclaims the new child', async () => {
225
- const orch = new FakeOrchestrator();
226
- const store = new DonationStore(join(tmpRoot, 'donations'));
227
- const { grants, token } = makeGrants();
228
- const svc = new DonationService({ orchestrator: orch, grants, store });
229
-
230
- // The borrower's DELETE lands inside the spawn window. The record names no
231
- // dockerId yet, so terminate's own cleanup branch finds nothing to stop —
232
- // the abandon path here is the only thing that can reclaim this child.
233
- orch.createDelayMs = 20;
234
- let terminated: Promise<void> | undefined;
235
- orch.onCreate = (request) => { terminated ??= svc.terminate(request.containerId); };
236
-
237
- await expect(svc.provision(baseRequest(token))).rejects.toMatchObject({ code: 'invalid_state' });
238
- await terminated;
239
-
240
- const stored = store.list().find((d) => d.grantToken === token)!;
241
- expect(stored.status).toBe('terminated');
242
- expect(stored.dockerId).toBeUndefined();
243
- expect(orch.removed).toEqual(['dock_1']);
244
- // The ended loan no longer holds a quota slot.
245
- expect(store.liveNodeCount(token)).toBe(0);
246
- });
247
-
248
- it('does not rewrite a terminated record as error when the spawn then fails', async () => {
249
- const orch = new FakeOrchestrator();
250
- const store = new DonationStore(join(tmpRoot, 'donations'));
251
- const { grants, token } = makeGrants();
252
- const svc = new DonationService({ orchestrator: orch, grants, store });
253
-
254
- // Ending first, host fault second: the borrower's `terminated` must survive
255
- // — a host-side error is not allowed to overwrite the borrower's own ending.
256
- orch.createDelayMs = 20;
257
- orch.failCreate = true;
258
- let terminated: Promise<void> | undefined;
259
- orch.onCreate = (request) => { terminated ??= svc.terminate(request.containerId); };
260
-
261
- await expect(svc.provision(baseRequest(token)))
262
- .rejects.toMatchObject({ code: 'orchestrator_error' });
263
- await terminated;
264
-
265
- const stored = store.list().find((d) => d.grantToken === token)!;
266
- expect(stored.status).toBe('terminated');
267
- expect(stored.error).toBeUndefined();
268
- expect(orch.removed).toEqual([]);
269
- expect(store.liveNodeCount(token)).toBe(0);
270
- });
271
-
272
- it('reclaims the new child and recreates nothing when the record vanishes mid-spawn', async () => {
273
- const orch = new FakeOrchestrator();
274
- const store = new DonationStore(join(tmpRoot, 'donations'));
275
- const { grants, token } = makeGrants();
276
- const svc = new DonationService({ orchestrator: orch, grants, store });
277
-
278
- orch.createDelayMs = 20;
279
- orch.onCreate = (request) => { store.remove(request.containerId); };
280
-
281
- await expect(svc.provision(baseRequest(token))).rejects.toMatchObject({ code: 'not_found' });
282
-
283
- // No row to protect, so none is written back and the child is fully
284
- // reclaimed rather than left holding ports and a workdir nothing names.
285
- expect(store.list()).toEqual([]);
286
- expect(orch.removed).toEqual(['dock_1']);
287
- });
288
- });
289
-
290
- describe('DonationService seedToken persistence', () => {
291
- it('reproduces the seedToken from a store rebuilt off disk (host-restart gap)', async () => {
292
- const orch = new FakeOrchestrator();
293
- const dir = join(tmpRoot, 'donations');
294
- const { grants, token } = makeGrants();
295
- const svc = new DonationService({ orchestrator: orch, grants, store: new DonationStore(dir) });
296
-
297
- const view = await svc.provision(baseRequest(token));
298
-
299
- // Simulate a host restart: brand-new store instance, same directory.
300
- const reloaded = new DonationStore(dir);
301
- const record = reloaded.get(view.id);
302
- expect(record?.status).toBe('awaiting_seed');
303
- expect(record?.seedToken).toBe('seed-token-1');
304
- expect(record?.seedEndpoint).toBe('http://127.0.0.1:9001/seed');
305
- });
306
- });
307
-
308
- describe('DonationService quota-race serialization', () => {
309
- it('serializes per grant so two concurrent provisions at the boundary cannot both pass', async () => {
310
- const orch = new FakeOrchestrator();
311
- orch.createDelayMs = 40; // widen the check→create window so an unserialized impl would race
312
- const { grants, token } = makeGrants({ maxNodes: 1 });
313
- const svc = new DonationService({
314
- orchestrator: orch,
315
- grants,
316
- store: new DonationStore(join(tmpRoot, 'donations')),
317
- });
318
-
319
- const [a, b] = await Promise.allSettled([
320
- svc.provision(baseRequest(token)),
321
- svc.provision(baseRequest(token)),
322
- ]);
323
-
324
- const fulfilled = [a, b].filter((r) => r.status === 'fulfilled');
325
- const rejected = [a, b].filter((r) => r.status === 'rejected');
326
- expect(fulfilled).toHaveLength(1);
327
- expect(rejected).toHaveLength(1);
328
- expect((rejected[0] as PromiseRejectedResult).reason).toMatchObject({ code: 'quota_exceeded' });
329
- // Exactly one node was actually spawned.
330
- expect(orch.createCalls).toHaveLength(1);
331
- });
332
- });
333
-
334
- describe('DonationService reclaim-on-failure', () => {
335
- it('reclaims the spawned resources and marks error when persistence fails after spawn', async () => {
336
- const orch = new FakeOrchestrator();
337
- const store = new DonationStore(join(tmpRoot, 'donations'));
338
- // Fail the SECOND put — the awaiting_seed write, after the orchestrator has
339
- // already handed back a dockerId. The service must reclaim it.
340
- const realPut = store.put.bind(store);
341
- let puts = 0;
342
- (store as unknown as { put: (d: unknown) => void }).put = (d: unknown) => {
343
- puts += 1;
344
- if (puts === 2) throw new DonationError('storage_error', 'disk full');
345
- realPut(d as never);
346
- };
347
- const { grants, token } = makeGrants();
348
- const svc = new DonationService({ orchestrator: orch, grants, store });
349
-
350
- await expect(svc.provision(baseRequest(token)))
351
- .rejects.toMatchObject({ code: 'orchestrator_error' });
352
-
353
- // The spawned child was reclaimed (removeContainer called with its dockerId).
354
- expect(orch.removed).toEqual(['dock_1']);
355
- // The record is marked error (the 3rd put, which succeeds) → not counted live.
356
- const errored = store.list().find((d) => d.grantToken === token);
357
- expect(errored?.status).toBe('error');
358
- expect(store.liveNodeCount(token)).toBe(0);
359
- });
360
-
361
- it('does not reclaim when the spawn itself throws (nothing was allocated)', async () => {
362
- const orch = new FakeOrchestrator();
363
- orch.failCreate = true;
364
- const store = new DonationStore(join(tmpRoot, 'donations'));
365
- const { grants, token } = makeGrants();
366
- const svc = new DonationService({ orchestrator: orch, grants, store });
367
-
368
- await expect(svc.provision(baseRequest(token)))
369
- .rejects.toMatchObject({ code: 'orchestrator_error' });
370
- expect(orch.removed).toEqual([]);
371
- // Nor does it reclaim the workdir by name: `createContainer`'s own unwind
372
- // already removed a directory its failed spawn created, and a second
373
- // attempt from here would be redundant.
374
- expect(orch.reclaimedWorkdirs).toEqual([]);
375
- expect(store.list().find((d) => d.grantToken === token)?.status).toBe('error');
376
- expect(store.liveNodeCount(token)).toBe(0);
377
- });
378
- });
379
-
380
- describe('DonationService.applySeed', () => {
381
- it('marks the record seeded and reports the peers the node added', async () => {
382
- const orch = new FakeOrchestrator();
383
- const store = new DonationStore(join(tmpRoot, 'donations'));
384
- const { grants, token } = makeGrants();
385
- const svc = new DonationService({ orchestrator: orch, grants, store });
386
-
387
- const provisioned = await svc.provision(baseRequest(token));
388
- stubSeedFetch({ success: true, peersAdded: 2 });
389
-
390
- const result = await svc.applySeed(provisioned.id, 'encoded-seed');
391
-
392
- expect(result).toEqual({ outcome: 'seeded', peersAdded: 2 });
393
- expect(store.get(provisioned.id)?.status).toBe('seeded');
394
- });
395
-
396
- it('reports a node that refuses the seed, and writes nothing', async () => {
397
- const orch = new FakeOrchestrator();
398
- const store = new DonationStore(join(tmpRoot, 'donations'));
399
- const { grants, token } = makeGrants();
400
- const svc = new DonationService({ orchestrator: orch, grants, store });
401
-
402
- const provisioned = await svc.provision(baseRequest(token));
403
- const before = store.get(provisioned.id)!;
404
- stubSeedFetch({ success: false, error: 'seed signer is not a trusted owner' });
405
-
406
- const result = await svc.applySeed(provisioned.id, 'encoded-seed');
407
-
408
- expect(result).toEqual({ outcome: 'rejected', error: 'seed signer is not a trusted owner' });
409
- // The node's own policy call is not our business to record.
410
- expect(store.get(provisioned.id)).toEqual(before);
411
- });
412
-
413
- // A 401 is the node refusing the HOST's bearer — it never looked at the seed —
414
- // so it must not read as the requester's seed being untrusted.
415
- it('reports a node that refuses the host credential as a credential fault, not a seed rejection', async () => {
416
- const orch = new FakeOrchestrator();
417
- const store = new DonationStore(join(tmpRoot, 'donations'));
418
- const { grants, token } = makeGrants();
419
- const svc = new DonationService({ orchestrator: orch, grants, store });
420
-
421
- const provisioned = await svc.provision(baseRequest(token));
422
- const before = store.get(provisioned.id)!;
423
- globalThis.fetch = (async () => new Response(
424
- JSON.stringify({ success: false, error: 'unauthorized' }),
425
- { status: 401, headers: { 'content-type': 'application/json' } },
426
- )) as typeof globalThis.fetch;
427
-
428
- const err = await svc.applySeed(provisioned.id, 'encoded-seed').catch((e: unknown) => e);
429
-
430
- expect(err).toBeInstanceOf(DonationError);
431
- expect(err).toMatchObject({
432
- code: 'seed_failed',
433
- message: 'Donated node rejected the host\'s seed credential (401)',
434
- });
435
- expect(store.get(provisioned.id)).toEqual(before);
436
- });
437
-
438
- it('lets a borrower terminate that lands mid-seed win', async () => {
439
- const orch = new FakeOrchestrator();
440
- const store = new DonationStore(join(tmpRoot, 'donations'));
441
- const { grants, token } = makeGrants();
442
- const svc = new DonationService({ orchestrator: orch, grants, store });
443
-
444
- const provisioned = await svc.provision(baseRequest(token));
445
- // The borrower's DELETE lands inside the seed window: the node accepts the
446
- // seed, but by the time it answers the loan is over.
447
- let terminated: Promise<void> | undefined;
448
- stubSeedFetch({ success: true, peersAdded: 2 }, () => {
449
- terminated = svc.terminate(provisioned.id);
450
- return terminated;
451
- });
452
-
453
- const result = await svc.applySeed(provisioned.id, 'encoded-seed');
454
- await terminated;
455
-
456
- expect(result).toEqual({ outcome: 'abandoned', status: 'terminated' });
457
- const stored = store.get(provisioned.id)!;
458
- expect(stored.status).toBe('terminated');
459
- expect(stored.dockerId).toBe('dock_1');
460
- // Unlike an abandoned respawn there is nothing left for us to clean up: the
461
- // record named its dockerId throughout, so the ending stopped + reclaimed
462
- // the child itself, and no live record remains for a supervisor to respawn.
463
- expect(orch.stopped).toEqual(['dock_1']);
464
- expect(orch.removed).toEqual(['dock_1']);
465
- expect(store.liveNodeCount(token)).toBe(0);
466
- });
467
-
468
- it('lets a stale-seed reap that lands mid-seed win without restarting the TTL clock', async () => {
469
- const orch = new FakeOrchestrator();
470
- const store = new DonationStore(join(tmpRoot, 'donations'));
471
- const { grants, token } = makeGrants();
472
- let clock = new Date('2025-01-01T00:00:00.000Z');
473
- const svc = new DonationService({ orchestrator: orch, grants, store, now: () => clock });
474
-
475
- const provisioned = await svc.provision(baseRequest(token));
476
-
477
- // Past the awaiting_seed TTL, so the sweep collects this record.
478
- clock = new Date(clock.getTime() + DONATION_AWAITING_SEED_TTL_MS + 60_000);
479
- const reapAtIso = clock.toISOString();
480
- let reaped: Promise<string[]> | undefined;
481
- stubSeedFetch({ success: true, peersAdded: 2 }, () => {
482
- // Move the clock on once the reap's terminal write has landed, so a seed
483
- // write that overwrote it would show up as a *later* updatedAt.
484
- reaped = svc.reapStaleAwaitingSeed().then((ids) => {
485
- clock = new Date(clock.getTime() + 1_000);
486
- return ids;
487
- });
488
- return reaped;
489
- });
490
-
491
- const result = await svc.applySeed(provisioned.id, 'encoded-seed');
492
- await expect(reaped).resolves.toEqual([provisioned.id]);
493
-
494
- expect(result).toEqual({ outcome: 'abandoned', status: 'terminated' });
495
- const stored = store.get(provisioned.id)!;
496
- expect(stored.status).toBe('terminated');
497
- // The reap's own "now" — a seed write here would restart the TTL clock.
498
- expect(stored.updatedAt).toBe(reapAtIso);
499
- expect(store.liveNodeCount(token)).toBe(0);
500
- });
501
-
502
- it('abandons with no status when the record vanishes mid-seed', async () => {
503
- const orch = new FakeOrchestrator();
504
- const store = new DonationStore(join(tmpRoot, 'donations'));
505
- const { grants, token } = makeGrants();
506
- const svc = new DonationService({ orchestrator: orch, grants, store });
507
-
508
- const provisioned = await svc.provision(baseRequest(token));
509
- stubSeedFetch({ success: true, peersAdded: 2 }, () => { store.remove(provisioned.id); });
510
-
511
- const result = await svc.applySeed(provisioned.id, 'encoded-seed');
512
-
513
- expect(result).toEqual({ outcome: 'abandoned' });
514
- expect(store.get(provisioned.id)).toBeUndefined();
515
- });
516
-
517
- it('refuses to seed a terminated loan on entry', async () => {
518
- const orch = new FakeOrchestrator();
519
- const store = new DonationStore(join(tmpRoot, 'donations'));
520
- const { grants, token } = makeGrants();
521
- const svc = new DonationService({ orchestrator: orch, grants, store });
522
-
523
- const provisioned = await svc.provision(baseRequest(token));
524
- await svc.terminate(provisioned.id);
525
- stubSeedFetch({ success: true, peersAdded: 2 });
526
-
527
- await expect(svc.applySeed(provisioned.id, 'encoded-seed'))
528
- .rejects.toMatchObject({ code: 'invalid_state' });
529
- });
530
- });
531
-
532
- // The orchestrator refuses to re-spawn a container whose child is still running, so
533
- // each case that expects a spawn has the first child (`dock_1`) go down first.
534
- describe('DonationService.respawn', () => {
535
- it('replays the persisted spawn inputs, swaps the handles, and leaves status alone', async () => {
536
- const orch = new FakeOrchestrator();
537
- const store = new DonationStore(join(tmpRoot, 'donations'));
538
- const { grants, token } = makeGrants();
539
- const svc = new DonationService({ orchestrator: orch, grants, store });
540
-
541
- const provisioned = await svc.provision(baseRequest(token));
542
- // Pretend the borrower seeded it, then the child died.
543
- store.put({ ...store.get(provisioned.id)!, status: 'seeded' });
544
-
545
- orch.crash('dock_1');
546
- const result = await svc.respawn(provisioned.id);
547
-
548
- expect(result).toMatchObject({ outcome: 'respawned' });
549
- expect(result.outcome === 'respawned' && result.donation.status).toBe('seeded');
550
- // The replayed spawn carried the persisted inputs, not fresh ones.
551
- expect(orch.createCalls).toHaveLength(2);
552
- expect(orch.createCalls[1]).toMatchObject({
553
- containerId: provisioned.id,
554
- partyId: 'party-P',
555
- bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWA9hbnKrRnPRSPTRkzXqTHzGE8YpJ3JHZmQ5tGwLRTMmp'],
556
- pinnedOwnerKeys: [OWNER_KEY],
557
- profile: 'storage',
558
- });
559
-
560
- const stored = store.get(provisioned.id)!;
561
- expect(stored.status).toBe('seeded');
562
- expect(stored.dockerId).toBe('dock_2');
563
- expect(stored.seedEndpoint).toBe('http://127.0.0.1:9002/seed');
564
- expect(stored.seedToken).toBe('seed-token-2');
565
- expect(stored.respawn?.attempts).toBe(1);
566
- // Nothing was stopped or reclaimed — the workdir (identity key, node-local
567
- // stores) has to survive for the respawn to be the same node.
568
- expect(orch.stopped).toEqual([]);
569
- expect(orch.removed).toEqual([]);
570
- });
571
-
572
- it('keeps awaiting_seed records awaiting_seed so a later seed hits the new endpoint', async () => {
573
- const orch = new FakeOrchestrator();
574
- const store = new DonationStore(join(tmpRoot, 'donations'));
575
- const { grants, token } = makeGrants();
576
- const svc = new DonationService({ orchestrator: orch, grants, store });
577
-
578
- const provisioned = await svc.provision(baseRequest(token));
579
- orch.crash('dock_1');
580
- const result = await svc.respawn(provisioned.id);
581
-
582
- expect(result).toMatchObject({ outcome: 'respawned' });
583
- expect(result.outcome === 'respawned' && result.donation.status).toBe('awaiting_seed');
584
- expect(store.get(provisioned.id)?.seedToken).toBe('seed-token-2');
585
- });
586
-
587
- it('skips a record written before the spawn inputs were persisted', async () => {
588
- const orch = new FakeOrchestrator();
589
- const store = new DonationStore(join(tmpRoot, 'donations'));
590
- const { grants, token } = makeGrants();
591
- const svc = new DonationService({ orchestrator: orch, grants, store });
592
-
593
- // A pre-existing row on disk: no bootstrapNodes / ownerKeys to replay.
594
- store.put({
595
- id: 'grn_legacy',
596
- grantToken: token,
597
- partyId: 'party-P',
598
- profile: 'storage',
599
- status: 'seeded',
600
- dockerId: 'dock_old',
601
- createdAt: '2025-01-01T00:00:00.000Z',
602
- updatedAt: '2025-01-01T00:00:00.000Z',
603
- });
604
-
605
- // A skip, not a throw — a sweep over the store must survive these.
606
- await expect(svc.respawn('grn_legacy')).resolves.toEqual({ outcome: 'not_respawnable' });
607
- expect(orch.createCalls).toHaveLength(0);
608
- expect(store.get('grn_legacy')?.dockerId).toBe('dock_old');
609
- });
610
-
611
- // The empty list a phone requester provisions with is a persisted spawn input like
612
- // any other, so that loan must come back too.
613
- it('respawns a record provisioned with no bootstrap nodes, replaying the empty list', async () => {
614
- const orch = new FakeOrchestrator();
615
- const store = new DonationStore(join(tmpRoot, 'donations'));
616
- const { grants, token } = makeGrants();
617
- const svc = new DonationService({ orchestrator: orch, grants, store });
618
-
619
- const provisioned = await svc.provision({ ...baseRequest(token), bootstrapNodes: [] });
620
- store.put({ ...store.get(provisioned.id)!, status: 'seeded' });
621
-
622
- orch.crash('dock_1');
623
- const result = await svc.respawn(provisioned.id);
624
-
625
- expect(result).toMatchObject({ outcome: 'respawned' });
626
- expect(orch.createCalls).toHaveLength(2);
627
- expect(orch.createCalls[1].bootstrapNodes).toEqual([]);
628
- expect(store.get(provisioned.id)?.dockerId).toBe('dock_2');
629
- });
630
-
631
- // Only the field's ABSENCE marks a record from before spawn inputs were persisted,
632
- // so owner keys alone do not make a record replayable.
633
- it('skips a record that has owner keys but no bootstrapNodes field', async () => {
634
- const orch = new FakeOrchestrator();
635
- const store = new DonationStore(join(tmpRoot, 'donations'));
636
- const { grants, token } = makeGrants();
637
- const svc = new DonationService({ orchestrator: orch, grants, store });
638
-
639
- store.put({
640
- id: 'grn_partial',
641
- grantToken: token,
642
- partyId: 'party-P',
643
- ownerKeys: [OWNER_KEY],
644
- profile: 'storage',
645
- status: 'seeded',
646
- dockerId: 'dock_old',
647
- createdAt: '2025-01-01T00:00:00.000Z',
648
- updatedAt: '2025-01-01T00:00:00.000Z',
649
- });
650
-
651
- await expect(svc.respawn('grn_partial')).resolves.toEqual({ outcome: 'not_respawnable' });
652
- expect(orch.createCalls).toHaveLength(0);
653
- });
654
-
655
- it('refuses to resurrect a terminated loan', async () => {
656
- const orch = new FakeOrchestrator();
657
- const store = new DonationStore(join(tmpRoot, 'donations'));
658
- const { grants, token } = makeGrants();
659
- const svc = new DonationService({ orchestrator: orch, grants, store });
660
-
661
- const provisioned = await svc.provision(baseRequest(token));
662
- await svc.terminate(provisioned.id);
663
-
664
- await expect(svc.respawn(provisioned.id)).rejects.toMatchObject({ code: 'invalid_state' });
665
- expect(orch.createCalls).toHaveLength(1);
666
- });
667
-
668
- it('refuses a record whose provision is still in flight', async () => {
669
- const orch = new FakeOrchestrator();
670
- const store = new DonationStore(join(tmpRoot, 'donations'));
671
- const { grants, token } = makeGrants();
672
- const svc = new DonationService({ orchestrator: orch, grants, store });
673
-
674
- const provisioned = await svc.provision(baseRequest(token));
675
- // A host that died mid-provision leaves exactly this row: spawn inputs
676
- // persisted, status never advanced. Replaying it would race the provision
677
- // and strand the record in a status no reap sweep collects.
678
- store.put({ ...store.get(provisioned.id)!, status: 'provisioning' });
679
-
680
- await expect(svc.respawn(provisioned.id)).rejects.toMatchObject({ code: 'invalid_state' });
681
- expect(orch.createCalls).toHaveLength(1);
682
- });
683
-
684
- it('records the attempt and throws when the spawn fails', async () => {
685
- const orch = new FakeOrchestrator();
686
- const store = new DonationStore(join(tmpRoot, 'donations'));
687
- const { grants, token } = makeGrants();
688
- const svc = new DonationService({ orchestrator: orch, grants, store });
689
-
690
- const provisioned = await svc.provision(baseRequest(token));
691
- orch.failCreate = true;
692
-
693
- await expect(svc.respawn(provisioned.id)).rejects.toMatchObject({ code: 'orchestrator_error' });
694
-
695
- const stored = store.get(provisioned.id)!;
696
- expect(stored.respawn?.attempts).toBe(1);
697
- // The old handles are untouched — the caller owns backoff, not cleanup. The
698
- // seed token above all: the child still running was started with it, so a
699
- // rotated token would make that child refuse every later seed.
700
- expect(stored.dockerId).toBe('dock_1');
701
- expect(stored.seedEndpoint).toBe('http://127.0.0.1:9001/seed');
702
- expect(stored.seedToken).toBe('seed-token-1');
703
- expect(stored.status).toBe('awaiting_seed');
704
- });
705
-
706
- it('stops — but never reclaims — the new child when the post-spawn write fails', async () => {
707
- const orch = new FakeOrchestrator();
708
- // Fails exactly once, on the write that follows the second createContainer.
709
- // It must fail only once, or the merge write below fails too and the test
710
- // proves nothing.
711
- const store = new FlakyDonationStore(join(tmpRoot, 'donations'));
712
- const { grants, token } = makeGrants();
713
- const svc = new DonationService({ orchestrator: orch, grants, store });
714
-
715
- const provisioned = await svc.provision(baseRequest(token));
716
- store.failNextPut = true;
717
-
718
- orch.crash('dock_1');
719
- await expect(svc.respawn(provisioned.id)).rejects.toMatchObject({ code: 'orchestrator_error' });
720
-
721
- // Stopped, not removed: `removeContainer` deletes the workdir, and the
722
- // workdir is the identity key that makes a later respawn the SAME node.
723
- expect(orch.stopped).toEqual(['dock_2']);
724
- expect(orch.removed).toEqual([]);
725
- // The record names the child that actually exists. `createContainer`
726
- // already dropped the handle `dock_1` named, so leaving it there would aim
727
- // every later stop/reclaim at an id the orchestrator cannot resolve — and
728
- // strand the node's workdir on disk forever.
729
- const stored = store.get(provisioned.id)!;
730
- expect(stored.dockerId).toBe('dock_2');
731
- expect(stored.seedEndpoint).toBe('http://127.0.0.1:9002/seed');
732
- expect(stored.seedToken).toBe('seed-token-2');
733
- // The attempt failed, so status and the reap clock are left alone.
734
- expect(stored.status).toBe('awaiting_seed');
735
- expect(stored.updatedAt).toBe(provisioned.updatedAt);
736
- expect(stored.respawn?.attempts).toBe(1);
737
- });
738
-
739
- it('lets a borrower terminate that lands mid-spawn win, and cleans up the new child', async () => {
740
- const orch = new FakeOrchestrator();
741
- const store = new DonationStore(join(tmpRoot, 'donations'));
742
- const { grants, token } = makeGrants();
743
- const svc = new DonationService({ orchestrator: orch, grants, store });
744
-
745
- const provisioned = await svc.provision(baseRequest(token));
746
- store.put({ ...store.get(provisioned.id)!, status: 'seeded' });
747
-
748
- // The borrower's DELETE lands inside the respawn's spawn window.
749
- orch.createDelayMs = 20;
750
- let terminated: Promise<void> | undefined;
751
- orch.onCreate = () => { terminated ??= svc.terminate(provisioned.id); };
752
-
753
- const result = await svc.respawn(provisioned.id);
754
- await terminated;
755
-
756
- expect(result).toEqual({ outcome: 'abandoned', status: 'terminated' });
757
- const stored = store.get(provisioned.id)!;
758
- // The ending's write stands whole: no new handles, no attempt counter.
759
- expect(stored.status).toBe('terminated');
760
- expect(stored.dockerId).toBe('dock_1');
761
- expect(stored.seedToken).toBe('seed-token-1');
762
- expect(stored.respawn).toBeUndefined();
763
- // The child the ending could not see is stopped AND reclaimed — it is the
764
- // only thing left holding that spawn's ports and workdir.
765
- expect(orch.stopped).toContain('dock_2');
766
- expect(orch.removed).toContain('dock_2');
767
- // The ended loan no longer holds a quota slot.
768
- expect(store.liveNodeCount(token)).toBe(0);
769
- });
770
-
771
- it('reclaims the new child because the terminate could not clean up the old one', async () => {
772
- const orch = new FakeOrchestrator();
773
- const store = new DonationStore(join(tmpRoot, 'donations'));
774
- const { grants, token } = makeGrants();
775
- const svc = new DonationService({ orchestrator: orch, grants, store });
776
-
777
- const provisioned = await svc.provision(baseRequest(token));
778
- store.put({ ...store.get(provisioned.id)!, status: 'seeded' });
779
-
780
- // The borrower's DELETE lands in the window *after* the spawn dropped the
781
- // old handle — the window `abandonRespawn` was written for. (The test above
782
- // drives the same terminate from `onCreate`, i.e. before the drop, where the
783
- // terminate's own cleanup still works.) No `createDelayMs` needed: `onSpawned`
784
- // fires synchronously and `terminate` writes its `terminated` row before its
785
- // first `await`, so the row is terminal by the time `respawn` re-reads.
786
- let terminated: Promise<void> | undefined;
787
- orch.onSpawned = () => { terminated ??= svc.terminate(provisioned.id); };
788
-
789
- orch.crash('dock_1');
790
- const result = await svc.respawn(provisioned.id);
791
- await terminated;
792
-
793
- expect(result).toEqual({ outcome: 'abandoned', status: 'terminated' });
794
- // Exact equality, not `toContain`: dock_1's ABSENCE is the whole claim. The
795
- // terminate aimed its own stop and reclaim at dock_1 — the handle this spawn
796
- // had already dropped — so it cleaned up nothing at all. That is why the
797
- // abandoned respawn has to reclaim dock_2 rather than merely stop it: dock_2
798
- // is the only thing left holding that spawn's ports and workdir.
799
- expect(orch.stopped).toEqual(['dock_2']);
800
- expect(orch.removed).toEqual(['dock_2']);
801
- // The ending's write stands whole, still naming the child it knew about.
802
- expect(store.get(provisioned.id)!.status).toBe('terminated');
803
- expect(store.get(provisioned.id)!.dockerId).toBe('dock_1');
804
- expect(store.liveNodeCount(token)).toBe(0);
805
- });
806
-
807
- it('lets a stale-seed reap that lands mid-spawn win without restarting the TTL clock', async () => {
808
- const orch = new FakeOrchestrator();
809
- const store = new DonationStore(join(tmpRoot, 'donations'));
810
- const { grants, token } = makeGrants();
811
- let clock = new Date('2025-01-01T00:00:00.000Z');
812
- const svc = new DonationService({ orchestrator: orch, grants, store, now: () => clock });
813
-
814
- const provisioned = await svc.provision(baseRequest(token));
815
-
816
- // Past the awaiting_seed TTL, so the sweep collects this record.
817
- clock = new Date(clock.getTime() + DONATION_AWAITING_SEED_TTL_MS + 60_000);
818
- const reapAtIso = clock.toISOString();
819
- orch.createDelayMs = 20;
820
- let reaped: Promise<string[]> | undefined;
821
- orch.onCreate = () => {
822
- // Move the clock on once the reap's terminal write has landed, so a
823
- // respawn that overwrote it would show up as a *later* updatedAt.
824
- reaped ??= svc.reapStaleAwaitingSeed().then((ids) => {
825
- clock = new Date(clock.getTime() + 1_000);
826
- return ids;
827
- });
828
- };
829
-
830
- const result = await svc.respawn(provisioned.id);
831
- await expect(reaped).resolves.toEqual([provisioned.id]);
832
-
833
- expect(result).toEqual({ outcome: 'abandoned', status: 'terminated' });
834
- const stored = store.get(provisioned.id)!;
835
- expect(stored.status).toBe('terminated');
836
- // The reap's own "now" — a respawn write here would restart the TTL clock.
837
- expect(stored.updatedAt).toBe(reapAtIso);
838
- expect(orch.stopped).toContain('dock_2');
839
- expect(orch.removed).toContain('dock_2');
840
- expect(store.liveNodeCount(token)).toBe(0);
841
- });
842
-
843
- it('stops but does not reclaim the new child when the record goes error mid-spawn', async () => {
844
- const orch = new FakeOrchestrator();
845
- const store = new DonationStore(join(tmpRoot, 'donations'));
846
- const { grants, token } = makeGrants();
847
- const svc = new DonationService({ orchestrator: orch, grants, store });
848
-
849
- const provisioned = await svc.provision(baseRequest(token));
850
-
851
- // A give-up write landing inside the spawn window. Both spawns share one
852
- // workdir, so reclaiming here would delete the identity key `error`
853
- // deliberately keeps.
854
- orch.createDelayMs = 20;
855
- orch.onCreate = () => {
856
- store.put({ ...store.get(provisioned.id)!, status: 'error', error: 'gave up' });
857
- };
858
-
859
- orch.crash('dock_1');
860
- const result = await svc.respawn(provisioned.id);
861
-
862
- expect(result).toEqual({ outcome: 'abandoned', status: 'error' });
863
- expect(orch.stopped).toContain('dock_2');
864
- expect(orch.removed).toEqual([]);
865
- const stored = store.get(provisioned.id)!;
866
- expect(stored.status).toBe('error');
867
- expect(stored.dockerId).toBe('dock_1');
868
- });
869
-
870
- it('abandons with no status when the record vanishes mid-spawn', async () => {
871
- const orch = new FakeOrchestrator();
872
- const store = new DonationStore(join(tmpRoot, 'donations'));
873
- const { grants, token } = makeGrants();
874
- const svc = new DonationService({ orchestrator: orch, grants, store });
875
-
876
- const provisioned = await svc.provision(baseRequest(token));
877
-
878
- orch.createDelayMs = 20;
879
- orch.onCreate = () => { store.remove(provisioned.id); };
880
-
881
- orch.crash('dock_1');
882
- const result = await svc.respawn(provisioned.id);
883
-
884
- // No row to protect, so nothing is written back and the child is fully
885
- // reclaimed rather than left holding ports and a workdir nothing names.
886
- expect(result).toEqual({ outcome: 'abandoned' });
887
- expect(store.get(provisioned.id)).toBeUndefined();
888
- expect(orch.stopped).toContain('dock_2');
889
- expect(orch.removed).toContain('dock_2');
890
- });
891
- });
892
-
893
- describe('DonationService.terminate', () => {
894
- it('marks the record terminated BEFORE stopping the child', async () => {
895
- const orch = new FakeOrchestrator();
896
- const store = new DonationStore(join(tmpRoot, 'donations'));
897
- const { grants, token } = makeGrants();
898
- const svc = new DonationService({ orchestrator: orch, grants, store });
899
-
900
- const provisioned = await svc.provision(baseRequest(token));
901
- // The stop fires the orchestrator's state-change; a respawn supervisor
902
- // reacting to it must already see a terminal record, or it resurrects a
903
- // loan the borrower just ended.
904
- let statusAtStop: string | undefined;
905
- orch.onStop = () => { statusAtStop = store.get(provisioned.id)?.status; };
906
-
907
- await svc.terminate(provisioned.id);
908
-
909
- expect(statusAtStop).toBe('terminated');
910
- expect(orch.stopped).toEqual(['dock_1']);
911
- expect(orch.removed).toEqual(['dock_1']);
912
- // `removeContainer` already deleted the workdir — reclaiming by name on top
913
- // of that would be redundant, and would race the next spawn of the same id.
914
- expect(orch.reclaimedWorkdirs).toEqual([]);
915
- expect(store.liveNodeCount(token)).toBe(0);
916
- });
917
-
918
- it('reclaims the workdir by name for a record that never got a dockerId', async () => {
919
- const orch = new FakeOrchestrator();
920
- const store = new DonationStore(join(tmpRoot, 'donations'));
921
- const { grants, token } = makeGrants();
922
- const svc = new DonationService({ orchestrator: orch, grants, store });
923
-
924
- // A record the host died mid-provision on: `ensureNodeIdentity` created
925
- // `<rootDir>/grn_stuck` before anything produced a dockerId. Without this
926
- // branch, terminating it would move it to `terminated` — which the
927
- // stuck-`provisioning` reap no longer matches — stranding the directory.
928
- store.put({
929
- id: 'grn_stuck',
930
- grantToken: token,
931
- partyId: 'party-P',
932
- bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWA9hbnKrRnPRSPTRkzXqTHzGE8YpJ3JHZmQ5tGwLRTMmp'],
933
- ownerKeys: [OWNER_KEY],
934
- profile: 'storage',
935
- status: 'provisioning',
936
- createdAt: '2025-01-01T00:00:00.000Z',
937
- updatedAt: '2025-01-01T00:00:00.000Z',
938
- });
939
-
940
- await svc.terminate('grn_stuck');
941
-
942
- expect(store.get('grn_stuck')?.status).toBe('terminated');
943
- expect(orch.reclaimedWorkdirs).toEqual(['grn_stuck']);
944
- expect(orch.stopped).toEqual([]);
945
- expect(orch.removed).toEqual([]);
946
- });
947
-
948
- it('swallows the second terminate of the same donation', async () => {
949
- const orch = new FakeOrchestrator();
950
- const store = new DonationStore(join(tmpRoot, 'donations'));
951
- const { grants, token } = makeGrants();
952
- const svc = new DonationService({ orchestrator: orch, grants, store });
953
-
954
- const provisioned = await svc.provision(baseRequest(token));
955
- await svc.terminate(provisioned.id);
956
-
957
- // `terminate` does not gate on status, so the second pass re-aims its stop
958
- // and reclaim at a handle the first pass already removed. The orchestrator
959
- // rejects both; `safeStop` / `safeReclaim` log and swallow, so a duplicate
960
- // DELETE is a no-op rather than a 500. Reaps and retries both land here.
961
- await expect(svc.terminate(provisioned.id)).resolves.toBeUndefined();
962
- expect(orch.stopped).toEqual(['dock_1']);
963
- expect(orch.removed).toEqual(['dock_1']);
964
- expect(store.get(provisioned.id)!.status).toBe('terminated');
965
- });
966
- });
967
-
968
- describe('DonationService.terminateGrant', () => {
969
- it('terminates every unterminated donation under the grant, error included, and leaves other grants alone', async () => {
970
- const orch = new FakeOrchestrator();
971
- const store = new DonationStore(join(tmpRoot, 'donations'));
972
- const { grants, token } = makeGrants({ maxNodes: 5 });
973
- const otherToken = grants.issue({ label: 'Bob' }).token;
974
- const svc = new DonationService({ orchestrator: orch, grants, store });
975
-
976
- const seeded = await svc.provision(baseRequest(token)); // dock_1
977
- store.put({ ...store.get(seeded.id)!, status: 'seeded' });
978
- // A loan the supervisor gave up on keeps its workdir for a later terminate —
979
- // which, once the grant is revoked, only this teardown can make.
980
- const gaveUp = await svc.provision(baseRequest(token)); // dock_2
981
- store.put({ ...store.get(gaveUp.id)!, status: 'error', error: 'respawn gave up' });
982
- const ended = await svc.provision(baseRequest(token)); // dock_3
983
- await svc.terminate(ended.id);
984
- const bobs = await svc.provision(baseRequest(otherToken)); // dock_4
985
-
986
- grants.revoke(token);
987
- const terminated = await svc.terminateGrant(token);
988
-
989
- expect(terminated.sort()).toEqual([seeded.id, gaveUp.id].sort());
990
- expect(store.get(seeded.id)?.status).toBe('terminated');
991
- expect(store.get(gaveUp.id)?.status).toBe('terminated');
992
- expect(store.get(bobs.id)?.status).toBe('awaiting_seed');
993
- expect(orch.removed.sort()).toEqual(['dock_1', 'dock_2', 'dock_3']);
994
- });
995
- });
996
-
997
- describe('DonationService.reapStaleAwaitingSeed', () => {
998
- it('terminates awaiting_seed donations past the TTL and leaves fresh ones alone', async () => {
999
- const orch = new FakeOrchestrator();
1000
- const store = new DonationStore(join(tmpRoot, 'donations'));
1001
- const { grants, token } = makeGrants({ maxNodes: 5 });
1002
- let clock = new Date('2025-01-01T00:00:00.000Z');
1003
- const svc = new DonationService({ orchestrator: orch, grants, store, now: () => clock });
1004
-
1005
- // Old donation (enters awaiting_seed at T0).
1006
- const old = await svc.provision(baseRequest(token));
1007
-
1008
- // Advance well past the TTL, then provision a fresh one at the new "now".
1009
- const ttlMs = 30 * 60 * 1000;
1010
- clock = new Date(Date.parse('2025-01-01T00:00:00.000Z') + ttlMs + 60_000);
1011
- const fresh = await svc.provision(baseRequest(token));
1012
-
1013
- const reaped = await svc.reapStaleAwaitingSeed(ttlMs);
1014
-
1015
- expect(reaped).toEqual([old.id]);
1016
- expect(store.get(old.id)?.status).toBe('terminated');
1017
- expect(store.get(fresh.id)?.status).toBe('awaiting_seed');
1018
- // The stale child was stopped + removed.
1019
- expect(orch.stopped).toContain('dock_1');
1020
- expect(orch.removed).toContain('dock_1');
1021
- // Only the fresh donation still counts against the quota.
1022
- expect(store.liveNodeCount(token)).toBe(1);
1023
- });
1024
-
1025
- it('leaves a loan alone when a seed lands mid-sweep, before its turn comes up', async () => {
1026
- const orch = new FakeOrchestrator();
1027
- const store = new DonationStore(join(tmpRoot, 'donations'));
1028
- const { grants, token } = makeGrants({ maxNodes: 5 });
1029
- let clock = new Date('2025-01-01T00:00:00.000Z');
1030
- const svc = new DonationService({ orchestrator: orch, grants, store, now: () => clock });
1031
-
1032
- const first = await svc.provision(baseRequest(token));
1033
- const second = await svc.provision(baseRequest(token));
1034
- clock = new Date(clock.getTime() + DONATION_AWAITING_SEED_TTL_MS + 60_000);
1035
-
1036
- // The borrower seeds the second loan while the sweep is still awaiting the
1037
- // FIRST record's stop — the very write `applySeed` makes once its node
1038
- // answers. The sweep's candidate list predates it, so acting on that stale
1039
- // copy would terminate a loan that just came good.
1040
- orch.onStop = (dockerId) => {
1041
- if (dockerId !== 'dock_1') return;
1042
- store.put({ ...store.get(second.id)!, status: 'seeded', updatedAt: clock.toISOString() });
1043
- };
1044
-
1045
- await expect(svc.reapStaleAwaitingSeed()).resolves.toEqual([first.id]);
1046
-
1047
- expect(store.get(first.id)?.status).toBe('terminated');
1048
- expect(store.get(second.id)?.status).toBe('seeded');
1049
- expect(orch.stopped).toEqual(['dock_1']);
1050
- expect(orch.removed).toEqual(['dock_1']);
1051
- // The seeded loan still holds its quota slot; the reaped one does not.
1052
- expect(store.liveNodeCount(token)).toBe(1);
1053
- });
1054
-
1055
- it('is a no-op when nothing is stale', async () => {
1056
- const orch = new FakeOrchestrator();
1057
- const { grants, token } = makeGrants();
1058
- const svc = new DonationService({
1059
- orchestrator: orch,
1060
- grants,
1061
- store: new DonationStore(join(tmpRoot, 'donations')),
1062
- });
1063
- await svc.provision(baseRequest(token));
1064
- expect(await svc.reapStaleAwaitingSeed(30 * 60 * 1000)).toEqual([]);
1065
- expect(orch.removed).toEqual([]);
1066
- });
1067
- });
1068
-
1069
- describe('DonationService.reapStaleProvisioning', () => {
1070
- /**
1071
- * A donation whose host died right after writing the `provisioning` row —
1072
- * exactly what `provisionLocked` writes before it ever calls the
1073
- * orchestrator (see `donation-service.ts`). No `dockerId`: nothing beyond
1074
- * this raw write ever happened for the record itself.
1075
- */
1076
- const stuckRecord = (opts: { id: string; token: string; at: string }): Donation => ({
1077
- id: opts.id,
1078
- grantToken: opts.token,
1079
- partyId: 'party-P',
1080
- bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWA9hbnKrRnPRSPTRkzXqTHzGE8YpJ3JHZmQ5tGwLRTMmp'],
1081
- ownerKeys: [OWNER_KEY],
1082
- profile: 'storage',
1083
- status: 'provisioning',
1084
- createdAt: opts.at,
1085
- updatedAt: opts.at,
1086
- });
1087
-
1088
- it('reaps a donation stuck in provisioning past the TTL and marks it error', async () => {
1089
- const orch = new FakeOrchestrator();
1090
- const store = new DonationStore(join(tmpRoot, 'donations'));
1091
- const { grants, token } = makeGrants({ maxNodes: 5 });
1092
- let clock = new Date('2025-01-01T00:00:00.000Z');
1093
- const svc = new DonationService({ orchestrator: orch, grants, store, now: () => clock });
1094
-
1095
- store.put(stuckRecord({ id: 'grn_stuck', token, at: clock.toISOString() }));
1096
- clock = new Date(clock.getTime() + DONATION_PROVISIONING_TTL_MS + 60_000);
1097
-
1098
- const reaped = await svc.reapStaleProvisioning(DONATION_PROVISIONING_TTL_MS);
1099
-
1100
- expect(reaped).toEqual(['grn_stuck']);
1101
- expect(store.get('grn_stuck')?.status).toBe('error');
1102
- expect(store.get('grn_stuck')?.error).toMatch(/provisioning/);
1103
- // Nothing was ever spawned — no dockerId to resolve, so nothing to stop or
1104
- // remove by handle. The workdir the dead spawn created is still reachable
1105
- // by container name, though, and this is the only path that will ever
1106
- // remove it.
1107
- expect(orch.stopped).toEqual([]);
1108
- expect(orch.removed).toEqual([]);
1109
- expect(orch.reclaimedWorkdirs).toEqual(['grn_stuck']);
1110
- expect(store.liveNodeCount(token)).toBe(0);
1111
- });
1112
-
1113
- it('stops and reclaims the child when the orchestrator can resolve its dockerId', async () => {
1114
- const orch = new FakeOrchestrator();
1115
- const store = new DonationStore(join(tmpRoot, 'donations'));
1116
- const { grants, token } = makeGrants({ maxNodes: 5 });
1117
- let clock = new Date('2025-01-01T00:00:00.000Z');
1118
- const svc = new DonationService({ orchestrator: orch, grants, store, now: () => clock });
1119
-
1120
- // Simulates a crash between the host spawning the child (the orchestrator
1121
- // already knows it by dockerId) and `provisionLocked` writing that dockerId
1122
- // onto the donation record — the resource-leak wrinkle from the ticket.
1123
- const spawn = await orch.createContainer({
1124
- containerId: 'grn_stuck',
1125
- partyId: 'party-P',
1126
- bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWA9hbnKrRnPRSPTRkzXqTHzGE8YpJ3JHZmQ5tGwLRTMmp'],
1127
- profile: 'storage',
1128
- pinnedOwnerKeys: [OWNER_KEY],
1129
- });
1130
- store.put(stuckRecord({ id: 'grn_stuck', token, at: clock.toISOString() }));
1131
- clock = new Date(clock.getTime() + DONATION_PROVISIONING_TTL_MS + 60_000);
1132
-
1133
- const reaped = await svc.reapStaleProvisioning(DONATION_PROVISIONING_TTL_MS);
1134
-
1135
- expect(reaped).toEqual(['grn_stuck']);
1136
- expect(store.get('grn_stuck')?.status).toBe('error');
1137
- expect(orch.stopped).toEqual([spawn.dockerId]);
1138
- expect(orch.removed).toEqual([spawn.dockerId]);
1139
- // `removeContainer` deletes that child's workdir itself; reclaiming by name
1140
- // on top of it would be redundant.
1141
- expect(orch.reclaimedWorkdirs).toEqual([]);
1142
- expect(store.liveNodeCount(token)).toBe(0);
1143
- });
1144
-
1145
- it('leaves a fresh provisioning record within the TTL alone', async () => {
1146
- const orch = new FakeOrchestrator();
1147
- const store = new DonationStore(join(tmpRoot, 'donations'));
1148
- const { grants, token } = makeGrants({ maxNodes: 5 });
1149
- const clock = new Date('2025-01-01T00:00:00.000Z');
1150
- const svc = new DonationService({ orchestrator: orch, grants, store, now: () => clock });
1151
-
1152
- store.put(stuckRecord({ id: 'grn_fresh', token, at: clock.toISOString() }));
1153
-
1154
- expect(await svc.reapStaleProvisioning(DONATION_PROVISIONING_TTL_MS)).toEqual([]);
1155
- expect(store.get('grn_fresh')?.status).toBe('provisioning');
1156
- expect(orch.stopped).toEqual([]);
1157
- });
1158
-
1159
- it('leaves a record alone that legitimately advances between the sweep snapshot and its per-record re-read', async () => {
1160
- const orch = new FakeOrchestrator();
1161
- const store = new DonationStore(join(tmpRoot, 'donations'));
1162
- const { grants, token } = makeGrants({ maxNodes: 5 });
1163
- let clock = new Date('2025-01-01T00:00:00.000Z');
1164
- const svc = new DonationService({ orchestrator: orch, grants, store, now: () => clock });
1165
-
1166
- const firstSpawn = await orch.createContainer({
1167
- containerId: 'grn_first',
1168
- partyId: 'party-P',
1169
- bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWA9hbnKrRnPRSPTRkzXqTHzGE8YpJ3JHZmQ5tGwLRTMmp'],
1170
- profile: 'storage',
1171
- pinnedOwnerKeys: [OWNER_KEY],
1172
- });
1173
- store.put(stuckRecord({ id: 'grn_first', token, at: clock.toISOString() }));
1174
- store.put(stuckRecord({ id: 'grn_second', token, at: clock.toISOString() }));
1175
- clock = new Date(clock.getTime() + DONATION_PROVISIONING_TTL_MS + 60_000);
1176
-
1177
- // The second record's own in-flight `provisionLocked` call finally lands
1178
- // while the sweep is still awaiting the FIRST record's stop — the sweep's
1179
- // candidate list predates that write, so acting on the stale copy would
1180
- // wrongly terminate a provision that just came good.
1181
- orch.onStop = (dockerId) => {
1182
- if (dockerId !== firstSpawn.dockerId) return;
1183
- store.put({ ...store.get('grn_second')!, status: 'awaiting_seed', updatedAt: clock.toISOString() });
1184
- };
1185
-
1186
- const reaped = await svc.reapStaleProvisioning(DONATION_PROVISIONING_TTL_MS);
1187
-
1188
- expect(reaped).toEqual(['grn_first']);
1189
- expect(store.get('grn_first')?.status).toBe('error');
1190
- expect(store.get('grn_second')?.status).toBe('awaiting_seed');
1191
- expect(store.liveNodeCount(token)).toBe(1);
1192
- });
1193
- });