@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
@@ -19,6 +19,8 @@ import { decodeDockerId, encodeDockerId, type NodePorts } from '../orchestrator/
19
19
  import { StateStore } from '../orchestrator/state-store.js';
20
20
  import { isPidAlive } from '../orchestrator/pid-liveness.js';
21
21
  import { loadIdentity } from '../installer/identity.js';
22
+ import { describeHandleContract } from './orchestrator-handle-contract.js';
23
+ import { removeAllNodes } from './orchestrator-teardown.js';
22
24
 
23
25
  const FAKE_CHILD = `
24
26
  import fs from 'node:fs';
@@ -76,15 +78,12 @@ beforeEach(() => {
76
78
  });
77
79
 
78
80
  afterEach(async () => {
79
- // Force-kill any leftover children before cleanup.
80
- for (const orch of orchestrators) {
81
- for (const dockerId of listDockerIds(orch)) {
82
- try { await orch.removeContainer(dockerId); } catch { /* ignore */ }
83
- }
81
+ try {
82
+ await removeAllNodes(orchestrators);
83
+ } finally {
84
+ await sleep(50);
85
+ try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
84
86
  }
85
- orchestrators.length = 0;
86
- await sleep(50);
87
- try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
88
87
  });
89
88
 
90
89
  function makeOrchestrator(overrides: Partial<ConstructorParameters<typeof HostProcessOrchestrator>[0]> = {}): HostProcessOrchestrator {
@@ -103,12 +102,6 @@ function makeOrchestrator(overrides: Partial<ConstructorParameters<typeof HostPr
103
102
  return orch;
104
103
  }
105
104
 
106
- function listDockerIds(orch: HostProcessOrchestrator): string[] {
107
- // Use the state file as the source of truth since handles map is private.
108
- const state = new StateStore((orch as unknown as { rootDir: string }).rootDir);
109
- return state.load().handles.map((h) => h.dockerId);
110
- }
111
-
112
105
  async function waitFor(predicate: () => boolean | Promise<boolean>, timeoutMs = 3000, intervalMs = 50): Promise<void> {
113
106
  const deadline = Date.now() + timeoutMs;
114
107
  while (Date.now() < deadline) {
@@ -131,6 +124,21 @@ function makeRequest(containerId: string, opts: { profile?: 'storage' | 'transac
131
124
  };
132
125
  }
133
126
 
127
+ /** Replace `<workdir>/storage` with a file so the next launch throws EEXIST. */
128
+ function sabotageWorkdir(workdir: string): string {
129
+ const storage = join(workdir, 'storage');
130
+ rmSync(storage, { recursive: true, force: true });
131
+ writeFileSync(storage, 'not-a-directory', 'utf8');
132
+ return storage;
133
+ }
134
+
135
+ describeHandleContract<HostProcessOrchestrator>('HostProcessOrchestrator', {
136
+ make: () => makeOrchestrator(),
137
+ request: (containerId) => makeRequest(containerId),
138
+ started: (orch, dockerId) => waitFor(() => orch.isRunning(dockerId)),
139
+ failNextCreate: (orch, containerId) => { sabotageWorkdir(orch.getNode(containerId)!.workdir); },
140
+ });
141
+
134
142
  describe('HostProcessOrchestrator.createContainer', () => {
135
143
  it('spawns a live child and persists handle, ports, and endpoints', async () => {
136
144
  const orch = makeOrchestrator();
@@ -265,14 +273,6 @@ describe('HostProcessOrchestrator re-spawn of the same containerId', () => {
265
273
  * spawn.
266
274
  */
267
275
  describe('HostProcessOrchestrator failed launch', () => {
268
- /** Replace `<workdir>/storage` with a file so the next launch throws EEXIST. */
269
- function sabotageWorkdir(workdir: string): string {
270
- const storage = join(workdir, 'storage');
271
- rmSync(storage, { recursive: true, force: true });
272
- writeFileSync(storage, 'not-a-directory', 'utf8');
273
- return storage;
274
- }
275
-
276
276
  it('keeps the prior handle addressable, so a later terminate still reclaims the workdir', async () => {
277
277
  const orch = makeOrchestrator();
278
278
  const rootDir = (orch as unknown as { rootDir: string }).rootDir;
@@ -1002,6 +1002,7 @@ describe('child survives orchestrator exit', () => {
1002
1002
  const sizeB = statSync(handle.logPath).size;
1003
1003
  expect(sizeB).toBeGreaterThan(sizeA);
1004
1004
  } finally {
1005
+ // Started by the helper process, so no orchestrator here holds it: the one child removeAllNodes cannot see.
1005
1006
  try { process.kill(handle.pid, 'SIGKILL'); } catch { /* ignore */ }
1006
1007
  try { rmSync(handle.workdir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); } catch { /* ignore */ }
1007
1008
  }
@@ -58,7 +58,7 @@ describe('TrustCircleService — real CadreNode integration', () => {
58
58
  expect(db).not.toBeNull();
59
59
  await db!.insertOwnerKey(hostOwnerPublicKey);
60
60
 
61
- host.initializeSeedBootstrap(hostOwnerPrivateKey);
61
+ await host.initializeSeedBootstrap(hostOwnerPrivateKey);
62
62
 
63
63
  // The owner writes its own CadrePeer row up-front (the implement ticket's
64
64
  // CLI change). After this the host's own peerId is a member, so it shows up
package/src/bin/host.ts CHANGED
@@ -45,6 +45,7 @@ import {
45
45
  DONATION_AWAITING_SEED_TTL_MS,
46
46
  DONATION_PROVISIONING_TTL_MS,
47
47
  DONATION_REAP_SWEEP_MS,
48
+ type GrantListing,
48
49
  } from '../donation/index.js';
49
50
  import { NatService } from '../nat/index.js';
50
51
  import { createSecretsStore } from '../nat/secrets/index.js';
@@ -57,7 +58,7 @@ import {
57
58
  } from '../push/index.js';
58
59
  import { StrandService } from '../strands/index.js';
59
60
  import { OwnerNodeClient } from '../owner/index.js';
60
- import { createLocalUiServer, HostSettingsStore } from '../server/index.js';
61
+ import { createLocalUiServer, HostSettingsStore, type FounderServices } from '../server/index.js';
61
62
  import { openBrowser } from '../installer/browser.js';
62
63
 
63
64
  const DEFAULT_PORT = Number(process.env.CADRE_HOST_PORT ?? '8765');
@@ -353,9 +354,7 @@ program
353
354
  // (they 404). Per-donated-node WAN reachability is deferred to
354
355
  // backlog/feat-cadre-host-wan-grant-reachability, so v1 donor mode is
355
356
  // loopback-only — nothing for NatService to map without an owner node.
356
- let trustCircle: TrustCircleService | undefined;
357
- let natService: NatService | undefined;
358
- let strandService: StrandService | undefined;
357
+ let founder: FounderServices | undefined;
359
358
  if (hostOwnsCadre(cfg)) {
360
359
  // Spawn the owner node. Best-effort: a spawn failure leaves the
361
360
  // management API up (trust-circle listing degrades to local labels,
@@ -376,19 +375,19 @@ program
376
375
  const owner = new OwnerNodeClient(() => orchestrator.getOwnerAdminEndpoint());
377
376
 
378
377
  const trustCircleStore = new TrustCircleStore(cfg.dataDir);
379
- trustCircle = new TrustCircleService({
378
+ const trustCircle = new TrustCircleService({
380
379
  cadreNode: owner,
381
380
  store: trustCircleStore,
382
381
  });
383
382
 
384
- natService = new NatService({
383
+ const natService = new NatService({
385
384
  rootDir: cfg.dataDir,
386
385
  cadreNode: owner,
387
386
  });
388
387
 
389
388
  // Strand management is founder-only for the same reason as the trust
390
389
  // circle: it asks the owner node, and donor-only mode has none.
391
- strandService = new StrandService({ cadreNode: owner });
390
+ const strandService = new StrandService({ cadreNode: owner });
392
391
 
393
392
  // Push NAT-resolved invite addresses to the node on every NAT change.
394
393
  // NatService.start() also fires this once as an initial push, retried
@@ -422,6 +421,8 @@ program
422
421
  } catch (err) {
423
422
  console.error(`self trust-circle label failed: ${(err as Error).message}`);
424
423
  }
424
+
425
+ founder = { trustCircle, nat: natService, strands: strandService };
425
426
  } else {
426
427
  // Donor-only. If ownCadre was toggled off after a prior founder run,
427
428
  // orchestrator.init() re-attaches the still-running owner child (it would
@@ -442,9 +443,7 @@ program
442
443
  uiPort: cfg.uiPort,
443
444
  dataDir: cfg.dataDir,
444
445
  orchestrator,
445
- ...(trustCircle ? { trustCircle } : {}),
446
- ...(natService ? { nat: natService } : {}),
447
- ...(strandService ? { strands: strandService } : {}),
446
+ ...(founder ? { founder } : {}),
448
447
  update: updateService,
449
448
  grants: grantService,
450
449
  donations: donationService,
@@ -460,7 +459,7 @@ program
460
459
  clearInterval(reapTimer);
461
460
  donationSupervisor.stop();
462
461
  try { await server.stop(); } catch { /* ignore */ }
463
- try { await natService?.stop(); } catch { /* ignore */ }
462
+ try { await founder?.nat.stop(); } catch { /* ignore */ }
464
463
  try { await orchestrator.stopOwnerNode(); } catch { /* ignore */ }
465
464
  updateService.stop();
466
465
  console.log('cadre-host stopped.');
@@ -730,8 +729,7 @@ trust
730
729
  // A grant token lets one grantee (friend/family) present a Bearer credential to
731
730
  // ask this host to donate cadre nodes, up to a per-grantee cap. These commands
732
731
  // are thin HTTP clients of the loopback `/grants-admin` admin surface — no
733
- // bearer (same-machine admin), same posture as `invite` / `trust`. The
734
- // grantee-facing provisioning surface lands in the donation-service ticket.
732
+ // bearer (same-machine admin), same posture as `invite` / `trust`.
735
733
 
736
734
  const grant = program
737
735
  .command('grant')
@@ -819,16 +817,14 @@ grant
819
817
  process.exit(1);
820
818
  return;
821
819
  }
822
- const body = await response.json() as {
823
- grants: Array<{ token: string; label: string; maxNodes: number; expiresAt?: string; revokedAt?: string }>;
824
- };
820
+ const body = await response.json() as { grants: GrantListing[] };
825
821
  console.log('Grants:');
826
822
  if (body.grants.length === 0) {
827
823
  console.log(' (none)');
828
824
  } else {
829
825
  for (const g of body.grants) {
830
826
  const state = g.revokedAt ? ' [revoked]' : (g.expiresAt ? ` (expires ${g.expiresAt})` : '');
831
- console.log(` ${g.token} ${g.label} max=${g.maxNodes}${state}`);
827
+ console.log(` ${g.token} ${g.label} live=${g.liveNodes} max=${g.maxNodes}${state}`);
832
828
  }
833
829
  }
834
830
  process.exit(0);
@@ -836,30 +832,57 @@ grant
836
832
 
837
833
  grant
838
834
  .command('revoke')
839
- .description('Revoke a grant token (blocks future requests; live nodes are not torn down)')
835
+ .description('Revoke a grant token (blocks future requests) and shut down the nodes donated under it')
840
836
  .argument('<token>', 'Grant token to revoke')
837
+ .option('--keep-nodes', 'Leave the nodes already donated under this grant running')
841
838
  .option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
842
839
  .option('--host <host>', 'cadre-host management API host', '127.0.0.1')
843
- .action(async (token: string, opts: { port: string; host: string }) => {
840
+ .action(async (token: string, opts: { keepNodes?: boolean; port: string; host: string }) => {
844
841
  const base = `http://${opts.host}:${resolvePort(opts.port)}`;
