@serfab/cadre-host 1.7.0 → 1.8.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 (87) hide show
  1. package/README.md +31 -16
  2. package/dist/bin/host.js +41 -20
  3. package/dist/bin/host.js.map +1 -1
  4. package/dist/donation/donation-service.d.ts +27 -4
  5. package/dist/donation/donation-service.js +57 -6
  6. package/dist/donation/donation-service.js.map +1 -1
  7. package/dist/donation/donation-store.d.ts +3 -1
  8. package/dist/donation/donation-store.js +5 -1
  9. package/dist/donation/donation-store.js.map +1 -1
  10. package/dist/donation/grant-service.d.ts +16 -7
  11. package/dist/donation/grant-service.js +57 -10
  12. package/dist/donation/grant-service.js.map +1 -1
  13. package/dist/donation/index.d.ts +2 -2
  14. package/dist/donation/index.js +1 -1
  15. package/dist/donation/index.js.map +1 -1
  16. package/dist/donation/types.d.ts +29 -7
  17. package/dist/donation/types.js.map +1 -1
  18. package/dist/index.d.ts +1 -1
  19. package/dist/nat/address-resolver.d.ts +3 -2
  20. package/dist/nat/address-resolver.js.map +1 -1
  21. package/dist/orchestrator/host-process-orchestrator.d.ts +1 -1
  22. package/dist/orchestrator/host-process-orchestrator.js +21 -11
  23. package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
  24. package/dist/orchestrator/port-allocator.d.ts +6 -39
  25. package/dist/orchestrator/port-allocator.js +9 -89
  26. package/dist/orchestrator/port-allocator.js.map +1 -1
  27. package/dist/server/events/types.d.ts +3 -0
  28. package/dist/server/index.d.ts +19 -18
  29. package/dist/server/index.js +13 -15
  30. package/dist/server/index.js.map +1 -1
  31. package/dist/server/routes/grants-admin.d.ts +15 -3
  32. package/dist/server/routes/grants-admin.js +33 -7
  33. package/dist/server/routes/grants-admin.js.map +1 -1
  34. package/dist/server/routes/nodes.d.ts +13 -5
  35. package/dist/server/routes/nodes.js +58 -39
  36. package/dist/server/routes/nodes.js.map +1 -1
  37. package/dist/server/routes/status.d.ts +13 -0
  38. package/dist/server/routes/status.js +2 -0
  39. package/dist/server/routes/status.js.map +1 -1
  40. package/dist/ui/assets/index-C6nELNfC.js +23 -0
  41. package/dist/ui/assets/index-C6nELNfC.js.map +1 -0
  42. package/dist/ui/assets/index-TtIYSeZO.css +1 -0
  43. package/dist/ui/index.html +2 -2
  44. package/package.json +4 -4
  45. package/src/__tests__/global-setup.ts +58 -56
  46. package/src/__tests__/orchestrator-env-scrub.test.ts +12 -11
  47. package/src/__tests__/orchestrator-handle-contract.ts +134 -0
  48. package/src/__tests__/orchestrator-node-identity.test.ts +6 -7
  49. package/src/__tests__/orchestrator-owner.test.ts +6 -13
  50. package/src/__tests__/orchestrator-pin-keys.test.ts +6 -7
  51. package/src/__tests__/orchestrator-ports.test.ts +5 -34
  52. package/src/__tests__/orchestrator-push.test.ts +6 -5
  53. package/src/__tests__/orchestrator-teardown.ts +65 -0
  54. package/src/__tests__/orchestrator.test.ts +23 -22
  55. package/src/auth/__tests__/trust-circle-integration.test.ts +1 -1
  56. package/src/bin/host.ts +56 -33
  57. package/src/donation/__tests__/donation-service.test.ts +38 -0
  58. package/src/donation/__tests__/fake-orchestrator.test.ts +19 -14
  59. package/src/donation/__tests__/fake-orchestrator.ts +21 -3
  60. package/src/donation/__tests__/grant-service.test.ts +2 -2
  61. package/src/donation/donation-service.ts +58 -6
  62. package/src/donation/donation-store.ts +176 -171
  63. package/src/donation/grant-service.ts +252 -200
  64. package/src/donation/index.ts +65 -64
  65. package/src/donation/types.ts +260 -244
  66. package/src/index.ts +2 -0
  67. package/src/nat/address-resolver.ts +3 -2
  68. package/src/orchestrator/host-process-orchestrator.ts +1260 -1251
  69. package/src/orchestrator/port-allocator.ts +16 -91
  70. package/src/server/__tests__/fakes.ts +44 -0
  71. package/src/server/__tests__/grants-admin-route.test.ts +171 -73
  72. package/src/server/__tests__/nodes-route.test.ts +49 -25
  73. package/src/server/__tests__/publishers.test.ts +5 -41
  74. package/src/server/__tests__/server.smoke.test.ts +7 -37
  75. package/src/server/__tests__/status-route.test.ts +8 -21
  76. package/src/server/__tests__/strands-route.test.ts +3 -2
  77. package/src/server/events/types.ts +24 -23
  78. package/src/server/index.ts +267 -267
  79. package/src/server/routes/grants-admin.ts +74 -41
  80. package/src/server/routes/nodes.ts +61 -43
  81. package/src/server/routes/status.ts +16 -0
  82. package/dist/donation/__tests__/fake-orchestrator.d.ts +0 -103
  83. package/dist/donation/__tests__/fake-orchestrator.js +0 -180
  84. package/dist/donation/__tests__/fake-orchestrator.js.map +0 -1
  85. package/dist/ui/assets/index-BVoqLjyX.css +0 -1
  86. package/dist/ui/assets/index-CEoNRwM_.js +0 -17
  87. package/dist/ui/assets/index-CEoNRwM_.js.map +0 -1
