@serfab/cadre-host 1.12.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,120 @@
1
+ /**
2
+ * Human-readable output of the `cadre-host join` and `cadre-host node` commands:
3
+ * the claim payload as a terminal QR code and as text, the warning when the node
4
+ * is not reachable from outside, the claimed line, the outcome of a join by
5
+ * invitation, and the node list.
6
+ *
7
+ * The types are loose mirrors of `ClaimDetails` and `HostedNodeView`
8
+ * (`../hosted/`): the body arrives over HTTP from whichever cadre-host version is
9
+ * running, so every field is optional and a missing one prints as `?`.
10
+ *
11
+ * Streams: the payload text goes to stdout ALONE, so `cadre-host join --no-qr`
12
+ * can be piped or copied; everything else (the QR, the instructions, the
13
+ * warning, the claimed line) goes to stderr. The payload carries the claim
14
+ * secret by design; nothing else printed here contains it.
15
+ */
16
+
17
+ import { createRequire } from 'node:module';
18
+
19
+ export interface ClaimDetailsLike {
20
+ payload?: string;
21
+ peerId?: string;
22
+ multiaddrs?: string[];
23
+ reachability?: { verdict?: string; reason?: string | null } | null;
24
+ }
25
+
26
+ export interface HostedNodeLike {
27
+ id?: string;
28
+ join?: { kind?: string };
29
+ status?: string;
30
+ partyId?: string;
31
+ ownerKey?: string;
32
+ memberPeerId?: string;
33
+ connected?: boolean;
34
+ error?: string;
35
+ retryable?: boolean;
36
+ }
37
+
38
+ const requireForQr = createRequire(import.meta.url);
39
+
40
+ /** Print the QR code (unless `qr` is false) and the instructions on stderr, then the payload text on stdout. */
41
+ export function printClaimPayload(details: ClaimDetailsLike, opts: { qr: boolean }): void {
42
+ console.error('Scan this with the Sereus app on the phone that owns the cadre, or paste the text into it.');
43
+ if (opts.qr && details.payload) renderQr(details.payload);
44
+ console.error(`Node ${details.peerId ?? '?'} at:`);
45
+ for (const addr of details.multiaddrs ?? []) console.error(` ${addr}`);
46
+ printReachabilityWarning(details);
47
+ console.log(details.payload ?? '');
48
+ }
49
+
50
+ /**
51
+ * One line on stderr when the NAT layer says the node cannot be reached from
52
+ * outside: the claim still works from the home network over the LAN address.
53
+ */
54
+ export function printReachabilityWarning(details: ClaimDetailsLike): void {
55
+ const reachability = details.reachability;
56
+ if (!reachability || reachability.verdict !== 'unreachable') return;
57
+ console.error(
58
+ 'Warning: this node cannot be reached from outside your home network yet'
59
+ + `${reachability.reason ? ` — ${reachability.reason}` : ''}. `
60
+ + 'A phone on your home network can still claim it; run `cadre-host nat status` for the ports to forward.',
61
+ );
62
+ }
63
+
64
+ /** The line printed once the watcher reports the claim. */
65
+ export function printClaimed(node: HostedNodeLike): void {
66
+ console.error(`✓ Claimed by owner ${ownerFingerprint(node.ownerKey)} into cadre ${node.partyId ?? '?'}`);
67
+ }
68
+
69
+ /**
70
+ * How a join by invitation ended: the member that admitted the node, or the failure. A
71
+ * retryable failure means no member could be reached, which is the usual case for a cadre's
72
+ * first always-on node: its only members are phones, which nothing can dial.
73
+ */
74
+ export function printInvitationOutcome(node: HostedNodeLike): void {
75
+ if (node.status === 'joined') {
76
+ console.error(`✓ Joined cadre ${node.partyId ?? '?'} at member ${node.memberPeerId ?? '(unnamed)'}`);
77
+ return;
78
+ }
79
+ console.error(`✗ Could not join: ${node.error ?? 'unknown error'}`);
80
+ if (!node.retryable) return;
81
+ console.error(
82
+ 'If this is the cadre\'s first always-on node, no member can be reached yet: use `cadre-host join` without '
83
+ + '`--invitation` and scan the QR code from the phone instead. Otherwise, once a member is online, run '
84
+ + `\`cadre-host node retry ${node.id ?? '<id>'}\`.`,
85
+ );
86
+ }
87
+
88
+ /** One line per hosted node: id, status, party, owner fingerprint, connected. */
89
+ export function printNodeList(nodes: HostedNodeLike[]): void {
90
+ if (nodes.length === 0) {
91
+ console.log('Hosted nodes: none. Run `cadre-host join` to start one.');
92
+ return;
93
+ }
94
+ console.log('Hosted nodes:');
95
+ for (const node of nodes) {
96
+ // An invitation node names its cadre from the start; a claim node only once claimed.
97
+ const party = node.status === 'joined' || node.join?.kind === 'invitation' ? `cadre ${node.partyId ?? '?'}` : (node.status ?? '?');
98
+ const owner = node.ownerKey ? ` owner ${ownerFingerprint(node.ownerKey)}` : '';
99
+ const connected = node.status === 'joined' ? ` ${node.connected ? 'connected' : 'not connected'}` : '';
100
+ const error = node.error ? ` (${node.error})` : '';
101
+ console.log(` ${node.id ?? '?'} ${node.status ?? '?'} ${party}${owner}${connected}${error}`);
102
+ }
103
+ }
104
+
105
+ /** The first 8 characters of an owner key, as the UI shows it. */
106
+ function ownerFingerprint(ownerKey: string | undefined): string {
107
+ return ownerKey ? ownerKey.slice(0, 8) : '?';
108
+ }
109
+
110
+ /** Best-effort QR render on stderr — fall back to the bare text if the lib chokes. */
111
+ function renderQr(text: string): void {
112
+ try {
113
+ const qr = requireForQr('qrcode-terminal') as {
114
+ generate: (text: string, opts?: { small?: boolean }, cb?: (s: string) => void) => void;
115
+ };
116
+ qr.generate(text, { small: true }, (rendered) => { console.error(rendered); });
117
+ } catch (err) {
118
+ console.error(`(qrcode-terminal unavailable: ${(err as Error).message})`);
119
+ }
120
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Human-readable output of the `cadre-host nat` commands: the host-level NAT
3
+ * state, then one block per hosted node, with the forward to make on the
4
+ * router when a node cannot be reached from outside.
5
+ *
6
+ * The types are loose mirrors of `NatStatusSnapshot` (`../nat/types.ts`): the
7
+ * body arrives over HTTP from whichever cadre-host version is running, so every
8
+ * field is optional and a missing one prints as `?`.
9
+ */
10
+
11
+ export interface NatPortRouteLike {
12
+ internalPort?: number;
13
+ externalPort?: number | null;
14
+ source?: string | null;
15
+ error?: string | null;
16
+ }
17
+
18
+ export interface NatNodeLike {
19
+ nodeId?: string;
20
+ running?: boolean;
21
+ verdict?: string;
22
+ reason?: string | null;
23
+ tcp?: NatPortRouteLike;
24
+ ws?: NatPortRouteLike | null;
25
+ publicAddrs?: string[];
26
+ }
27
+
28
+ export interface NatStatusLike {
29
+ upnpEnabled?: boolean;
30
+ gateway?: {
31
+ found?: boolean;
32
+ lanAddress?: string | null;
33
+ routerExternalIp?: string | null;
34
+ lastError?: string | null;
35
+ };
36
+ externalIp?: string | null;
37
+ cgnatDetected?: boolean;
38
+ directReachability?: string;
39
+ lastTestedAt?: string | null;
40
+ nodes?: NatNodeLike[];
41
+ ddns?: {
42
+ providerId?: string | null;
43
+ hostname?: string | null;
44
+ externallyManaged?: boolean;
45
+ lastUpdateAt?: string | null;
46
+ lastUpdateOk?: boolean | null;
47
+ lastError?: string | null;
48
+ };
49
+ }
50
+
51
+ type PortKind = 'tcp' | 'ws';
52
+
53
+ interface PortEntry {
54
+ kind: PortKind;
55
+ route: NatPortRouteLike;
56
+ }
57
+
58
+ const KIND_LABEL: Record<PortKind, string> = { tcp: 'TCP', ws: 'WebSocket' };
59
+
60
+ /** Width of the label column inside a node block. */
61
+ const FIELD_WIDTH = 11;
62
+
63
+ /**
64
+ * A forward changes the node's public addresses only when it changes the
65
+ * external port or makes a port routable, and the restart that follows is
66
+ * rate-limited per node (`NAT_ADDRESS_RESTART_MIN_INTERVAL_MS`).
67
+ */
68
+ const FORWARD_RESTART_NOTE =
69
+ 'If its public addresses changed, the node restarts to announce them; a node already restarted ' +
70
+ 'for an address change in the last 10 minutes waits until those 10 minutes are up.';
71
+
72
+ export function printNatStatus(s: NatStatusLike): void {
73
+ printHostLines(s);
74
+ printDdns(s);
75
+ const nodes = s.nodes ?? [];
76
+ console.log(nodes.length === 0 ? '\nNodes: none' : '\nNodes:');
77
+ for (const n of nodes) printNodeReachability(n, s);
78
+ }
79
+
80
+ /** The block for the node a forward was saved for, then what happens next. */
81
+ export function printForwardResult(nodeId: string, s: NatStatusLike): void {
82
+ const node = s.nodes?.find((n) => n.nodeId === nodeId);
83
+ if (node) printNodeReachability(node, s);
84
+ console.log(`\n${FORWARD_RESTART_NOTE}`);
85
+ }
86
+
87
+ function printHostLines(s: NatStatusLike): void {
88
+ const gateway = s.gateway ?? {};
89
+ const gatewayLine = gateway.found
90
+ ? `found (this machine is ${gateway.lanAddress ?? '?'} on its network)`
91
+ : `not found${gateway.lastError ? ` — ${gateway.lastError}` : ''}`;
92
+ console.log(`UPnP: ${s.upnpEnabled === false ? 'off' : 'on'}; router ${gatewayLine}`);
93
+ if (gateway.routerExternalIp) {
94
+ console.log(`Router IP: ${gateway.routerExternalIp}`);
95
+ }
96
+ console.log(`External IP: ${s.externalIp ?? '(unknown)'}${s.cgnatDetected ? ' [CGNAT detected]' : ''}`);
97
+ console.log(`Reachability: ${s.directReachability ?? 'unknown'}${s.lastTestedAt ? ` (tested ${s.lastTestedAt})` : ''}`);
98
+ }
99
+
100
+ function printDdns(s: NatStatusLike): void {
101
+ const ddns = s.ddns ?? {};
102
+ if (!ddns.providerId && !ddns.hostname) {
103
+ console.log('DDNS: not configured');
104
+ return;
105
+ }
106
+ console.log(`DDNS: ${ddns.hostname ?? '(no hostname)'} via ${ddns.providerId ?? '(no provider)'}${ddns.externallyManaged ? ' [externally managed]' : ''}`);
107
+ if (ddns.lastUpdateAt) {
108
+ const ok = ddns.lastUpdateOk ? 'OK' : 'FAIL';
109
+ console.log(`DDNS update: ${ok} at ${ddns.lastUpdateAt}${ddns.lastError ? ` — ${ddns.lastError}` : ''}`);
110
+ }
111
+ }
112
+
113
+ function printNodeReachability(n: NatNodeLike, s: NatStatusLike): void {
114
+ console.log(` ${n.nodeId ?? '?'}: ${n.verdict ?? '?'}${n.running === false ? ' [stopped]' : ''}`);
115
+ printField('TCP', [formatRoute(n.tcp)]);
116
+ printField('WebSocket', [n.ws ? formatRoute(n.ws) : 'not available']);
117
+ printField('Public', n.publicAddrs?.length ? n.publicAddrs : ['none']);
118
+ if (n.reason) printField('Why', [n.reason]);
119
+ const advice = forwardAdvice(n, s);
120
+ if (advice.length > 0) printField('To fix', advice);
121
+ }
122
+
123
+ function printField(label: string, values: string[]): void {
124
+ values.forEach((value, i) => console.log(` ${(i === 0 ? label : '').padEnd(FIELD_WIDTH)}${value}`));
125
+ }
126
+
127
+ function formatRoute(r: NatPortRouteLike | undefined): string {
128
+ if (!r) return '?';
129
+ const internal = `internal ${r.internalPort ?? '?'}`;
130
+ if (r.externalPort == null) return `${internal} → not mapped${r.error ? ` (${r.error})` : ''}`;
131
+ const failed = r.error ? `; last attempt failed: ${r.error}` : '';
132
+ return `${internal} → external ${r.externalPort} (${r.source === 'manual' ? 'forwarded by hand' : 'UPnP'}${failed})`;
133
+ }
134
+
135
+ /**
136
+ * What to do for an unreachable node, one line per array entry. Behind CGNAT a
137
+ * router forward does not help, but the detection can misfire, so the forward
138
+ * command is still offered for every port. With every port routed there is
139
+ * nothing to forward: what is missing is the host's public address, which the
140
+ * reason already explains.
141
+ *
142
+ * NOTE: the UI builds the same sentence (`forwardInstruction` in
143
+ * `ui/src/lib/reachability.ts`); if the rule for which ports to forward
144
+ * changes, have the server send the port list in `NodeReachability` instead
145
+ * of changing both copies.
146
+ */
147
+ function forwardAdvice(n: NatNodeLike, s: NatStatusLike): string[] {
148
+ if (n.verdict !== 'unreachable') return [];
149
+ const nodeId = n.nodeId ?? '<node-id>';
150
+ const ports = portsOf(n);
151
+ if (s.cgnatDetected) {
152
+ return [
153
+ 'Carrier-grade NAT detected: a forward on your router will not help, and cadre-host cannot reserve a relay for its nodes yet.',
154
+ 'If the detection is wrong and you did forward ports, tell cadre-host with:',
155
+ ` ${forwardCommand(nodeId, ports)}`,
156
+ ];
157
+ }
158
+ const unrouted = ports.filter(({ route }) => route.externalPort == null);
159
+ if (unrouted.length === 0) return [];
160
+ const lan = s.gateway?.lanAddress ?? null;
161
+ const forwards = unrouted.map(({ kind, route }) => {
162
+ const port = route.internalPort ?? '?';
163
+ return `${KIND_LABEL[kind]} port ${port} to ${lan ? `${lan}:${port}` : `port ${port} on this machine's LAN address`}`;
164
+ });
165
+ return [`On your router, forward ${forwards.join(' and ')}, then run:`, ` ${forwardCommand(nodeId, unrouted)}`];
166
+ }
167
+
168
+ function portsOf(n: NatNodeLike): PortEntry[] {
169
+ const out: PortEntry[] = [];
170
+ if (n.tcp) out.push({ kind: 'tcp', route: n.tcp });
171
+ if (n.ws) out.push({ kind: 'ws', route: n.ws });
172
+ return out;
173
+ }
174
+
175
+ function forwardCommand(nodeId: string, ports: PortEntry[]): string {
176
+ return ['cadre-host nat forward', nodeId, ...ports.map(({ kind }) => `--${kind} <external port>`)].join(' ');
177
+ }
@@ -1,173 +1,173 @@
1
- /**
2
- * Contract tests for `FakeOrchestrator` itself — the handle lifecycle it borrows
3
- * from `HostProcessOrchestrator`. The donation suites lean on these semantics to
4
- * express races that only exist because the real class drops and rejects
5
- * handles, so this file exists to stop a later relaxation of the fake from
6
- * quietly turning one of those tests green.
7
- *
8
- * The rules the fake shares with the real class live in
9
- * `src/__tests__/orchestrator-handle-contract.ts` and run against both — here
10
- * and in `src/__tests__/orchestrator.test.ts` — so a disagreement between the
11
- * two fails a test. The cases in this file add what only the fake has: its
12
- * recording arrays (`stopped`, `removed`, `reclaimedWorkdirs`) and its hooks.
13
- * Each names the real behaviour it mirrors.
14
- */
15
-
16
- import { describe, expect, it } from 'vitest';
17
-
18
- import type { OrchestratorCreateRequest } from '@serfab/cadre-provider';
19
-
20
- import { describeHandleContract } from '../../__tests__/orchestrator-handle-contract.js';
21
- import { FakeOrchestrator } from './fake-orchestrator.js';
22
-
23
- const request = (containerId: string): OrchestratorCreateRequest => ({
24
- containerId,
25
- partyId: 'party-P',
26
- bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWA9hbnKrRnPRSPTRkzXqTHzGE8YpJ3JHZmQ5tGwLRTMmp'],
27
- profile: 'storage',
28
- });
29
-
30
- describeHandleContract<FakeOrchestrator>('FakeOrchestrator', {
31
- make: () => new FakeOrchestrator(),
32
- request,
33
- // No process to wait for: a fake child is running as soon as it exists.
34
- started: () => Promise.resolve(),
35
- failNextCreate: (orch) => { orch.failCreate = true; },
36
- });
37
-
38
- describe('FakeOrchestrator handle lifecycle', () => {
39
- it('drops the prior handle when a re-spawn succeeds (mirrors dropStaleHandle)', async () => {
40
- const orch = new FakeOrchestrator();
41
- const first = await orch.createContainer(request('grn_1'));
42
- orch.crash(first.dockerId);
43
- const second = await orch.createContainer(request('grn_1'));
44
-
45
- await expect(orch.stopContainer(first.dockerId)).rejects.toThrow(
46
- `Container not found: ${first.dockerId}`,
47
- );
48
- // The rejected call left no trace: `stopped` records handles acted on, not
49
- // calls attempted.
50
- expect(orch.stopped).toEqual([]);
51
- // …and the container now resolves only to the new spawn.
52
- expect(orch.resolveDockerId('grn_1')).toBe(second.dockerId);
53
-
54
- await orch.stopContainer(second.dockerId);
55
- expect(orch.stopped).toEqual([second.dockerId]);
56
- });
57
-
58
- it('keeps the prior handle live across the whole pre-drop await window', async () => {
59
- const orch = new FakeOrchestrator();
60
- const first = await orch.createContainer(request('grn_1'));
61
-
62
- // The invariant the donation race tests rest on: a concurrent stop started
63
- // from `onCreate` still finds the old handle. Deferring it onto a microtask
64
- // puts it strictly after `onCreate` returns and strictly before the
65
- // `createDelayMs` timer, so this one case pins the drop behind BOTH — with
66
- // no dependence on wall-clock timing. The outcome is captured rather than
67
- // rethrown so a regression fails this assertion instead of surfacing as an
68
- // unhandled rejection.
69
- orch.createDelayMs = 1;
70
- let outcome: Promise<string> | undefined;
71
- orch.onCreate = () => {
72
- outcome ??= Promise.resolve()
73
- .then(() => orch.stopContainer(first.dockerId))
74
- .then(() => 'stopped', (err: Error) => `rejected: ${err.message}`);
75
- };
76
-
77
- const second = await orch.createContainer(request('grn_1'));
78
-
79
- expect(await outcome).toBe('stopped');
80
- expect(orch.stopped).toEqual([first.dockerId]);
81
- expect(orch.resolveDockerId('grn_1')).toBe(second.dockerId);
82
- });
83
-
84
- it('leaves the prior handle in place when the re-spawn fails (mirrors restoreDroppedHandles)', async () => {
85
- const orch = new FakeOrchestrator();
86
- const first = await orch.createContainer(request('grn_1'));
87
-
88
- orch.failCreate = true;
89
- await expect(orch.createContainer(request('grn_1'))).rejects.toThrow('spawn boom');
90
-
91
- // Host state is exactly as the failed attempt found it — which is what lets
92
- // `DonationSupervisor.giveUp` still stop the child it knows about.
93
- await expect(orch.stopContainer(first.dockerId)).resolves.toBeUndefined();
94
- expect(orch.stopped).toEqual([first.dockerId]);
95
- expect(orch.resolveDockerId('grn_1')).toBe(first.dockerId);
96
- });
97
-
98
- it('rejects a never-issued dockerId (mirrors requireHandle)', async () => {
99
- const orch = new FakeOrchestrator();
100
-
101
- await expect(orch.removeContainer('dock_nope')).rejects.toThrow(
102
- 'Container not found: dock_nope',
103
- );
104
- expect(orch.removed).toEqual([]);
105
- });
106
-
107
- it('rejects a second call once a handle has been removed (mirrors removeContainer deleting it)', async () => {
108
- const orch = new FakeOrchestrator();
109
- const { dockerId } = await orch.createContainer(request('grn_1'));
110
-
111
- await orch.removeContainer(dockerId);
112
- expect(orch.removed).toEqual([dockerId]);
113
-
114
- await expect(orch.stopContainer(dockerId)).rejects.toThrow(
115
- `Container not found: ${dockerId}`,
116
- );
117
- expect(orch.stopped).toEqual([]);
118
- });
119
-
120
- it('fires onStop for a rejected stop, so a test can still observe the attempt', async () => {
121
- const orch = new FakeOrchestrator();
122
- const attempted: string[] = [];
123
- orch.onStop = (id) => { attempted.push(id); };
124
-
125
- await expect(orch.stopContainer('dock_nope')).rejects.toThrow('Container not found');
126
- expect(attempted).toEqual(['dock_nope']);
127
- expect(orch.stopped).toEqual([]);
128
- });
129
-
130
- it('refuses to reclaim the workdir of a container that still resolves (mirrors the live-handle guard)', async () => {
131
- const orch = new FakeOrchestrator();
132
- await orch.createContainer(request('grn_1'));
133
-
134
- // A live handle owns that directory; only `removeContainer` — which stops
135
- // the child first — may delete it.
136
- expect(orch.reclaimWorkdir('grn_1')).toBe(false);
137
- expect(orch.reclaimedWorkdirs).toEqual([]);
138
- });
139
-
140
- it('reclaims the workdir of a container no handle owns', async () => {
141
- const orch = new FakeOrchestrator();
142
-
143
- // Never spawned — the stuck-`provisioning` record whose host died before a
144
- // handle ever existed.
145
- expect(orch.reclaimWorkdir('grn_orphan')).toBe(true);
146
- expect(orch.reclaimedWorkdirs).toEqual(['grn_orphan']);
147
-
148
- // …and once the container's own handle is removed, its id becomes
149
- // reclaimable too.
150
- const { dockerId } = await orch.createContainer(request('grn_1'));
151
- expect(orch.reclaimWorkdir('grn_1')).toBe(false);
152
- await orch.removeContainer(dockerId);
153
- expect(orch.reclaimWorkdir('grn_1')).toBe(true);
154
- expect(orch.reclaimedWorkdirs).toEqual(['grn_orphan', 'grn_1']);
155
- });
156
-
157
- it('fires onSpawned after the drop, with the new handle live and the old one gone', async () => {
158
- const orch = new FakeOrchestrator();
159
- const first = await orch.createContainer(request('grn_1'));
160
- orch.crash(first.dockerId);
161
-
162
- let observed: { spawned: string; resolves: string | undefined } | undefined;
163
- orch.onSpawned = (dockerId) => {
164
- observed = { spawned: dockerId, resolves: orch.resolveDockerId('grn_1') };
165
- };
166
- const second = await orch.createContainer(request('grn_1'));
167
-
168
- // The post-drop world: the container already resolves to the new handle, so
169
- // work driven from here sees what `abandonRespawn`'s caller would see.
170
- expect(observed).toEqual({ spawned: second.dockerId, resolves: second.dockerId });
171
- expect(second.dockerId).not.toBe(first.dockerId);
172
- });
173
- });
1
+ /**
2
+ * Contract tests for `FakeOrchestrator` itself — the handle lifecycle it borrows
3
+ * from `HostProcessOrchestrator`. The hosted-node suites lean on these semantics to
4
+ * express races that only exist because the real class drops and rejects
5
+ * handles, so this file exists to stop a later relaxation of the fake from
6
+ * quietly turning one of those tests green.
7
+ *
8
+ * The rules the fake shares with the real class live in
9
+ * `src/__tests__/orchestrator-handle-contract.ts` and run against both — here
10
+ * and in `src/__tests__/orchestrator.test.ts` — so a disagreement between the
11
+ * two fails a test. The cases in this file add what only the fake has: its
12
+ * recording arrays (`stopped`, `removed`, `reclaimedWorkdirs`) and its hooks.
13
+ * Each names the real behaviour it mirrors.
14
+ */
15
+
16
+ import { describe, expect, it } from 'vitest';
17
+
18
+ import type { HostedSpawnRequest } from '../../orchestrator/types.js';
19
+
20
+ import { describeHandleContract } from '../../__tests__/orchestrator-handle-contract.js';
21
+ import { FakeOrchestrator } from './fake-orchestrator.js';
22
+
23
+ const request = (containerId: string): HostedSpawnRequest => ({
24
+ containerId,
25
+ partyId: 'party-P',
26
+ bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWA9hbnKrRnPRSPTRkzXqTHzGE8YpJ3JHZmQ5tGwLRTMmp'],
27
+ profile: 'storage',
28
+ });
29
+
30
+ describeHandleContract<FakeOrchestrator>('FakeOrchestrator', {
31
+ make: () => new FakeOrchestrator(),
32
+ request,
33
+ // No process to wait for: a fake child is running as soon as it exists.
34
+ started: () => Promise.resolve(),
35
+ failNextCreate: (orch) => { orch.failCreate = true; },
36
+ });
37
+
38
+ describe('FakeOrchestrator handle lifecycle', () => {
39
+ it('drops the prior handle when a re-spawn succeeds (mirrors dropStaleHandle)', async () => {
40
+ const orch = new FakeOrchestrator();
41
+ const first = await orch.createContainer(request('hn_1'));
42
+ orch.crash(first.dockerId);
43
+ const second = await orch.createContainer(request('hn_1'));
44
+
45
+ await expect(orch.stopContainer(first.dockerId)).rejects.toThrow(
46
+ `Container not found: ${first.dockerId}`,
47
+ );
48
+ // The rejected call left no trace: `stopped` records handles acted on, not
49
+ // calls attempted.
50
+ expect(orch.stopped).toEqual([]);
51
+ // …and the container now resolves only to the new spawn.
52
+ expect(orch.resolveDockerId('hn_1')).toBe(second.dockerId);
53
+
54
+ await orch.stopContainer(second.dockerId);
55
+ expect(orch.stopped).toEqual([second.dockerId]);
56
+ });
57
+
58
+ it('keeps the prior handle live across the whole pre-drop await window', async () => {
59
+ const orch = new FakeOrchestrator();
60
+ const first = await orch.createContainer(request('hn_1'));
61
+
62
+ // The invariant the hosted-node race tests rest on: a concurrent stop started
63
+ // from `onCreate` still finds the old handle. Deferring it onto a microtask
64
+ // puts it strictly after `onCreate` returns and strictly before the
65
+ // `createDelayMs` timer, so this one case pins the drop behind BOTH — with
66
+ // no dependence on wall-clock timing. The outcome is captured rather than
67
+ // rethrown so a regression fails this assertion instead of surfacing as an
68
+ // unhandled rejection.
69
+ orch.createDelayMs = 1;
70
+ let outcome: Promise<string> | undefined;
71
+ orch.onCreate = () => {
72
+ outcome ??= Promise.resolve()
73
+ .then(() => orch.stopContainer(first.dockerId))
74
+ .then(() => 'stopped', (err: Error) => `rejected: ${err.message}`);
75
+ };
76
+
77
+ const second = await orch.createContainer(request('hn_1'));
78
+
79
+ expect(await outcome).toBe('stopped');
80
+ expect(orch.stopped).toEqual([first.dockerId]);
81
+ expect(orch.resolveDockerId('hn_1')).toBe(second.dockerId);
82
+ });
83
+
84
+ it('leaves the prior handle in place when the re-spawn fails (mirrors restoreDroppedHandles)', async () => {
85
+ const orch = new FakeOrchestrator();
86
+ const first = await orch.createContainer(request('hn_1'));
87
+
88
+ orch.failCreate = true;
89
+ await expect(orch.createContainer(request('hn_1'))).rejects.toThrow('spawn boom');
90
+
91
+ // Host state is exactly as the failed attempt found it — which is what lets
92
+ // `HostedNodeSupervisor.giveUp` still stop the child it knows about.
93
+ await expect(orch.stopContainer(first.dockerId)).resolves.toBeUndefined();
94
+ expect(orch.stopped).toEqual([first.dockerId]);
95
+ expect(orch.resolveDockerId('hn_1')).toBe(first.dockerId);
96
+ });
97
+
98
+ it('rejects a never-issued dockerId (mirrors requireHandle)', async () => {
99
+ const orch = new FakeOrchestrator();
100
+
101
+ await expect(orch.removeContainer('dock_nope')).rejects.toThrow(
102
+ 'Container not found: dock_nope',
103
+ );
104
+ expect(orch.removed).toEqual([]);
105
+ });
106
+
107
+ it('rejects a second call once a handle has been removed (mirrors removeContainer deleting it)', async () => {
108
+ const orch = new FakeOrchestrator();
109
+ const { dockerId } = await orch.createContainer(request('hn_1'));
110
+
111
+ await orch.removeContainer(dockerId);
112
+ expect(orch.removed).toEqual([dockerId]);
113
+
114
+ await expect(orch.stopContainer(dockerId)).rejects.toThrow(
115
+ `Container not found: ${dockerId}`,
116
+ );
117
+ expect(orch.stopped).toEqual([]);
118
+ });
119
+
120
+ it('fires onStop for a rejected stop, so a test can still observe the attempt', async () => {
121
+ const orch = new FakeOrchestrator();
122
+ const attempted: string[] = [];
123
+ orch.onStop = (id) => { attempted.push(id); };
124
+
125
+ await expect(orch.stopContainer('dock_nope')).rejects.toThrow('Container not found');
126
+ expect(attempted).toEqual(['dock_nope']);
127
+ expect(orch.stopped).toEqual([]);
128
+ });
129
+
130
+ it('refuses to reclaim the workdir of a container that still resolves (mirrors the live-handle guard)', async () => {
131
+ const orch = new FakeOrchestrator();
132
+ await orch.createContainer(request('hn_1'));
133
+
134
+ // A live handle owns that directory; only `removeContainer` — which stops
135
+ // the child first — may delete it.
136
+ expect(orch.reclaimWorkdir('hn_1')).toBe(false);
137
+ expect(orch.reclaimedWorkdirs).toEqual([]);
138
+ });
139
+
140
+ it('reclaims the workdir of a container no handle owns', async () => {
141
+ const orch = new FakeOrchestrator();
142
+
143
+ // Never spawned — the stuck-`spawning` record whose host died before a
144
+ // handle ever existed.
145
+ expect(orch.reclaimWorkdir('hn_orphan')).toBe(true);
146
+ expect(orch.reclaimedWorkdirs).toEqual(['hn_orphan']);
147
+
148
+ // …and once the container's own handle is removed, its id becomes
149
+ // reclaimable too.
150
+ const { dockerId } = await orch.createContainer(request('hn_1'));
151
+ expect(orch.reclaimWorkdir('hn_1')).toBe(false);
152
+ await orch.removeContainer(dockerId);
153
+ expect(orch.reclaimWorkdir('hn_1')).toBe(true);
154
+ expect(orch.reclaimedWorkdirs).toEqual(['hn_orphan', 'hn_1']);
155
+ });
156
+
157
+ it('fires onSpawned after the drop, with the new handle live and the old one gone', async () => {
158
+ const orch = new FakeOrchestrator();
159
+ const first = await orch.createContainer(request('hn_1'));
160
+ orch.crash(first.dockerId);
161
+
162
+ let observed: { spawned: string; resolves: string | undefined } | undefined;
163
+ orch.onSpawned = (dockerId) => {
164
+ observed = { spawned: dockerId, resolves: orch.resolveDockerId('hn_1') };
165
+ };
166
+ const second = await orch.createContainer(request('hn_1'));
167
+
168
+ // The post-drop world: the container already resolves to the new handle, so
169
+ // work driven from here sees what `abandonRespawn`'s caller would see.
170
+ expect(observed).toEqual({ spawned: second.dockerId, resolves: second.dockerId });
171
+ expect(second.dockerId).not.toBe(first.dockerId);
172
+ });
173
+ });