845
- let response: Response;
846
- try {
847
- response = await fetch(`${base}/grants-admin/${encodeURIComponent(token)}`, { method: 'DELETE' });
848
- } catch (err) {
849
- console.error(`Failed to reach cadre-host at ${base}: ${(err as Error).message}`);
850
- process.exit(2);
851
- return;
852
- }
853
- if (!response.ok) {
854
- const text = await response.text().catch(() => '');
855
- console.error(`cadre-host returned ${response.status}: ${text || response.statusText}`);
856
- process.exit(1);
857
- return;
858
- }
842
+ const query = opts.keepNodes ? '?keepNodes=true' : '';
843
+ const response = await adminDelete(base, `/grants-admin/${encodeURIComponent(token)}${query}`);
844
+ const body = await response.json() as { terminated?: string[] };
859
845
  console.log(`revoked grant: ${token}`);
846
+ console.log(opts.keepNodes
847
+ ? 'existing donated nodes left running'
848
+ : `terminated ${body.terminated?.length ?? 0} donated node(s)`);
860
849
  process.exit(0);
861
850
  });
862
851
 
852
+ grant
853
+ .command('terminate')
854
+ .description('Shut down one donated node (the id shown on the Nodes page)')
855
+ .argument('<donation-id>', 'Donation id (grn_…) of the node to shut down')
856
+ .option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
857
+ .option('--host <host>', 'cadre-host management API host', '127.0.0.1')
858
+ .action(async (id: string, opts: { port: string; host: string }) => {
859
+ const base = `http://${opts.host}:${resolvePort(opts.port)}`;
860
+ await adminDelete(base, `/grants-admin/donations/${encodeURIComponent(id)}`);
861
+ console.log(`terminated donated node: ${id}`);
862
+ process.exit(0);
863
+ });
864
+
865
+ /**
866
+ * DELETE against the loopback admin surface, exiting the process on failure —
867
+ * 2 when cadre-host is unreachable, 1 on a non-OK response — so callers only
868
+ * ever see an OK response.
869
+ */
870
+ async function adminDelete(base: string, path: string): Promise<Response> {
871
+ let response: Response;
872
+ try {
873
+ response = await fetch(`${base}${path}`, { method: 'DELETE' });
874
+ } catch (err) {
875
+ console.error(`Failed to reach cadre-host at ${base}: ${(err as Error).message}`);
876
+ process.exit(2);
877
+ }
878
+ if (!response.ok) {
879
+ const text = await response.text().catch(() => '');
880
+ console.error(`cadre-host returned ${response.status}: ${text || response.statusText}`);
881
+ process.exit(1);
882
+ }
883
+ return response;
884
+ }
885
+
863
886
  // ============================================================================