@@ -0,0 +1 @@
1
+ .dot.svelte-1qa2kn2{display:inline-flex;align-items:center;gap:.5rem;font-weight:500}.bulb.svelte-1qa2kn2{display:inline-block;border-radius:999px;flex:0 0 auto;background:var(--color-text-soft);box-shadow:0 0 0 3px #0000000f}.dot-ok.svelte-1qa2kn2 .bulb:where(.svelte-1qa2kn2){background:var(--color-success);box-shadow:0 0 0 3px var(--color-success-bg)}.dot-warn.svelte-1qa2kn2 .bulb:where(.svelte-1qa2kn2){background:var(--color-warn);box-shadow:0 0 0 3px var(--color-warn-bg)}.dot-err.svelte-1qa2kn2 .bulb:where(.svelte-1qa2kn2){background:var(--color-danger);box-shadow:0 0 0 3px var(--color-danger-bg)}.dot-muted.svelte-1qa2kn2 .bulb:where(.svelte-1qa2kn2){background:var(--color-text-soft)}.dot-ok.svelte-1qa2kn2{color:var(--color-success)}.dot-warn.svelte-1qa2kn2{color:var(--color-warn)}.dot-err.svelte-1qa2kn2{color:var(--color-danger)}.row.svelte-lwwmgz{display:inline-flex;gap:.4rem;flex-wrap:wrap}.banner.svelte-15mtls8{display:flex;align-items:center;justify-content:space-between;gap:var(--space-3);padding:var(--space-3) var(--space-4);border-radius:var(--radius);background:var(--color-info-bg);color:var(--color-info);border:1px solid var(--color-info)}.summary.svelte-15mtls8{grid-template-columns:repeat(auto-fit,minmax(15rem,1fr))}.kv.svelte-15mtls8{margin:var(--space-3) 0 0 0;display:grid;grid-template-columns:max-content 1fr;gap:var(--space-1) var(--space-3);font-size:.9rem}.kv.svelte-15mtls8>div:where(.svelte-15mtls8){display:contents}.kv.svelte-15mtls8 dt:where(.svelte-15mtls8){color:var(--color-text-muted)}.kv.svelte-15mtls8 dd:where(.svelte-15mtls8){margin:0}.big.svelte-15mtls8{font-size:1.75rem;font-weight:600;margin:var(--space-2) 0}.big.svelte-15mtls8 .muted:where(.svelte-15mtls8){font-size:.95rem;font-weight:400}.link.svelte-15mtls8{display:inline-block;margin-top:var(--space-2);font-weight:500}.backdrop.svelte-80vbit{position:fixed;inset:0;background:#0f111673;z-index:1000}.dialog.svelte-80vbit{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-lg);box-shadow:var(--shadow-lg);padding:var(--space-5);min-width:min(24rem,calc(100vw - 2rem));max-width:min(32rem,calc(100vw - 2rem));overflow-wrap:anywhere;z-index:1010}.note.svelte-80vbit{background:var(--color-warn-bg);color:var(--color-warn);border-radius:var(--radius);padding:var(--space-2) var(--space-3);font-size:.9rem}.gate.svelte-80vbit{margin-top:var(--space-3);overflow-wrap:anywhere}.actions.svelte-80vbit{display:flex;justify-content:flex-end;gap:.5rem;margin-top:var(--space-4)}.qr.svelte-1uqetyj{background:#fff;border-radius:var(--radius);padding:.5rem;display:inline-block;line-height:0}.qr.svelte-1uqetyj svg{width:100%;height:100%;display:block}.placeholder.svelte-1uqetyj{background:var(--color-surface-alt)}.qr-wrap.svelte-sddtr9{display:flex;justify-content:center;margin:var(--space-3) 0}.token.svelte-sddtr9{display:flex;flex-direction:column;gap:.5rem}.token.svelte-sddtr9 textarea:where(.svelte-sddtr9){font-family:var(--font-mono);font-size:.78rem;word-break:break-all;resize:vertical}.token.svelte-sddtr9 button:where(.svelte-sddtr9){align-self:flex-end;display:inline-flex;align-items:center;gap:.375rem}.backdrop.svelte-1d46kin{position:fixed;inset:0;background:#0f111673;z-index:1000}.dialog.svelte-1d46kin{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-lg);box-shadow:var(--shadow-lg);padding:var(--space-5);width:min(28rem,calc(100vw - 2rem));max-height:calc(100vh - 2rem);overflow:auto;z-index:1010}.actions.svelte-1d46kin{display:flex;justify-content:flex-end;gap:.5rem;margin-top:var(--space-4)}.small.svelte-1d46kin{font-size:.85rem}.page-header.svelte-3alf51{display:flex;align-items:flex-end;justify-content:space-between;flex-wrap:wrap;gap:var(--space-3)}.list.svelte-3alf51{list-style:none;padding:0;margin:var(--space-3) 0 0 0;display:flex;flex-direction:column;gap:.5rem}.list.svelte-3alf51 li:where(.svelte-3alf51){display:flex;align-items:center;justify-content:space-between;gap:var(--space-3);padding:var(--space-3);border:1px solid var(--color-border);border-radius:var(--radius);background:var(--color-surface-alt)}.member-main.svelte-3alf51{display:flex;flex-direction:column;gap:.125rem;min-width:0}.member-label.svelte-3alf51{font-weight:500}.small.svelte-3alf51{font-size:.85rem}.grid.two.svelte-hh3bto{grid-template-columns:repeat(auto-fit,minmax(20rem,1fr))}.kv.svelte-hh3bto{margin:var(--space-3) 0;display:grid;grid-template-columns:max-content 1fr;gap:var(--space-1) var(--space-3);font-size:.9rem}.kv.svelte-hh3bto>div:where(.svelte-hh3bto){display:contents}.kv.svelte-hh3bto dt:where(.svelte-hh3bto){color:var(--color-text-muted)}.kv.svelte-hh3bto dd:where(.svelte-hh3bto){margin:0}.row.inline.svelte-hh3bto{gap:.5rem}.row.inline.svelte-hh3bto input[type=checkbox]:where(.svelte-hh3bto){width:auto}.warning.svelte-hh3bto{background:var(--color-warn-bg);border-color:var(--color-warn)}.warning.svelte-hh3bto ul:where(.svelte-hh3bto){margin:0;padding-left:1.25rem}.actions.svelte-hh3bto{display:flex;justify-content:flex-end;gap:.5rem}.small.svelte-hh3bto{font-size:.85rem}table.svelte-7tcksg{width:100%;border-collapse:collapse;font-size:.92rem}th.svelte-7tcksg,td.svelte-7tcksg{padding:.5rem .75rem;text-align:left;border-bottom:1px solid var(--color-border)}th.svelte-7tcksg{font-weight:600;font-size:.78rem;text-transform:uppercase;letter-spacing:.05em;color:var(--color-text-muted);border-bottom-color:var(--color-border-strong)}tr.svelte-7tcksg:last-child td:where(.svelte-7tcksg){border-bottom:none}.log-card.svelte-15ufwrh{display:flex;flex-direction:column;gap:var(--space-3)}.header.svelte-15ufwrh{display:flex;align-items:center;justify-content:space-between;gap:var(--space-3)}.logs.svelte-15ufwrh{max-height:24rem;font-size:.78rem;line-height:1.45;white-space:pre}.small.svelte-15ufwrh{font-size:.85rem}button.svelte-15ufwrh{display:inline-flex;align-items:center;gap:.375rem}.back.svelte-1eq4g96{font-size:.92rem}.page-header.svelte-1eq4g96{display:flex;align-items:flex-start;justify-content:space-between;flex-wrap:wrap;gap:var(--space-3)}.kv.svelte-1eq4g96{margin:var(--space-3) 0;display:grid;grid-template-columns:max-content 1fr;gap:var(--space-1) var(--space-3);font-size:.92rem}.kv.svelte-1eq4g96>div:where(.svelte-1eq4g96){display:contents}.kv.svelte-1eq4g96 dt:where(.svelte-1eq4g96){color:var(--color-text-muted)}.kv.svelte-1eq4g96 dd:where(.svelte-1eq4g96){margin:0}.actions.svelte-1eq4g96{display:flex;gap:.5rem;flex-wrap:wrap}.row.inline.svelte-1cl3wo6{gap:.5rem}.row.inline.svelte-1cl3wo6 input[type=checkbox]:where(.svelte-1cl3wo6){width:auto}.banner.svelte-1cl3wo6{display:flex;align-items:center;justify-content:space-between;gap:var(--space-3);padding:var(--space-3);border-radius:var(--radius);background:var(--color-info-bg);color:var(--color-info);border:1px solid var(--color-info);flex-wrap:wrap}.kv.svelte-1cl3wo6{margin:var(--space-3) 0;display:grid;grid-template-columns:max-content 1fr;gap:var(--space-1) var(--space-3);font-size:.92rem}.kv.svelte-1cl3wo6>div:where(.svelte-1cl3wo6){display:contents}.kv.svelte-1cl3wo6 dt:where(.svelte-1cl3wo6){color:var(--color-text-muted)}.kv.svelte-1cl3wo6 dd:where(.svelte-1cl3wo6){margin:0}.copy-row.svelte-1cl3wo6{display:flex;align-items:center;gap:.375rem}.actions.svelte-1cl3wo6{display:flex;justify-content:flex-end}.small.svelte-1cl3wo6{font-size:.85rem}.error.svelte-1cl3wo6{color:var(--color-danger)}.list.svelte-ngb3vw{list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:.5rem}.list.svelte-ngb3vw li:where(.svelte-ngb3vw){display:flex;align-items:center;justify-content:space-between;gap:var(--space-3);padding:var(--space-3);border:1px solid var(--color-border);border-radius:var(--radius);background:var(--color-surface-alt)}.strand-main.svelte-ngb3vw{display:flex;flex-direction:column;gap:.25rem;min-width:0}.strand-id.svelte-ngb3vw{overflow-wrap:anywhere;user-select:text}.error.svelte-ngb3vw{color:var(--color-danger)}.meta.svelte-ngb3vw{gap:.5rem}.small.svelte-ngb3vw{font-size:.85rem}.warning.svelte-ngb3vw{background:var(--color-warn-bg);border-color:var(--color-warn)}.warning.svelte-ngb3vw code:where(.svelte-ngb3vw){overflow-wrap:anywhere}.actions.svelte-ngb3vw{display:flex;justify-content:flex-end;gap:.5rem}.backdrop.svelte-1gylbi3{position:fixed;inset:0;background:#0f111673;z-index:1000}.dialog.svelte-1gylbi3{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-lg);box-shadow:var(--shadow-lg);padding:var(--space-5);width:min(28rem,calc(100vw - 2rem));max-height:calc(100vh - 2rem);overflow:auto;overflow-wrap:anywhere;z-index:1010}.actions.svelte-1gylbi3{display:flex;justify-content:flex-end;gap:.5rem;margin-top:var(--space-4)}.small.svelte-1gylbi3{font-size:.85rem}.page-header.svelte-1jncsno{display:flex;align-items:flex-end;justify-content:space-between;flex-wrap:wrap;gap:var(--space-3)}.list.svelte-1jncsno{list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:.5rem}.list.svelte-1jncsno>li:where(.svelte-1jncsno){display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:var(--space-3);padding:var(--space-3);border:1px solid var(--color-border);border-radius:var(--radius);background:var(--color-surface-alt)}.grant-main.svelte-1jncsno{display:flex;flex-direction:column;gap:.25rem;min-width:0;overflow-wrap:anywhere}.grant-label.svelte-1jncsno{font-weight:500}.meta.svelte-1jncsno{gap:.5rem}.donations.svelte-1jncsno{list-style:none;padding:0;margin:.25rem 0 0;display:flex;flex-direction:column;gap:.125rem}.donations.svelte-1jncsno li:where(.svelte-1jncsno){display:flex;flex-wrap:wrap;gap:.5rem}.actions.svelte-1jncsno{display:flex;gap:.5rem}.keep.svelte-1jncsno{display:flex;align-items:center;gap:.5rem;margin-top:var(--space-3);font-size:.9rem;font-weight:400;color:var(--color-text)}.error.svelte-1jncsno{color:var(--color-danger)}.small.svelte-1jncsno{font-size:.85rem}.toast-stack.svelte-g7yjw8{position:fixed;right:1rem;bottom:1rem;display:flex;flex-direction:column;gap:.5rem;max-width:min(30rem,calc(100vw - 2rem));z-index:1100}.toast.svelte-g7yjw8{background:var(--color-surface);color:var(--color-text);border:1px solid var(--color-border-strong);border-radius:var(--radius);padding:.625rem .75rem;display:flex;align-items:center;gap:.5rem;box-shadow:var(--shadow);font-size:.92rem}.toast-error.svelte-g7yjw8{border-color:var(--color-danger);background:var(--color-danger-bg);color:var(--color-danger)}.toast-success.svelte-g7yjw8{border-color:var(--color-success);background:var(--color-success-bg);color:var(--color-success)}.text.svelte-g7yjw8{flex:1 1 auto}button.svelte-g7yjw8{font-size:1.1rem;line-height:1;padding:.125rem .5rem;color:currentColor}.topbar.svelte-1r8vfx3{position:sticky;top:0;z-index:10;display:flex;align-items:center;justify-content:space-between;gap:var(--space-4);padding:var(--space-3) var(--space-5);background:var(--color-surface);border-bottom:1px solid var(--color-border);backdrop-filter:saturate(180%) blur(8px)}.brand.svelte-1r8vfx3{display:flex;align-items:center;gap:.6rem;font-weight:600}.logo.svelte-1r8vfx3{color:var(--color-accent);font-size:1.1rem}nav.svelte-1r8vfx3{display:flex;gap:.125rem}nav.svelte-1r8vfx3 a:where(.svelte-1r8vfx3){font-size:.92rem;color:var(--color-text-muted);padding:.375rem .75rem;border-radius:var(--radius);text-decoration:none}nav.svelte-1r8vfx3 a:where(.svelte-1r8vfx3):hover{background:var(--color-surface-alt);color:var(--color-text)}nav.svelte-1r8vfx3 a.active:where(.svelte-1r8vfx3){color:var(--color-accent-on);background:var(--color-accent)}main.svelte-1r8vfx3{flex:1 1 auto;width:min(64rem,100%);margin:0 auto;padding:var(--space-5)}@media(max-width:640px){.topbar.svelte-1r8vfx3{flex-direction:column;align-items:flex-start;gap:var(--space-2);padding:var(--space-2) var(--space-3)}nav.svelte-1r8vfx3{width:100%;overflow-x:auto}main.svelte-1r8vfx3{padding:var(--space-3)}}:root{color-scheme:light dark;--font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;--font-mono: ui-monospace, "SF Mono", "Cascadia Code", "Roboto Mono", Menlo, monospace;--radius-sm: .25rem;--radius: .5rem;--radius-lg: .75rem;--space-1: .25rem;--space-2: .5rem;--space-3: .75rem;--space-4: 1rem;--space-5: 1.5rem;--space-6: 2rem;--space-7: 3rem;--color-bg: #f7f8fa;--color-surface: #ffffff;--color-surface-alt: #f0f2f6;--color-border: #d9dde3;--color-border-strong: #b8bec7;--color-text: #1d1f24;--color-text-muted: #5b626d;--color-text-soft: #8a8f99;--color-accent: #1d4ed8;--color-accent-strong: #1e3a8a;--color-accent-on: #ffffff;--color-accent-soft: #e0e7ff;--color-success: #15803d;--color-success-bg: #dcfce7;--color-warn: #b45309;--color-warn-bg: #fef3c7;--color-danger: #b91c1c;--color-danger-bg: #fee2e2;--color-info: #1d4ed8;--color-info-bg: #dbeafe;--shadow-sm: 0 1px 2px rgba(15, 23, 42, .05);--shadow: 0 4px 12px rgba(15, 23, 42, .08);--shadow-lg: 0 12px 32px rgba(15, 23, 42, .16)}@media(prefers-color-scheme:dark){:root{--color-bg: #0f1116;--color-surface: #181b22;--color-surface-alt: #20242d;--color-border: #2c313c;--color-border-strong: #3b424f;--color-text: #e8eaf0;--color-text-muted: #a4abb8;--color-text-soft: #777e8b;--color-accent: #93c5fd;--color-accent-strong: #bfdbfe;--color-accent-on: #0f1116;--color-accent-soft: #1e293b;--color-success: #4ade80;--color-success-bg: #14532d;--color-warn: #fbbf24;--color-warn-bg: #78350f;--color-danger: #f87171;--color-danger-bg: #7f1d1d;--color-info: #93c5fd;--color-info-bg: #1e3a8a;--shadow-sm: 0 1px 2px rgba(0, 0, 0, .4);--shadow: 0 4px 12px rgba(0, 0, 0, .45);--shadow-lg: 0 12px 32px rgba(0, 0, 0, .6)}}*,*:before,*:after{box-sizing:border-box}html,body{margin:0;padding:0;background:var(--color-bg);color:var(--color-text);font-family:var(--font-sans);font-size:15px;line-height:1.5;-webkit-font-smoothing:antialiased;min-height:100vh}#app{min-height:100vh;display:flex;flex-direction:column}h1,h2,h3,h4,h5,h6{margin:0 0 var(--space-2) 0;font-weight:600;line-height:1.25;letter-spacing:-.01em}h1{font-size:1.5rem}h2{font-size:1.25rem}h3{font-size:1.05rem}p{margin:0 0 var(--space-3) 0}a{color:var(--color-accent);text-decoration:none}a:hover{text-decoration:underline}button{font:inherit;cursor:pointer;border:1px solid var(--color-border-strong);background:var(--color-surface);color:var(--color-text);padding:var(--space-2) var(--space-3);border-radius:var(--radius);transition:background-color .12s ease,border-color .12s ease}button:hover:not(:disabled){background:var(--color-surface-alt);border-color:var(--color-text-muted)}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:2px solid var(--color-accent);outline-offset:2px}button:disabled{opacity:.55;cursor:not-allowed}button.primary{background:var(--color-accent);border-color:var(--color-accent);color:var(--color-accent-on)}button.primary:hover:not(:disabled){background:var(--color-accent-strong);border-color:var(--color-accent-strong)}button.danger{background:var(--color-danger);border-color:var(--color-danger);color:#fff}button.ghost{background:transparent;border-color:transparent;color:var(--color-text-muted);padding:var(--space-1) var(--space-2)}button.ghost:hover:not(:disabled){color:var(--color-text);background:var(--color-surface-alt)}input[type=text],input[type=url],input[type=number],input[type=password],select,textarea{font:inherit;color:var(--color-text);background:var(--color-surface);border:1px solid var(--color-border-strong);border-radius:var(--radius);padding:var(--space-2) var(--space-3);width:100%}label{display:block;font-size:.85rem;color:var(--color-text-muted);font-weight:500;margin-bottom:var(--space-1)}code,pre{font-family:var(--font-mono);font-size:.85rem}pre{background:var(--color-surface-alt);padding:var(--space-3);border-radius:var(--radius);overflow:auto;margin:0}hr{border:none;border-top:1px solid var(--color-border);margin:var(--space-5) 0}.card{background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-lg);padding:var(--space-5);box-shadow:var(--shadow-sm)}.muted{color:var(--color-text-muted)}.row{display:flex;gap:var(--space-3);align-items:center;flex-wrap:wrap}.stack{display:flex;flex-direction:column;gap:var(--space-3)}.grid{display:grid;gap:var(--space-4)}.badge{display:inline-flex;align-items:center;gap:var(--space-1);font-size:.78rem;font-weight:600;letter-spacing:.02em;text-transform:uppercase;padding:.125rem .5rem;border-radius:999px;background:var(--color-surface-alt);color:var(--color-text-muted)}.badge.ok{background:var(--color-success-bg);color:var(--color-success)}.badge.warn{background:var(--color-warn-bg);color:var(--color-warn)}.badge.err{background:var(--color-danger-bg);color:var(--color-danger)}.badge.info{background:var(--color-info-bg);color:var(--color-info)}.visually-hidden{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
@@ -5,8 +5,8 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <meta name="color-scheme" content="light dark" />
7
7
  <title>cadre-host</title>
8
- <script type="module" crossorigin src="./assets/index-CEoNRwM_.js"></script>
9
- <link rel="stylesheet" crossorigin href="./assets/index-BVoqLjyX.css">
8
+ <script type="module" crossorigin src="./assets/index-C6nELNfC.js"></script>
9
+ <link rel="stylesheet" crossorigin href="./assets/index-TtIYSeZO.css">
10
10
  </head>
11
11
  <body>
12
12
  <div id="app"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serfab/cadre-host",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "type": "module",
5
5
  "description": "Self-hosted cadre node manager for basement-PC deployments",
6
6
  "main": "dist/index.js",
@@ -65,9 +65,9 @@
65
65
  "@libp2p/interface": "^3.1.0",
66
66
  "@libp2p/peer-id": "^6.0.4",
67
67
  "@multiformats/multiaddr": "^12.5.1",
68
- "@serfab/cadre-cli": "^1.7.0",
69
- "@serfab/cadre-core": "^1.7.0",
70
- "@serfab/cadre-provider": "^1.7.0",
68
+ "@serfab/cadre-cli": "^1.8.0",
69
+ "@serfab/cadre-core": "^1.8.0",
70
+ "@serfab/cadre-provider": "^1.8.0",
71
71
  "commander": "^14.0.2",
72
72
  "debug": "^4.4.3",
73
73
  "fastify": "^5.7.1",
@@ -1,56 +1,58 @@
1
- /**
2
- * Vitest global setup — runs once before the whole cadre-host suite.
3
- *
4
- * `src/update/manifest.ts` and `src/push/index.ts` import real symbols from
5
- * `@serfab/cadre-core` (`canonicalJson`, `validatePushCredentials`), and
6
- * `trust-circle-integration.test.ts` imports and drives a real `CadreNode` from
7
- * it too. `@serfab/cadre-core`'s compiled entry point in turn imports
8
- * `@serfab/quereus-plugin-sereus`, which imports the `@optimystic/*` and
9
- * `@quereus/quereus` packages linked in from their sibling checkouts.
10
- * `host-process-orchestrator.ts` also resolves `@serfab/cadre-cli`'s bin path at
11
- * runtime, and `@serfab/cadre-provider` is a declared dependency in its own
12
- * right. Every one of those resolves through a `node_modules` symlink whose
13
- * manifest points at `dist`, so an edit to any of their `src` with no following
14
- * build is invisible here: the suite runs the previous build and reports green
15
- * about code it never executed. Fail the run up front instead.
16
- *
17
- * NOTE: accepted tradeoff — this does not cover cadre-host's *own* `src` going
18
- * stale relative to its own `dist`. The CLI smoke tests spawn `dist/bin/host.js`
19
- * as a child process, which is a same-package staleness concern, not the
20
- * cross-package one `BuildTarget` is meant to check (see
21
- * `test-harness/build-freshness.ts`'s module comment). Consequence: a `src`-only
22
- * edit to `bin/host.ts` is checked by `bin/__tests__/cli-reference.test.ts`
23
- * (which reads `src`) but not by the smoke tests (which read `dist`), so run
24
- * `yarn workspace @serfab/cadre-host build:server` before the suite. Revisit if
25
- * a stale `dist` ever produces a false green rather than merely a weaker check.
26
- *
27
- * The guard itself lives at the repo root (`test-harness/build-freshness.ts`),
28
- * shared with the other suites that call it; the list of packages below is this
29
- * suite's own concern.
30
- */
31
-
32
- import { assertBuildFresh, type BuildTarget } from '../../../../test-harness/build-freshness.js';
33
-
34
- /**
35
- * Every package this suite runs compiled code from — the `workspace:` entries of
36
- * `package.json`'s `dependencies`, plus the `link:` packages reached
37
- * transitively through them.
38
- *
39
- * Exported so `build-targets.test.ts` can hold it against this package's actual
40
- * `dependencies` — a hand-written list rots silently otherwise.
41
- */
42
- export const TARGETS: BuildTarget[] = [
43
- { packageName: '@serfab/cadre-cli', distEntry: 'dist/bin/cadre.js', location: 'workspace' },
44
- { packageName: '@serfab/cadre-core', distEntry: 'dist/index.js', location: 'workspace' },
45
- { packageName: '@serfab/cadre-provider', distEntry: 'dist/index.js', location: 'workspace' },
46
- { packageName: '@serfab/quereus-plugin-sereus', distEntry: 'dist/index.js', location: 'workspace' },
47
- { packageName: '@optimystic/db-core', distEntry: 'dist/src/index.js', location: 'linked' },
48
- { packageName: '@optimystic/db-p2p', distEntry: 'dist/src/index.js', location: 'linked' },
49
- { packageName: '@optimystic/quereus-plugin-crypto', distEntry: 'dist/index.js', location: 'linked' },
50
- { packageName: '@optimystic/quereus-plugin-optimystic', distEntry: 'dist/index.js', location: 'linked' },
51
- { packageName: '@quereus/quereus', distEntry: 'dist/src/index.js', location: 'linked' },
52
- ];
53
-
54
- export default function setup(): void {
55
- assertBuildFresh(TARGETS, import.meta.url);
56
- }
1
+ /**
2
+ * Vitest global setup — runs once before the whole cadre-host suite.
3
+ *
4
+ * `src/update/manifest.ts` and `src/push/index.ts` import real symbols from
5
+ * `@serfab/cadre-core` (`canonicalJson`, `validatePushCredentials`), and
6
+ * `trust-circle-integration.test.ts` imports and drives a real `CadreNode` from
7
+ * it too. `@serfab/cadre-core`'s compiled entry point in turn imports
8
+ * `@serfab/quereus-plugin-sereus`, which imports the `@optimystic/*` and
9
+ * `@quereus/quereus` packages linked in from their sibling checkouts.
10
+ * `host-process-orchestrator.ts` also resolves `@serfab/cadre-cli`'s bin path at
11
+ * runtime, and `@serfab/cadre-provider` is a declared dependency in its own
12
+ * right. Every one of those resolves through a `node_modules` symlink whose
13
+ * manifest points at `dist`, so an edit to any of their `src` with no following
14
+ * build is invisible here: the suite runs the previous build and reports green
15
+ * about code it never executed. Fail the run up front instead.
16
+ *
17
+ * NOTE: accepted tradeoff — this does not cover cadre-host's *own* `src` going
18
+ * stale relative to its own `dist`. The CLI smoke tests spawn `dist/bin/host.js`
19
+ * as a child process, which is a same-package staleness concern, not the
20
+ * cross-package one `BuildTarget` is meant to check (see
21
+ * `test-harness/build-freshness.ts`'s module comment). Consequence: a `src`-only
22
+ * edit to `bin/host.ts` is checked by `bin/__tests__/cli-reference.test.ts`
23
+ * (which reads `src`) but not by the smoke tests (which read `dist`), so run
24
+ * `yarn workspace @serfab/cadre-host build:server` before the suite. Revisit if
25
+ * a stale `dist` ever produces a false green rather than merely a weaker check.
26
+ *
27
+ * The guard itself lives at the repo root (`test-harness/build-freshness.ts`),
28
+ * shared with the other suites that call it; the list of packages below is this
29
+ * suite's own concern.
30
+ */
31
+
32
+ import { assertBuildFresh, type BuildTarget } from '../../../../test-harness/build-freshness.js';
33
+
34
+ /**
35
+ * Every package this suite runs compiled code from — the `workspace:` entries of
36
+ * `package.json`'s `dependencies`, plus the `link:` packages reached
37
+ * transitively through them.
38
+ *
39
+ * Exported so `build-targets.test.ts` can hold it against this package's actual
40
+ * `dependencies` — a hand-written list rots silently otherwise.
41
+ */
42
+ export const TARGETS: BuildTarget[] = [
43
+ { packageName: '@serfab/cadre-cli', distEntry: 'dist/bin/cadre.js', location: 'workspace' },
44
+ { packageName: '@serfab/cadre-core', distEntry: 'dist/index.js', location: 'workspace' },
45
+ { packageName: '@serfab/cadre-provider', distEntry: 'dist/index.js', location: 'workspace' },
46
+ // Reached through `@serfab/cadre-cli`, whose spawned `dist` loads it.
47
+ { packageName: '@serfab/config-check', distEntry: 'dist/index.js', location: 'workspace' },
48
+ { packageName: '@serfab/quereus-plugin-sereus', distEntry: 'dist/index.js', location: 'workspace' },
49
+ { packageName: '@optimystic/db-core', distEntry: 'dist/src/index.js', location: 'linked' },
50
+ { packageName: '@optimystic/db-p2p', distEntry: 'dist/src/index.js', location: 'linked' },
51
+ { packageName: '@optimystic/quereus-plugin-crypto', distEntry: 'dist/index.js', location: 'linked' },
52
+ { packageName: '@optimystic/quereus-plugin-optimystic', distEntry: 'dist/index.js', location: 'linked' },
53
+ { packageName: '@quereus/quereus', distEntry: 'dist/src/index.js', location: 'linked' },
54
+ ];
55
+
56
+ export default function setup(): void {
57
+ assertBuildFresh(TARGETS, import.meta.url);
58
+ }
@@ -24,6 +24,7 @@ import { tmpdir } from 'node:os';
24
24
  import { join } from 'node:path';
25
25
 
26
26
  import { HostProcessOrchestrator } from '../orchestrator/host-process-orchestrator.js';
27
+ import { removeAllNodes } from './orchestrator-teardown.js';
27
28
 
28
29
  // Writes the vars under test (as seen in the child env) next to the startup token.
29
30
  const FAKE_CLI = `
@@ -42,7 +43,9 @@ if (tokenPath) {
42
43
  CADRE_LISTEN_ADDRS: process.env.CADRE_LISTEN_ADDRS ?? null,
43
44
  DEBUG: process.env.DEBUG ?? null,
44
45
  };
45
- try { fs.writeFileSync(path.join(dir, 'env-seen.json'), JSON.stringify(record), 'utf8'); } catch (e) { console.error(e); }
46
+ // Write then rename: the test polls for the file's existence, and must never read it half-written.
47
+ const seenPath = path.join(dir, 'env-seen.json');
48
+ try { fs.writeFileSync(seenPath + '.tmp', JSON.stringify(record), 'utf8'); fs.renameSync(seenPath + '.tmp', seenPath); } catch (e) { console.error(e); }
46
49
  if (token) { try { fs.writeFileSync(tokenPath, token, 'utf8'); } catch (e) { console.error(e); } }
47
50
  }
48
51
  process.on('SIGTERM', () => process.exit(0));
@@ -67,18 +70,16 @@ beforeEach(() => {
67
70
  });
68
71
 
69
72
  afterEach(async () => {
70
- for (const orch of orchestrators) {
71
- for (const n of orch.listNodes()) {
72
- try { await orch.removeContainer(n.dockerId); } catch { /* ignore */ }
73
+ try {
74
+ await removeAllNodes(orchestrators);
75
+ } finally {
76
+ await sleep(50);
77
+ try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
78
+ for (const [key, value] of Object.entries(savedEnv)) {
79
+ if (value === undefined) delete process.env[key];
80
+ else process.env[key] = value;
73
81
  }
74
82
  }
75
- orchestrators.length = 0;
76
- await sleep(50);
77
- try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
78
- for (const [key, value] of Object.entries(savedEnv)) {
79
- if (value === undefined) delete process.env[key];
80
- else process.env[key] = value;
81
- }
82
83
  });
83
84
 
84
85
  function makeOrchestrator(rootDir: string): HostProcessOrchestrator {
@@ -0,0 +1,134 @@
1
+ /**
2
+ * The handle rules `HostProcessOrchestrator` and its test stand-in
3
+ * `FakeOrchestrator` (`donation/__tests__/fake-orchestrator.ts`) must both obey,
4
+ * written once and run against each: `orchestrator.test.ts` registers them over
5
+ * the real class, `fake-orchestrator.test.ts` over the fake. A rule one of them
6
+ * stops obeying fails in that file's run.
7
+ *
8
+ * Not a `.test.ts` file, so vitest does not collect it on its own.
9
+ *
10
+ * Only what both classes can satisfy belongs here — no ports, no files on disk,
11
+ * none of the fake's recording arrays. Those stay in the two calling files.
12
+ */
13
+
14
+ import { describe, expect, it } from 'vitest';
15
+
16
+ import type { OrchestratorCreateRequest, OrchestratorCreateResult } from '@serfab/cadre-provider';
17
+
18
+ import type { HostProcessOrchestrator } from '../orchestrator/host-process-orchestrator.js';
19
+
20
+ /** What the contract needs from either orchestrator. */
21
+ export type ContractOrchestrator = Pick<
22
+ HostProcessOrchestrator,
23
+ 'createContainer' | 'stopContainer' | 'removeContainer' | 'resolveDockerId'
24
+ >;
25
+
26
+ export interface HandleContractHarness<O extends ContractOrchestrator> {
27
+ /** A fresh orchestrator. Teardown is the calling file's own afterEach. */
28
+ make(): O;
29
+ request(containerId: string): OrchestratorCreateRequest;
30
+ /** Resolve once the spawn named by `dockerId` reads as running. */
31
+ started(orch: O, dockerId: string): Promise<void>;
32
+ /**
33
+ * Make `createContainer` for `containerId` throw from here on. Only called
34
+ * after that container has been spawned once and stopped, and no case creates
35
+ * again afterwards.
36
+ */
37
+ failNextCreate(orch: O, containerId: string): void;
38
+ }
39
+
40
+ const CONTAINER = 'grn_contract_a';
41
+ const OTHER_CONTAINER = 'grn_contract_b';
42
+
43
+ export function describeHandleContract<O extends ContractOrchestrator>(
44
+ label: string,
45
+ harness: HandleContractHarness<O>,
46
+ ): void {
47
+ async function spawn(orch: O, containerId: string): Promise<OrchestratorCreateResult> {
48
+ const result = await orch.createContainer(harness.request(containerId));
49
+ await harness.started(orch, result.dockerId);
50
+ return result;
51
+ }
52
+
53
+ // Stops first: a container whose previous child is still running is refused.
54
+ async function respawn(orch: O, containerId: string, previousDockerId: string): Promise<OrchestratorCreateResult> {
55
+ await orch.stopContainer(previousDockerId);
56
+ return spawn(orch, containerId);
57
+ }
58
+
59
+ describe(`${label} handle contract`, () => {
60
+ it('rejects a dockerId it never issued', async () => {
61
+ const orch = harness.make();
62
+
63
+ await expectNotFound(orch.stopContainer('never-issued'), 'never-issued');
64
+ await expectNotFound(orch.removeContainer('never-issued'), 'never-issued');
65
+ });
66
+
67
+ it('rejects a dockerId once its container has been removed', async () => {
68
+ const orch = harness.make();
69
+ const { dockerId } = await spawn(orch, CONTAINER);
70
+ await orch.stopContainer(dockerId);
71
+
72
+ await orch.removeContainer(dockerId);
73
+
74
+ await expectNotFound(orch.stopContainer(dockerId), dockerId);
75
+ await expectNotFound(orch.removeContainer(dockerId), dockerId);
76
+ });
77
+
78
+ it('replaces the first spawn\'s dockerId when the container re-spawns', async () => {
79
+ const orch = harness.make();
80
+ const first = await spawn(orch, CONTAINER);
81
+
82
+ const second = await respawn(orch, CONTAINER, first.dockerId);
83
+
84
+ await expectNotFound(orch.stopContainer(first.dockerId), first.dockerId);
85
+ expect(orch.resolveDockerId(CONTAINER)).toBe(second.dockerId);
86
+ await expect(orch.stopContainer(second.dockerId)).resolves.toBeUndefined();
87
+ });
88
+
89
+ // Launching over a live child hands its ports to a second child that then
90
+ // dies on them, while the caller records the new dockerId and seed token.
91
+ it('refuses to re-spawn a container whose previous child is still running', async () => {
92
+ const orch = harness.make();
93
+ const first = await spawn(orch, CONTAINER);
94
+
95
+ await expect(orch.createContainer(harness.request(CONTAINER)))
96
+ .rejects.toHaveProperty('message', `container ${CONTAINER} is still running`);
97
+
98
+ expect(orch.resolveDockerId(CONTAINER)).toBe(first.dockerId);
99
+ await expect(orch.stopContainer(first.dockerId)).resolves.toBeUndefined();
100
+ });
101
+
102
+ // The drop on re-spawn filters on containerId; an orchestrator that cleared
103
+ // every handle would still pass the same-container case above.
104
+ it('leaves another container\'s dockerId alone when one container re-spawns', async () => {
105
+ const orch = harness.make();
106
+ const other = await spawn(orch, OTHER_CONTAINER);
107
+ const first = await spawn(orch, CONTAINER);
108
+
109
+ await respawn(orch, CONTAINER, first.dockerId);
110
+
111
+ expect(orch.resolveDockerId(OTHER_CONTAINER)).toBe(other.dockerId);
112
+ await expect(orch.stopContainer(other.dockerId)).resolves.toBeUndefined();
113
+ });
114
+
115
+ // What lets a caller still clean up the node it knows about after a re-spawn
116
+ // that did not happen.
117
+ it('keeps the first spawn\'s dockerId when the re-spawn fails', async () => {
118
+ const orch = harness.make();
119
+ const first = await spawn(orch, CONTAINER);
120
+ await orch.stopContainer(first.dockerId);
121
+
122
+ harness.failNextCreate(orch, CONTAINER);
123
+ await expect(orch.createContainer(harness.request(CONTAINER))).rejects.toThrow();
124
+
125
+ expect(orch.resolveDockerId(CONTAINER)).toBe(first.dockerId);
126
+ await expect(orch.removeContainer(first.dockerId)).resolves.toBeUndefined();
127
+ });
128
+ });
129
+ }
130
+
131
+ /** The whole message, not a substring, so the two classes cannot come to word it differently. */
132
+ async function expectNotFound(call: Promise<void>, dockerId: string): Promise<void> {
133
+ await expect(call).rejects.toHaveProperty('message', `Container not found: ${dockerId}`);
134
+ }
@@ -19,6 +19,7 @@ import { join } from 'node:path';
19
19
 
20
20
  import { HostProcessOrchestrator, OWNER_CONTAINER_ID } from '../orchestrator/host-process-orchestrator.js';
21
21
  import { loadIdentity } from '../installer/identity.js';
22
+ import { removeAllNodes } from './orchestrator-teardown.js';
22
23
 
23
24
  // Writes the --identity-file argument (as seen on its own command line) next
24
25
  // to the startup token, then behaves like a minimal long-lived node.
@@ -49,14 +50,12 @@ beforeEach(() => {
49
50
  });
50
51
 
51
52
  afterEach(async () => {
52
- for (const orch of orchestrators) {
53
- for (const n of orch.listNodes()) {
54
- try { await orch.removeContainer(n.dockerId); } catch { /* ignore */ }
55
- }
53
+ try {
54
+ await removeAllNodes(orchestrators);
55
+ } finally {
56
+ await sleep(50);
57
+ try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
56
58
  }
57
- orchestrators.length = 0;
58
- await sleep(50);
59
- try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
60
59
  });
61
60
 
62
61
  function makeOrchestrator(
@@ -17,6 +17,7 @@ import { StateStore } from '../orchestrator/state-store.js';
17
17
  import { isPidAlive } from '../orchestrator/pid-liveness.js';
18
18
  import { decodeDockerId } from '../orchestrator/types.js';
19
19
  import { OwnerNodeClient } from '../owner/owner-node-client.js';
20
+ import { removeAllNodes } from './orchestrator-teardown.js';
20
21
 
21
22
  const FAKE_OWNER_CLI = `
22
23
  import fs from 'node:fs';
@@ -70,15 +71,12 @@ beforeEach(() => {
70
71
  });
71
72
 
72
73
  afterEach(async () => {
73
- for (const orch of orchestrators) {
74
- try { await orch.stopOwnerNode(); } catch { /* ignore */ }
75
- for (const dockerId of listDockerIds(orch)) {
76
- try { await orch.removeContainer(dockerId); } catch { /* ignore */ }
77
- }
74
+ try {
75
+ await removeAllNodes(orchestrators);
76
+ } finally {
77
+ await sleep(50);
78
+ try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
78
79
  }
79
- orchestrators.length = 0;
80
- await sleep(50);
81
- try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
82
80
  });
83
81
 
84
82
  function makeOrchestrator(rootDir: string): HostProcessOrchestrator {
@@ -93,11 +91,6 @@ function makeOrchestrator(rootDir: string): HostProcessOrchestrator {
93
91
  return orch;
94
92
  }
95
93
 
96
- function listDockerIds(orch: HostProcessOrchestrator): string[] {
97
- const state = new StateStore((orch as unknown as { rootDir: string }).rootDir);
98
- return state.load().handles.map((h) => h.dockerId);
99
- }
100
-
101
94
  async function waitFor(predicate: () => boolean | Promise<boolean>, timeoutMs = 4000, intervalMs = 50): Promise<void> {
102
95
  const deadline = Date.now() + timeoutMs;
103
96
  while (Date.now() < deadline) {
@@ -20,6 +20,7 @@ import { join } from 'node:path';
20
20
 
21
21
  import type { PushCredentials } from '@serfab/cadre-core';
22
22
  import { HostProcessOrchestrator } from '../orchestrator/host-process-orchestrator.js';
23
+ import { removeAllNodes } from './orchestrator-teardown.js';
23
24
 
24
25
  // Writes CADRE_OWNER_KEYS (as seen in the child env) next to the startup token,
25
26
  // then behaves like a minimal long-lived node.
@@ -52,14 +53,12 @@ beforeEach(() => {
52
53
  });
53
54
 
54
55
  afterEach(async () => {
55
- for (const orch of orchestrators) {
56
- for (const n of orch.listNodes()) {
57
- try { await orch.removeContainer(n.dockerId); } catch { /* ignore */ }
58
- }
56
+ try {
57
+ await removeAllNodes(orchestrators);
58
+ } finally {
59
+ await sleep(50);
60
+ try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
59
61
  }
60
- orchestrators.length = 0;
61
- await sleep(50);
62
- try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
63
62
  });
64
63
 
65
64
  function makeOrchestrator(rootDir: string, pushResolver?: () => Promise<PushCredentials | undefined>): HostProcessOrchestrator {
@@ -1,16 +1,17 @@
1
1
  /**
2
- * The pure port-set logic behind `HostProcessOrchestrator`: the allocator, the
3
- * helpers that allocate, hold, release and reuse one node's set of ports, and the
4
- * listen addresses a child is started with. No child processes — the behaviour these
2
+ * The pure port-set logic behind `HostProcessOrchestrator`: the helpers that
3
+ * allocate, hold, release and reuse one node's set of ports, and the listen
4
+ * addresses a child is started with. No child processes — the behaviour these
5
5
  * feed is exercised against a stub child in `orchestrator.test.ts`.
6
6
  */
7
7
 
8
8
  import { describe, expect, it } from 'vitest';
9
9
 
10
+ import { PortAllocator } from '@serfab/cadre-provider';
11
+
10
12
  import { childListenAddrs } from '../orchestrator/host-process-orchestrator.js';
11
13
  import {
12
14
  allocateNodePorts,
13
- PortAllocator,
14
15
  releaseNodePorts,
15
16
  reserveNodePorts,
16
17
  reusedNodePorts,
@@ -25,36 +26,6 @@ function portsWithoutWs(ports: Omit<NodePorts, 'ws'>): NodePorts {
25
26
  return ports as NodePorts;
26
27
  }
27
28
 
28
- describe('PortAllocator', () => {
29
- it('allocates sequentially, releases, and reuses', () => {
30
- const a = new PortAllocator(100, 102);
31
- expect(a.allocate()).toBe(100);
32
- expect(a.allocate()).toBe(101);
33
- expect(a.allocate()).toBe(102);
34
- expect(() => a.allocate()).toThrow(/No available ports/);
35
- a.release(101);
36
- expect(a.allocate()).toBe(101);
37
- });
38
-
39
- it('markUsed reserves ports without allocating', () => {
40
- const a = new PortAllocator(100, 102);
41
- a.markUsed(101);
42
- expect(a.allocate()).toBe(100);
43
- expect(a.allocate()).toBe(102);
44
- expect(() => a.allocate()).toThrow();
45
- });
46
-
47
- // Without the integer check, `undefined` and NaN pass both range comparisons (each
48
- // is false) and land in the used-set, as would an in-range fraction.
49
- it('markUsed ignores a non-integer', () => {
50
- const a = new PortAllocator(100, 101);
51
- for (const junk of [undefined as unknown as number, Number.NaN, 100.5]) {
52
- a.markUsed(junk);
53
- expect(a.has(junk)).toBe(false);
54
- }
55
- });
56
- });
57
-
58
29
  describe('allocateNodePorts', () => {
59
30
  it('allocates the five ports in a fixed order, the newest key last', () => {
60
31
  const a = new PortAllocator(10000, 10010);
@@ -14,6 +14,7 @@ import { join } from 'node:path';
14
14
 
15
15
  import type { PushCredentials } from '@serfab/cadre-core';
16
16
  import { HostProcessOrchestrator, OWNER_CONTAINER_ID } from '../orchestrator/host-process-orchestrator.js';
17
+ import { removeAllNodes } from './orchestrator-teardown.js';
17
18
 
18
19
  const FAKE_CLI = `
19
20
  import fs from 'node:fs';
@@ -41,12 +42,12 @@ beforeEach(() => {
41
42
  });
42
43
 
43
44
  afterEach(async () => {
44
- for (const orch of orchestrators) {
45
- try { await orch.stopOwnerNode(); } catch { /* ignore */ }
45
+ try {
46
+ await removeAllNodes(orchestrators);
47
+ } finally {
48
+ await sleep(50);
49
+ try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
46
50
  }
47
- orchestrators.length = 0;
48
- await sleep(50);
49
- try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
50
51
  });
51
52
 
52
53
  function makeOrchestrator(rootDir: string, pushResolver?: () => Promise<PushCredentials | undefined>): HostProcessOrchestrator {
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Shared teardown for the test files that drive a real HostProcessOrchestrator
3
+ * against a stub child script.
4
+ */
5
+ import type { HostProcessOrchestrator } from '../orchestrator/host-process-orchestrator.js';
6
+ import { isPidAlive } from '../orchestrator/pid-liveness.js';
7
+ import { decodeDockerId } from '../orchestrator/types.js';
8
+
9
+ interface StartedNode {
10
+ containerId: string;
11
+ pid: number;
12
+ }
13
+
14
+ /**
15
+ * Stop and remove every node the given orchestrators hold, then fail if any of
16
+ * their processes is still alive. Empties `orchestrators`.
17
+ *
18
+ * Nodes come from `listNodes()` rather than the state file: two orchestrators
19
+ * over one rootDir each rewrite the whole file from their own memory, so the
20
+ * file can omit a child that one of them still holds.
21
+ */
22
+ export async function removeAllNodes(orchestrators: HostProcessOrchestrator[]): Promise<void> {
23
+ try {
24
+ const nodes: StartedNode[] = [];
25
+ const errors: string[] = [];
26
+ for (const orch of orchestrators) {
27
+ await removeNodesOf(orch, nodes, errors);
28
+ }
29
+ // NOTE: liveness is by pid alone, not the startup token. If the OS hands a dead child's pid to an
30
+ // unrelated process before this line, that process is reported and killed; if this ever produces a
31
+ // false failure, verify the token before counting a pid as a survivor.
32
+ const survivors = nodes.filter((n) => isPidAlive(n.pid));
33
+ if (survivors.length === 0) return;
34
+ survivors.forEach((n) => forceKill(n.pid));
35
+ throw new Error(describeSurvivors(survivors, errors));
36
+ } finally {
37
+ orchestrators.length = 0;
38
+ }
39
+ }
40
+
41
+ /** Keeps going past a failed removal so one failure does not strand the rest. */
42
+ async function removeNodesOf(orch: HostProcessOrchestrator, nodes: StartedNode[], errors: string[]): Promise<void> {
43
+ for (const node of orch.listNodes()) {
44
+ nodes.push({ containerId: node.id, pid: decodeDockerId(node.dockerId).pid });
45
+ try {
46
+ await orch.removeContainer(node.dockerId);
47
+ } catch (err) {
48
+ errors.push(`${node.id}: ${err instanceof Error ? err.message : String(err)}`);
49
+ }
50
+ }
51
+ }
52
+
53
+ function forceKill(pid: number): void {
54
+ try {
55
+ process.kill(pid, 'SIGKILL');
56
+ } catch (err) {
57
+ if ((err as NodeJS.ErrnoException).code !== 'ESRCH') throw err;
58
+ }
59
+ }
60
+
61
+ function describeSurvivors(survivors: StartedNode[], errors: string[]): string {
62
+ const names = survivors.map((n) => `${n.containerId} (pid ${n.pid})`).join(', ');
63
+ const causes = errors.length > 0 ? `; removeContainer errors: ${errors.join(' | ')}` : '';
64
+ return `test teardown left child processes running (now force-killed): ${names}${causes}`;
65
+ }