864
887
  // nat subcommands
865
888
  // ============================================================================
@@ -529,6 +529,8 @@ describe('DonationService.applySeed', () => {
529
529
  });
530
530
  });
531
531
 
532
+ // The orchestrator refuses to re-spawn a container whose child is still running, so
533
+ // each case that expects a spawn has the first child (`dock_1`) go down first.
532
534
  describe('DonationService.respawn', () => {
533
535
  it('replays the persisted spawn inputs, swaps the handles, and leaves status alone', async () => {
534
536
  const orch = new FakeOrchestrator();
@@ -540,6 +542,7 @@ describe('DonationService.respawn', () => {
540
542
  // Pretend the borrower seeded it, then the child died.
541
543
  store.put({ ...store.get(provisioned.id)!, status: 'seeded' });
542
544
 
545
+ orch.crash('dock_1');
543
546
  const result = await svc.respawn(provisioned.id);
544
547
 
545
548
  expect(result).toMatchObject({ outcome: 'respawned' });
@@ -573,6 +576,7 @@ describe('DonationService.respawn', () => {
573
576
  const svc = new DonationService({ orchestrator: orch, grants, store });
574
577
 
575
578
  const provisioned = await svc.provision(baseRequest(token));
579
+ orch.crash('dock_1');
576
580
  const result = await svc.respawn(provisioned.id);
577
581
 
578
582
  expect(result).toMatchObject({ outcome: 'respawned' });
@@ -615,6 +619,7 @@ describe('DonationService.respawn', () => {
615
619
  const provisioned = await svc.provision({ ...baseRequest(token), bootstrapNodes: [] });
616
620
  store.put({ ...store.get(provisioned.id)!, status: 'seeded' });
617
621
 
622
+ orch.crash('dock_1');
618
623
  const result = await svc.respawn(provisioned.id);
619
624
 
620
625
  expect(result).toMatchObject({ outcome: 'respawned' });
@@ -710,6 +715,7 @@ describe('DonationService.respawn', () => {
710
715
  const provisioned = await svc.provision(baseRequest(token));
711
716
  store.failNextPut = true;
712
717
 
718
+ orch.crash('dock_1');
713
719
  await expect(svc.respawn(provisioned.id)).rejects.toMatchObject({ code: 'orchestrator_error' });
714
720
 
715
721
  // Stopped, not removed: `removeContainer` deletes the workdir, and the
@@ -780,6 +786,7 @@ describe('DonationService.respawn', () => {
780
786
  let terminated: Promise<void> | undefined;
781
787
  orch.onSpawned = () => { terminated ??= svc.terminate(provisioned.id); };
782
788
 
789
+ orch.crash('dock_1');
783
790
  const result = await svc.respawn(provisioned.id);
784
791
  await terminated;
785
792
 
@@ -849,6 +856,7 @@ describe('DonationService.respawn', () => {
849
856
  store.put({ ...store.get(provisioned.id)!, status: 'error', error: 'gave up' });
850
857
  };
851
858
 
859
+ orch.crash('dock_1');
852
860
  const result = await svc.respawn(provisioned.id);
853
861
 
854
862
  expect(result).toEqual({ outcome: 'abandoned', status: 'error' });
@@ -870,6 +878,7 @@ describe('DonationService.respawn', () => {
870
878
  orch.createDelayMs = 20;
871
879
  orch.onCreate = () => { store.remove(provisioned.id); };
872
880
 
881
+ orch.crash('dock_1');
873
882
  const result = await svc.respawn(provisioned.id);
874
883
 
875
884
  // No row to protect, so nothing is written back and the child is fully
@@ -956,6 +965,35 @@ describe('DonationService.terminate', () => {
956
965
  });
957
966
  });
958
967
 
968
+ describe('DonationService.terminateGrant', () => {
969
+ it('terminates every unterminated donation under the grant, error included, and leaves other grants alone', async () => {
970
+ const orch = new FakeOrchestrator();
971
+ const store = new DonationStore(join(tmpRoot, 'donations'));
972
+ const { grants, token } = makeGrants({ maxNodes: 5 });
973
+ const otherToken = grants.issue({ label: 'Bob' }).token;
974
+ const svc = new DonationService({ orchestrator: orch, grants, store });
975
+
976
+ const seeded = await svc.provision(baseRequest(token)); // dock_1
977
+ store.put({ ...store.get(seeded.id)!, status: 'seeded' });
978
+ // A loan the supervisor gave up on keeps its workdir for a later terminate —
979
+ // which, once the grant is revoked, only this teardown can make.
980
+ const gaveUp = await svc.provision(baseRequest(token)); // dock_2
981
+ store.put({ ...store.get(gaveUp.id)!, status: 'error', error: 'respawn gave up' });
982
+ const ended = await svc.provision(baseRequest(token)); // dock_3
983
+ await svc.terminate(ended.id);
984
+ const bobs = await svc.provision(baseRequest(otherToken)); // dock_4
985
+
986
+ grants.revoke(token);
987
+ const terminated = await svc.terminateGrant(token);
988
+
989
+ expect(terminated.sort()).toEqual([seeded.id, gaveUp.id].sort());
990
+ expect(store.get(seeded.id)?.status).toBe('terminated');
991
+ expect(store.get(gaveUp.id)?.status).toBe('terminated');
992
+ expect(store.get(bobs.id)?.status).toBe('awaiting_seed');
993
+ expect(orch.removed.sort()).toEqual(['dock_1', 'dock_2', 'dock_3']);
994
+ });
995
+ });
996
+
959
997
  describe('DonationService.reapStaleAwaitingSeed', () => {
960
998
  it('terminates awaiting_seed donations past the TTL and leaves fresh ones alone', async () => {
961
999
  const orch = new FakeOrchestrator();
@@ -3,14 +3,21 @@
3
3
  * from `HostProcessOrchestrator`. The donation suites lean on these semantics to
4
4
  * express races that only exist because the real class drops and rejects
5
5
  * handles, so this file exists to stop a later relaxation of the fake from
6
- * quietly turning one of those tests green. Each case names the real behaviour
7
- * it mirrors.
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.
8
14
  */
9
15
 
10
16
  import { describe, expect, it } from 'vitest';
11
17
 
12
18
  import type { OrchestratorCreateRequest } from '@serfab/cadre-provider';
13
19
 
20
+ import { describeHandleContract } from '../../__tests__/orchestrator-handle-contract.js';
14
21
  import { FakeOrchestrator } from './fake-orchestrator.js';
15
22
 
16
23
  const request = (containerId: string): OrchestratorCreateRequest => ({
@@ -20,10 +27,19 @@ const request = (containerId: string): OrchestratorCreateRequest => ({
20
27
  profile: 'storage',
21
28
  });
22
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
+
23
38
  describe('FakeOrchestrator handle lifecycle', () => {
24
39
  it('drops the prior handle when a re-spawn succeeds (mirrors dropStaleHandle)', async () => {
25
40
  const orch = new FakeOrchestrator();
26
41
  const first = await orch.createContainer(request('grn_1'));
42
+ orch.crash(first.dockerId);
27
43
  const second = await orch.createContainer(request('grn_1'));
28
44
 
29
45
  await expect(orch.stopContainer(first.dockerId)).rejects.toThrow(
@@ -39,18 +55,6 @@ describe('FakeOrchestrator handle lifecycle', () => {
39
55
  expect(orch.stopped).toEqual([second.dockerId]);
40
56
  });
41
57
 
42
- it('drops only the re-spawned container, leaving another container untouched', async () => {
43
- const orch = new FakeOrchestrator();
44
- const other = await orch.createContainer(request('grn_2'));
45
- await orch.createContainer(request('grn_1'));
46
- await orch.createContainer(request('grn_1'));
47
-
48
- // The real drop filters on containerId; a fake that cleared the whole map
49
- // would still pass every same-container case above.
50
- await expect(orch.stopContainer(other.dockerId)).resolves.toBeUndefined();
51
- expect(orch.resolveDockerId('grn_2')).toBe(other.dockerId);
52
- });
53
-
54
58
  it('keeps the prior handle live across the whole pre-drop await window', async () => {
55
59
  const orch = new FakeOrchestrator();
56
60
  const first = await orch.createContainer(request('grn_1'));
@@ -153,6 +157,7 @@ describe('FakeOrchestrator handle lifecycle', () => {
153
157
  it('fires onSpawned after the drop, with the new handle live and the old one gone', async () => {
154
158
  const orch = new FakeOrchestrator();
155
159
  const first = await orch.createContainer(request('grn_1'));
160
+ orch.crash(first.dockerId);
156
161
 
157
162
  let observed: { spawned: string; resolves: string | undefined } | undefined;
158
163
  orch.onSpawned = (dockerId) => {
@@ -4,9 +4,12 @@
4
4
  * unique spawn results, and tracks per-`dockerId` liveness so a test can crash
5
5
  * one node and let the supervisor observe exactly that one as down.
6
6
  *
7
- * This file holds no tests of its own; its contract is pinned by the sibling
8
- * `fake-orchestrator.test.ts`, which exists so a future agent cannot make a
9
- * failing donation test go green by relaxing the fake.
7
+ * This file holds no tests of its own. The handle rules it shares with
8
+ * `HostProcessOrchestrator` live in `src/__tests__/orchestrator-handle-contract.ts`
9
+ * and run against both classes, so the two cannot come to disagree unnoticed.
10
+ * The sibling `fake-orchestrator.test.ts` runs that contract and pins what only
11
+ * the fake has (its recording arrays and hooks). Both exist so a future agent
12
+ * cannot make a failing donation test go green by relaxing the fake.
10
13
  */
11
14
 
12
15
  import type {
@@ -39,6 +42,9 @@ interface FakeChild {
39
42
  * resolving the moment its container re-spawns;
40
43
  * - a **failed** `createContainer` leaves those handles exactly as it found them
41
44
  * (the real `restoreDroppedHandles`);
45
+ * - `createContainer` refuses a container whose previous child is still running
46
+ * (the real `refuseRespawnOverLiveChild`), so a test re-spawns only after a
47
+ * `stopContainer` or a {@link FakeOrchestrator.crash};
42
48
  * - `stopContainer` / `removeContainer` throw `Container not found: <dockerId>`
43
49
  * for a handle this orchestrator no longer knows (the real `requireHandle`);
44
50
  * - `reclaimWorkdir` refuses a containerId that still resolves to a live
@@ -94,6 +100,9 @@ export class FakeOrchestrator implements Orchestrator {
94
100
  this.onCreate?.(request);
95
101
  if (this.createDelayMs) await sleep(this.createDelayMs);
96
102
  if (this.failCreate) throw new Error('spawn boom');
103
+ // After the delay, as in the real class, where the check is the last `await`
104
+ // before the drop: a stop that lands during the spawn window is seen here.
105
+ this.refuseRespawnOverLiveChild(request.containerId);
97
106
  // Drop the prior handles for this container, mirroring `dropStaleHandle`.
98
107
  // Success-only, and placed here on purpose: the real drop happens after the
99
108
  // spawn's every `await` and cannot fail afterwards, and a create that throws
@@ -203,6 +212,15 @@ export class FakeOrchestrator implements Orchestrator {
203
212
  for (const listener of this.listeners) listener(info);
204
213
  }
205
214
 
215
+ /** Mirrors the real method of the same name for a child this process spawned. */
216
+ private refuseRespawnOverLiveChild(containerId: string): void {
217
+ for (const child of this.children.values()) {
218
+ if (child.containerId === containerId && child.running) {
219
+ throw new Error(`container ${containerId} is still running`);
220
+ }
221
+ }
222
+ }
223
+
206
224
  /** Mirrors `HostProcessOrchestrator.requireHandle` — unknown handles throw. */
207
225
  private requireChild(dockerId: string): FakeChild {
208
226
  const child = this.children.get(dockerId);
@@ -194,7 +194,7 @@ describe('createGrantAdminHandlers', () => {
194
194
  let listed = await handlers.listGrants();
195
195
  expect(listed.grants.map(g => g.token)).toContain(issued.grant.token);
196
196
 
197
- await handlers.deleteGrant(issued.grant.token);
197
+ await handlers.deleteGrant(issued.grant.token, { keepNodes: false });
198
198
  listed = await handlers.listGrants();
199
199
  // Revocation marks, not removes — the grant is still listed but revoked.
200
200
  expect(listed.grants.find(g => g.token === issued.grant.token)?.revokedAt).toBeTruthy();
@@ -208,6 +208,6 @@ describe('createGrantAdminHandlers', () => {
208
208
 
209
209
  it('deleteGrant surfaces not_found for an unknown token', async () => {
210
210
  const handlers = createGrantAdminHandlers(service);
211
- await expect(handlers.deleteGrant('nope')).rejects.toMatchObject({ code: 'not_found' });
211
+ await expect(handlers.deleteGrant('nope', { keepNodes: false })).rejects.toMatchObject({ code: 'not_found' });
212
212
  });
213
213
  });
@@ -227,14 +227,16 @@ export class DonationService {
227
227
 
228
228
  /**
229
229
  * Per-grant-token serialization tail. `provision` chains onto the prior
230
- * provision for the same token so the quota check and record-create are
230
+ * operation for the same token so the quota check and record-create are
231
231
  * atomic — two concurrent requests at `count = maxNodes - 1` can't both pass.
232
+ * `terminateGrant` chains onto it too, so a revoke's teardown cannot miss a
233
+ * record that a provision already queued for the grant is about to write.
232
234
  *
233
235
  * NOTE: one entry per distinct token, never evicted. Fine at household scale
234
236
  * (a handful of grants); if grant counts ever grow large, evict the tail once
235
237
  * it resolves.
236
238
  */
237
- private readonly provisionTail = new Map<string, Promise<void>>();
239
+ private readonly grantTail = new Map<string, Promise<void>>();
238
240
 
239
241
  constructor(opts: DonationServiceOptions) {
240
242
  this.orchestrator = opts.orchestrator;
@@ -693,6 +695,46 @@ export class DonationService {
693
695
  log('donation %s terminated', id);
694
696
  }
695
697
 
698
+ /**
699
+ * Terminate every donation under one grant that is not already `terminated` —
700
+ * the teardown behind an admin revoke. Returns the ids it terminated.
701
+ *
702
+ * Includes `error` records on purpose: the supervisor's give-up keeps such a
703
+ * record's workdir (its identity key) for a later `terminate`, and once the
704
+ * grant is revoked the grantee can no longer make that call, so this is the
705
+ * last thing that can reclaim it.
706
+ *
707
+ * Serialized with `provision` on the same per-grant queue. The caller revokes
708
+ * the grant first, and `provisionLocked` re-validates the grant inside that
709
+ * queue, so a provision queued ahead of this call finishes and is then
710
+ * terminated here, while one queued behind it is refused as revoked.
711
+ *
712
+ * Idempotent, and best-effort per record like the reap sweeps: a failed
713
+ * terminate is logged and the loop continues, and running it again terminates
714
+ * whatever is still left.
715
+ */
716
+ async terminateGrant(token: string): Promise<string[]> {
717
+ return this.serializeByGrant(token, () => this.terminateGrantLocked(token));
718
+ }
719
+
720
+ private async terminateGrantLocked(token: string): Promise<string[]> {
721
+ const candidates = this.store.listByGrant(token).filter(isUnterminated);
722
+ const terminated: string[] = [];
723
+ for (const donation of candidates) {
724
+ try {
725
+ // Re-read: every `terminate` awaits, so from the second record on the
726
+ // candidate list is a stale snapshot (see `reapStaleAwaitingSeed`).
727
+ if (!isUnterminated(this.store.get(donation.id))) continue;
728
+ await this.terminate(donation.id);
729
+ terminated.push(donation.id);
730
+ } catch (err) {
731
+ log('failed to terminate donation %s of grant %s: %s', donation.id, token, errorMessage(err));
732
+ }
733
+ }
734
+ log('terminated %d donation(s) under grant %s', terminated.length, token);
735
+ return terminated;
736
+ }
737
+
696
738
  /**
697
739
  * Auto-terminate donations still `awaiting_seed` past `ttlMs` — a requester
698
740
  * that provisioned a node but never presented a seed leaves an orphaned child
@@ -884,15 +926,16 @@ export class DonationService {
884
926
  }
885
927
 
886
928
  /**
887
- * Run `fn` after any in-flight provision for the same grant token, so the
888
- * quota check + record-create pair is atomic per grant.
929
+ * Run `fn` after any in-flight provision or grant teardown for the same grant
930
+ * token, so the quota check + record-create pair is atomic per grant and a
931
+ * teardown sees every record a queued provision writes.
889
932
  */
890
933
  private async serializeByGrant<T>(token: string, fn: () => Promise<T>): Promise<T> {
891
- const prior = this.provisionTail.get(token) ?? Promise.resolve();
934
+ const prior = this.grantTail.get(token) ?? Promise.resolve();
892
935
  const run = prior.then(fn, fn);
893
936
  // The stored tail swallows outcomes — a rejected provision must not reject
894
937
  // the next caller's wait, only defer it.
895
- this.provisionTail.set(token, run.then(() => undefined, () => undefined));
938
+ this.grantTail.set(token, run.then(() => undefined, () => undefined));
896
939
  return run;
897
940
  }
898
941
  }
@@ -950,6 +993,15 @@ function denialToError(reason: GrantDenyReason | undefined): DonationError {
950
993
  }
951
994
  }
952
995
 
996
+ /**
997
+ * Whether a record still needs a grant teardown. Stated once and applied twice
998
+ * in {@link DonationService.terminateGrant} — on the candidate list, and again
999
+ * per record before terminating it.
1000
+ */
1001
+ function isUnterminated(donation: Donation | undefined): boolean {
1002
+ return donation !== undefined && donation.status !== 'terminated';
1003
+ }
1004
+
953
1005
  /**
954
1006
  * Whether a record is still a stale-`awaiting_seed` reap candidate: the requester
955
1007
  * provisioned a node and has not presented a seed since `cutoff`. Stated once and