@serfab/cadre-host 0.9.0 → 0.10.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 (113) hide show
  1. package/dist/auth/index.d.ts +2 -0
  2. package/dist/auth/index.js +1 -0
  3. package/dist/auth/index.js.map +1 -1
  4. package/dist/auth/self-label.d.ts +36 -0
  5. package/dist/auth/self-label.js +57 -0
  6. package/dist/auth/self-label.js.map +1 -0
  7. package/dist/auth/trust-circle.d.ts +32 -5
  8. package/dist/auth/trust-circle.js +31 -11
  9. package/dist/auth/trust-circle.js.map +1 -1
  10. package/dist/bin/host.js +41 -5
  11. package/dist/bin/host.js.map +1 -1
  12. package/dist/donation/__tests__/fake-orchestrator.d.ts +103 -0
  13. package/dist/donation/__tests__/fake-orchestrator.js +180 -0
  14. package/dist/donation/__tests__/fake-orchestrator.js.map +1 -0
  15. package/dist/donation/donation-service.d.ts +237 -12
  16. package/dist/donation/donation-service.js +491 -34
  17. package/dist/donation/donation-service.js.map +1 -1
  18. package/dist/donation/donation-store.d.ts +7 -0
  19. package/dist/donation/donation-store.js +7 -0
  20. package/dist/donation/donation-store.js.map +1 -1
  21. package/dist/donation/donation-supervisor.d.ts +167 -0
  22. package/dist/donation/donation-supervisor.js +341 -0
  23. package/dist/donation/donation-supervisor.js.map +1 -0
  24. package/dist/donation/index.d.ts +4 -2
  25. package/dist/donation/index.js +2 -1
  26. package/dist/donation/index.js.map +1 -1
  27. package/dist/donation/types.d.ts +26 -0
  28. package/dist/donation/types.js.map +1 -1
  29. package/dist/index.d.ts +3 -1
  30. package/dist/index.js +3 -1
  31. package/dist/index.js.map +1 -1
  32. package/dist/orchestrator/host-process-orchestrator.d.ts +109 -5
  33. package/dist/orchestrator/host-process-orchestrator.js +341 -98
  34. package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
  35. package/dist/orchestrator/node-identity.d.ts +26 -0
  36. package/dist/orchestrator/node-identity.js +36 -0
  37. package/dist/orchestrator/node-identity.js.map +1 -0
  38. package/dist/orchestrator/port-allocator.d.ts +14 -0
  39. package/dist/orchestrator/port-allocator.js +45 -0
  40. package/dist/orchestrator/port-allocator.js.map +1 -1
  41. package/dist/owner/owner-node-client.d.ts +20 -5
  42. package/dist/owner/owner-node-client.js +21 -4
  43. package/dist/owner/owner-node-client.js.map +1 -1
  44. package/dist/server/error-handler.d.ts +7 -1
  45. package/dist/server/error-handler.js +23 -1
  46. package/dist/server/error-handler.js.map +1 -1
  47. package/dist/server/events/types.d.ts +3 -0
  48. package/dist/server/index.d.ts +7 -0
  49. package/dist/server/index.js +9 -3
  50. package/dist/server/index.js.map +1 -1
  51. package/dist/server/routes/grants.js +22 -3
  52. package/dist/server/routes/grants.js.map +1 -1
  53. package/dist/server/routes/strands.d.ts +20 -0
  54. package/dist/server/routes/strands.js +38 -0
  55. package/dist/server/routes/strands.js.map +1 -0
  56. package/dist/strands/index.d.ts +7 -0
  57. package/dist/strands/index.js +6 -0
  58. package/dist/strands/index.js.map +1 -0
  59. package/dist/strands/strand-service.d.ts +50 -0
  60. package/dist/strands/strand-service.js +103 -0
  61. package/dist/strands/strand-service.js.map +1 -0
  62. package/dist/strands/types.d.ts +93 -0
  63. package/dist/strands/types.js +23 -0
  64. package/dist/strands/types.js.map +1 -0
  65. package/dist/ui/assets/index-BVoqLjyX.css +1 -0
  66. package/dist/ui/assets/index-C-iks41M.js +17 -0
  67. package/dist/ui/assets/index-C-iks41M.js.map +1 -0
  68. package/dist/ui/index.html +2 -2
  69. package/dist/update/release-key.d.ts +1 -1
  70. package/dist/update/release-key.js +1 -1
  71. package/package.json +5 -5
  72. package/src/__tests__/build-targets.test.ts +26 -0
  73. package/src/__tests__/global-setup.ts +51 -0
  74. package/src/__tests__/orchestrator-env-scrub.test.ts +140 -0
  75. package/src/__tests__/orchestrator-node-identity.test.ts +204 -0
  76. package/src/__tests__/orchestrator.test.ts +387 -6
  77. package/src/auth/__tests__/self-label.test.ts +85 -0
  78. package/src/auth/__tests__/trust-circle-integration.test.ts +152 -147
  79. package/src/auth/__tests__/trust-circle.test.ts +45 -1
  80. package/src/auth/index.ts +2 -0
  81. package/src/auth/self-label.ts +74 -0
  82. package/src/auth/trust-circle.ts +45 -13
  83. package/src/bin/host.ts +1299 -1258
  84. package/src/donation/__tests__/donation-service.test.ts +836 -58
  85. package/src/donation/__tests__/donation-supervisor.test.ts +510 -0
  86. package/src/donation/__tests__/fake-orchestrator.test.ts +168 -0
  87. package/src/donation/__tests__/fake-orchestrator.ts +225 -0
  88. package/src/donation/donation-service.ts +611 -41
  89. package/src/donation/donation-store.ts +7 -0
  90. package/src/donation/donation-supervisor.ts +388 -0
  91. package/src/donation/index.ts +15 -0
  92. package/src/donation/types.ts +23 -0
  93. package/src/index.ts +14 -0
  94. package/src/orchestrator/__tests__/node-identity.test.ts +62 -0
  95. package/src/orchestrator/host-process-orchestrator.ts +336 -97
  96. package/src/orchestrator/node-identity.ts +40 -0
  97. package/src/orchestrator/port-allocator.ts +48 -0
  98. package/src/owner/owner-node-client.ts +31 -5
  99. package/src/server/__tests__/grants-route.test.ts +80 -6
  100. package/src/server/__tests__/strands-route.test.ts +233 -0
  101. package/src/server/error-handler.ts +24 -1
  102. package/src/server/events/types.ts +1 -0
  103. package/src/server/index.ts +16 -3
  104. package/src/server/routes/grants.ts +27 -3
  105. package/src/server/routes/strands.ts +54 -0
  106. package/src/strands/__tests__/strand-service.test.ts +165 -0
  107. package/src/strands/index.ts +14 -0
  108. package/src/strands/strand-service.ts +135 -0
  109. package/src/strands/types.ts +102 -0
  110. package/src/update/release-key.ts +1 -1
  111. package/dist/ui/assets/index-BlMXQPSw.js +0 -14
  112. package/dist/ui/assets/index-BlMXQPSw.js.map +0 -1
  113. package/dist/ui/assets/index-BwU5mJGF.css +0 -1
@@ -5,11 +5,21 @@
5
5
  *
6
6
  * Covers: provision → awaiting_seed (pinned keys threaded, seedToken persisted +
7
7
  * redacted), seedToken survival across a store reconstruct, per-grant quota-race
8
- * serialization, reclaim-on-post-spawn-failure, and the stale-awaiting_seed reap.
8
+ * serialization, reclaim-on-post-spawn-failure, seeding, respawn, terminate, the
9
+ * stale-awaiting_seed reap, and the stuck-provisioning reap (the host died
10
+ * between writing the row and finishing the spawn).
9
11
  *
10
- * getPeer / applySeed do real `fetch` to a live node, so their happy paths live
11
- * in the cross-package integration scenario (`cadre-host-node-donation`), not
12
- * here.
12
+ * A recurring theme: **an ending that lands mid-operation wins.** `provision`,
13
+ * `applySeed`, and `respawn` each hold an entry-time copy of the record across a
14
+ * slow `await`, and `DonationStore.put` replaces the whole row — so each must
15
+ * re-read before writing or it resurrects a loan the borrower just ended. The
16
+ * three suites drive that race through `FakeOrchestrator.onCreate` / `onSpawned`
17
+ * (the spawn window, before and after the orchestrator drops the old handle) and
18
+ * a stubbed `globalThis.fetch` (seed window).
19
+ *
20
+ * `applySeed` is exercised here against that `fetch` stub. `getPeer` still does a
21
+ * real `fetch` to a live node, so its happy path lives in the cross-package
22
+ * integration scenario (`cadre-host-node-donation`), not here.
13
23
  */
14
24
 
15
25
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
@@ -17,78 +27,65 @@ import { mkdtempSync, rmSync } from 'node:fs';
17
27
  import { tmpdir } from 'node:os';
18
28
  import { join } from 'node:path';
19
29
 
20
- import type {
21
- Orchestrator,
22
- OrchestratorCreateRequest,
23
- OrchestratorCreateResult,
24
- OrchestratorStats,
25
- } from '@serfab/cadre-provider';
26
-
27
- import { DonationService } from '../donation-service.js';
30
+ import {
31
+ DonationService,
32
+ DONATION_AWAITING_SEED_TTL_MS,
33
+ DONATION_PROVISIONING_TTL_MS,
34
+ } from '../donation-service.js';
28
35
  import { DonationStore } from '../donation-store.js';
29
36
  import { GrantService } from '../grant-service.js';
30
37
  import { GrantStore } from '../grant-store.js';
38
+ import type { Donation } from '../types.js';
31
39
  import { DonationError } from '../types.js';
40
+ import { FakeOrchestrator } from './fake-orchestrator.js';
32
41
 
33
- function sleep(ms: number): Promise<void> {
34
- return new Promise((res) => setTimeout(res, ms));
35
- }
36
-
37
- /** Records every create/stop/remove; returns deterministic, unique spawn results. */
38
- class FakeOrchestrator implements Orchestrator {
39
- createCalls: OrchestratorCreateRequest[] = [];
40
- stopped: string[] = [];
41
- removed: string[] = [];
42
- failCreate = false;
43
- createDelayMs = 0;
44
- private counter = 0;
45
-
46
- async createContainer(request: OrchestratorCreateRequest): Promise<OrchestratorCreateResult> {
47
- this.createCalls.push(request);
48
- if (this.createDelayMs) await sleep(this.createDelayMs);
49
- if (this.failCreate) throw new Error('spawn boom');
50
- const n = ++this.counter;
51
- return {
52
- dockerId: `dock_${n}`,
53
- healthEndpoint: `http://127.0.0.1:${9000 + n}/health`,
54
- metricsEndpoint: `http://127.0.0.1:${9000 + n}/metrics`,
55
- seedEndpoint: `http://127.0.0.1:${9000 + n}/seed`,
56
- seedToken: `seed-token-${n}`,
57
- p2pPort: 4000 + n,
58
- };
59
- }
60
-
61
- async stopContainer(dockerId: string): Promise<void> {
62
- this.stopped.push(dockerId);
63
- }
64
-
65
- async removeContainer(dockerId: string): Promise<void> {
66
- this.removed.push(dockerId);
67
- }
68
-
69
- async getStats(): Promise<OrchestratorStats> {
70
- return { cpuPercent: 0, memoryBytes: 0, networkRxBytes: 0, networkTxBytes: 0 };
71
- }
42
+ /**
43
+ * A well-formed 32-byte base64url owner key. `provision` shape-checks the pins at
44
+ * the boundary, so a placeholder string like `OWNER_KEY` is now a
45
+ * rejection rather than an opaque fixture.
46
+ */
47
+ const OWNER_KEY = Buffer.alloc(32, 7).toString('base64url');
72
48
 
73
- async isRunning(): Promise<boolean> {
74
- return true;
75
- }
49
+ /** A store whose next `put` fails once — the post-spawn write-failure path. */
50
+ class FlakyDonationStore extends DonationStore {
51
+ failNextPut = false;
76
52
 
77
- async getLogs(): Promise<string> {
78
- return '';
53
+ override put(donation: Donation): void {
54
+ if (this.failNextPut) {
55
+ this.failNextPut = false;
56
+ throw new DonationError('storage_error', 'disk full');
57
+ }
58
+ super.put(donation);
79
59
  }
80
60
  }
81
61
 
82
62
  let tmpRoot: string;
63
+ const originalFetch = globalThis.fetch;
83
64
 
84
65
  beforeEach(() => {
85
66
  tmpRoot = mkdtempSync(join(tmpdir(), 'cadre-host-donation-svc-'));
86
67
  });
87
68
 
88
69
  afterEach(() => {
70
+ globalThis.fetch = originalFetch;
89
71
  try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
90
72
  });
91
73
 
74
+ /**
75
+ * Stub the donated node's `POST /seed` with `body`. `duringRequest` — when given
76
+ * — runs exactly once and is awaited before the response resolves, so whatever
77
+ * it starts lands *inside* the seed window. It is the `fetch` analogue of
78
+ * `FakeOrchestrator.onCreate`.
79
+ */
80
+ function stubSeedFetch(body: unknown, duringRequest?: () => unknown): void {
81
+ let ran: Promise<unknown> | undefined;
82
+ globalThis.fetch = (async () => {
83
+ ran ??= (async () => duringRequest?.())();
84
+ await ran;
85
+ return { ok: true, json: async () => body } as unknown as Response;
86
+ }) as typeof globalThis.fetch;
87
+ }
88
+
92
89
  function makeGrants(opts?: { maxNodes?: number }): { grants: GrantService; token: string } {
93
90
  const grants = new GrantService({ store: new GrantStore(join(tmpRoot, 'grants')) });
94
91
  const token = grants.issue({ label: 'Alice', ...(opts?.maxNodes ? { maxNodes: opts.maxNodes } : {}) }).token;
@@ -99,7 +96,7 @@ const baseRequest = (grantToken: string) => ({
99
96
  grantToken,
100
97
  partyId: 'party-P',
101
98
  bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooReq'],
102
- ownerKeys: ['owner-key-b64url'],
99
+ ownerKeys: [OWNER_KEY],
103
100
  });
104
101
 
105
102
  describe('DonationService.provision', () => {
@@ -121,7 +118,7 @@ describe('DonationService.provision', () => {
121
118
 
122
119
  // The orchestrator got the requester's pinned owner key (the seed-trust anchor).
123
120
  expect(orch.createCalls).toHaveLength(1);
124
- expect(orch.createCalls[0].pinnedOwnerKeys).toEqual(['owner-key-b64url']);
121
+ expect(orch.createCalls[0].pinnedOwnerKeys).toEqual([OWNER_KEY]);
125
122
  expect(orch.createCalls[0].containerId).toBe(view.id);
126
123
  expect(orch.createCalls[0].partyId).toBe('party-P');
127
124
 
@@ -132,6 +129,55 @@ describe('DonationService.provision', () => {
132
129
  expect(store.liveNodeCount(token)).toBe(1);
133
130
  });
134
131
 
132
+ /**
133
+ * The pins are checked before the grant is even validated, so a typo costs the
134
+ * requester nothing: no orchestrator call, no record, and — the point — no quota
135
+ * slot burned on a node that could never have booted. Previously the request was
136
+ * answered as provisioned and the child died at startup, leaving an `error`
137
+ * record and a slot the grantee had to notice and reclaim.
138
+ */
139
+ it('rejects a malformed ownerKeys entry without provisioning or burning a quota slot', async () => {
140
+ const orch = new FakeOrchestrator();
141
+ const store = new DonationStore(join(tmpRoot, 'donations'));
142
+ const { grants, token } = makeGrants();
143
+ const svc = new DonationService({ orchestrator: orch, grants, store });
144
+
145
+ await expect(
146
+ svc.provision({ ...baseRequest(token), ownerKeys: [OWNER_KEY, 'this-is-not-a-key'] }),
147
+ ).rejects.toMatchObject({ code: 'invalid_request', message: expect.stringContaining('this-is-not-a-key') });
148
+
149
+ expect(orch.createCalls).toEqual([]);
150
+ expect(store.list()).toEqual([]);
151
+ expect(store.liveNodeCount(token)).toBe(0);
152
+ });
153
+
154
+ it('rejects a blank ownerKeys entry the same way', async () => {
155
+ const orch = new FakeOrchestrator();
156
+ const store = new DonationStore(join(tmpRoot, 'donations'));
157
+ const { grants, token } = makeGrants();
158
+ const svc = new DonationService({ orchestrator: orch, grants, store });
159
+
160
+ await expect(
161
+ svc.provision({ ...baseRequest(token), ownerKeys: [''] }),
162
+ ).rejects.toBeInstanceOf(DonationError);
163
+
164
+ expect(orch.createCalls).toEqual([]);
165
+ expect(store.list()).toEqual([]);
166
+ expect(store.liveNodeCount(token)).toBe(0);
167
+ });
168
+
169
+ it('threads the trimmed keys onto the record and the child, not the raw input', async () => {
170
+ const orch = new FakeOrchestrator();
171
+ const store = new DonationStore(join(tmpRoot, 'donations'));
172
+ const { grants, token } = makeGrants();
173
+ const svc = new DonationService({ orchestrator: orch, grants, store });
174
+
175
+ const view = await svc.provision({ ...baseRequest(token), ownerKeys: [` ${OWNER_KEY}\n`] });
176
+
177
+ expect(orch.createCalls[0].pinnedOwnerKeys).toEqual([OWNER_KEY]);
178
+ expect(store.get(view.id)?.ownerKeys).toEqual([OWNER_KEY]);
179
+ });
180
+
135
181
  it('honours an explicit transaction profile', async () => {
136
182
  const orch = new FakeOrchestrator();
137
183
  const { grants, token } = makeGrants();
@@ -159,6 +205,71 @@ describe('DonationService.provision', () => {
159
205
  .rejects.toMatchObject({ code: 'unauthorized' });
160
206
  expect(orch.createCalls).toHaveLength(0);
161
207
  });
208
+
209
+ it('lets a borrower terminate that lands mid-spawn win, and reclaims the new child', async () => {
210
+ const orch = new FakeOrchestrator();
211
+ const store = new DonationStore(join(tmpRoot, 'donations'));
212
+ const { grants, token } = makeGrants();
213
+ const svc = new DonationService({ orchestrator: orch, grants, store });
214
+
215
+ // The borrower's DELETE lands inside the spawn window. The record names no
216
+ // dockerId yet, so terminate's own cleanup branch finds nothing to stop —
217
+ // the abandon path here is the only thing that can reclaim this child.
218
+ orch.createDelayMs = 20;
219
+ let terminated: Promise<void> | undefined;
220
+ orch.onCreate = (request) => { terminated ??= svc.terminate(request.containerId); };
221
+
222
+ await expect(svc.provision(baseRequest(token))).rejects.toMatchObject({ code: 'invalid_state' });
223
+ await terminated;
224
+
225
+ const stored = store.list().find((d) => d.grantToken === token)!;
226
+ expect(stored.status).toBe('terminated');
227
+ expect(stored.dockerId).toBeUndefined();
228
+ expect(orch.removed).toEqual(['dock_1']);
229
+ // The ended loan no longer holds a quota slot.
230
+ expect(store.liveNodeCount(token)).toBe(0);
231
+ });
232
+
233
+ it('does not rewrite a terminated record as error when the spawn then fails', async () => {
234
+ const orch = new FakeOrchestrator();
235
+ const store = new DonationStore(join(tmpRoot, 'donations'));
236
+ const { grants, token } = makeGrants();
237
+ const svc = new DonationService({ orchestrator: orch, grants, store });
238
+
239
+ // Ending first, host fault second: the borrower's `terminated` must survive
240
+ // — a host-side error is not allowed to overwrite the borrower's own ending.
241
+ orch.createDelayMs = 20;
242
+ orch.failCreate = true;
243
+ let terminated: Promise<void> | undefined;
244
+ orch.onCreate = (request) => { terminated ??= svc.terminate(request.containerId); };
245
+
246
+ await expect(svc.provision(baseRequest(token)))
247
+ .rejects.toMatchObject({ code: 'orchestrator_error' });
248
+ await terminated;
249
+
250
+ const stored = store.list().find((d) => d.grantToken === token)!;
251
+ expect(stored.status).toBe('terminated');
252
+ expect(stored.error).toBeUndefined();
253
+ expect(orch.removed).toEqual([]);
254
+ expect(store.liveNodeCount(token)).toBe(0);
255
+ });
256
+
257
+ it('reclaims the new child and recreates nothing when the record vanishes mid-spawn', async () => {
258
+ const orch = new FakeOrchestrator();
259
+ const store = new DonationStore(join(tmpRoot, 'donations'));
260
+ const { grants, token } = makeGrants();
261
+ const svc = new DonationService({ orchestrator: orch, grants, store });
262
+
263
+ orch.createDelayMs = 20;
264
+ orch.onCreate = (request) => { store.remove(request.containerId); };
265
+
266
+ await expect(svc.provision(baseRequest(token))).rejects.toMatchObject({ code: 'not_found' });
267
+
268
+ // No row to protect, so none is written back and the child is fully
269
+ // reclaimed rather than left holding ports and a workdir nothing names.
270
+ expect(store.list()).toEqual([]);
271
+ expect(orch.removed).toEqual(['dock_1']);
272
+ });
162
273
  });
163
274
 
164
275
  describe('DonationService seedToken persistence', () => {
@@ -242,11 +353,522 @@ describe('DonationService reclaim-on-failure', () => {
242
353
  await expect(svc.provision(baseRequest(token)))
243
354
  .rejects.toMatchObject({ code: 'orchestrator_error' });
244
355
  expect(orch.removed).toEqual([]);
356
+ // Nor does it reclaim the workdir by name: `createContainer`'s own unwind
357
+ // already removed a directory its failed spawn created, and a second
358
+ // attempt from here would be redundant.
359
+ expect(orch.reclaimedWorkdirs).toEqual([]);
245
360
  expect(store.list().find((d) => d.grantToken === token)?.status).toBe('error');
246
361
  expect(store.liveNodeCount(token)).toBe(0);
247
362
  });
248
363
  });
249
364
 
365
+ describe('DonationService.applySeed', () => {
366
+ it('marks the record seeded and reports the peers the node added', async () => {
367
+ const orch = new FakeOrchestrator();
368
+ const store = new DonationStore(join(tmpRoot, 'donations'));
369
+ const { grants, token } = makeGrants();
370
+ const svc = new DonationService({ orchestrator: orch, grants, store });
371
+
372
+ const provisioned = await svc.provision(baseRequest(token));
373
+ stubSeedFetch({ success: true, peersAdded: 2 });
374
+
375
+ const result = await svc.applySeed(provisioned.id, 'encoded-seed');
376
+
377
+ expect(result).toEqual({ outcome: 'seeded', peersAdded: 2 });
378
+ expect(store.get(provisioned.id)?.status).toBe('seeded');
379
+ });
380
+
381
+ it('reports a node that refuses the seed, and writes nothing', async () => {
382
+ const orch = new FakeOrchestrator();
383
+ const store = new DonationStore(join(tmpRoot, 'donations'));
384
+ const { grants, token } = makeGrants();
385
+ const svc = new DonationService({ orchestrator: orch, grants, store });
386
+
387
+ const provisioned = await svc.provision(baseRequest(token));
388
+ const before = store.get(provisioned.id)!;
389
+ stubSeedFetch({ success: false, error: 'seed signer is not a trusted owner' });
390
+
391
+ const result = await svc.applySeed(provisioned.id, 'encoded-seed');
392
+
393
+ expect(result).toEqual({ outcome: 'rejected', error: 'seed signer is not a trusted owner' });
394
+ // The node's own policy call is not our business to record.
395
+ expect(store.get(provisioned.id)).toEqual(before);
396
+ });
397
+
398
+ it('lets a borrower terminate that lands mid-seed win', async () => {
399
+ const orch = new FakeOrchestrator();
400
+ const store = new DonationStore(join(tmpRoot, 'donations'));
401
+ const { grants, token } = makeGrants();
402
+ const svc = new DonationService({ orchestrator: orch, grants, store });
403
+
404
+ const provisioned = await svc.provision(baseRequest(token));
405
+ // The borrower's DELETE lands inside the seed window: the node accepts the
406
+ // seed, but by the time it answers the loan is over.
407
+ let terminated: Promise<void> | undefined;
408
+ stubSeedFetch({ success: true, peersAdded: 2 }, () => {
409
+ terminated = svc.terminate(provisioned.id);
410
+ return terminated;
411
+ });
412
+
413
+ const result = await svc.applySeed(provisioned.id, 'encoded-seed');
414
+ await terminated;
415
+
416
+ expect(result).toEqual({ outcome: 'abandoned', status: 'terminated' });
417
+ const stored = store.get(provisioned.id)!;
418
+ expect(stored.status).toBe('terminated');
419
+ expect(stored.dockerId).toBe('dock_1');
420
+ // Unlike an abandoned respawn there is nothing left for us to clean up: the
421
+ // record named its dockerId throughout, so the ending stopped + reclaimed
422
+ // the child itself, and no live record remains for a supervisor to respawn.
423
+ expect(orch.stopped).toEqual(['dock_1']);
424
+ expect(orch.removed).toEqual(['dock_1']);
425
+ expect(store.liveNodeCount(token)).toBe(0);
426
+ });
427
+
428
+ it('lets a stale-seed reap that lands mid-seed win without restarting the TTL clock', async () => {
429
+ const orch = new FakeOrchestrator();
430
+ const store = new DonationStore(join(tmpRoot, 'donations'));
431
+ const { grants, token } = makeGrants();
432
+ let clock = new Date('2025-01-01T00:00:00.000Z');
433
+ const svc = new DonationService({ orchestrator: orch, grants, store, now: () => clock });
434
+
435
+ const provisioned = await svc.provision(baseRequest(token));
436
+
437
+ // Past the awaiting_seed TTL, so the sweep collects this record.
438
+ clock = new Date(clock.getTime() + DONATION_AWAITING_SEED_TTL_MS + 60_000);
439
+ const reapAtIso = clock.toISOString();
440
+ let reaped: Promise<string[]> | undefined;
441
+ stubSeedFetch({ success: true, peersAdded: 2 }, () => {
442
+ // Move the clock on once the reap's terminal write has landed, so a seed
443
+ // write that overwrote it would show up as a *later* updatedAt.
444
+ reaped = svc.reapStaleAwaitingSeed().then((ids) => {
445
+ clock = new Date(clock.getTime() + 1_000);
446
+ return ids;
447
+ });
448
+ return reaped;
449
+ });
450
+
451
+ const result = await svc.applySeed(provisioned.id, 'encoded-seed');
452
+ await expect(reaped).resolves.toEqual([provisioned.id]);
453
+
454
+ expect(result).toEqual({ outcome: 'abandoned', status: 'terminated' });
455
+ const stored = store.get(provisioned.id)!;
456
+ expect(stored.status).toBe('terminated');
457
+ // The reap's own "now" — a seed write here would restart the TTL clock.
458
+ expect(stored.updatedAt).toBe(reapAtIso);
459
+ expect(store.liveNodeCount(token)).toBe(0);
460
+ });
461
+
462
+ it('abandons with no status when the record vanishes mid-seed', async () => {
463
+ const orch = new FakeOrchestrator();
464
+ const store = new DonationStore(join(tmpRoot, 'donations'));
465
+ const { grants, token } = makeGrants();
466
+ const svc = new DonationService({ orchestrator: orch, grants, store });
467
+
468
+ const provisioned = await svc.provision(baseRequest(token));
469
+ stubSeedFetch({ success: true, peersAdded: 2 }, () => { store.remove(provisioned.id); });
470
+
471
+ const result = await svc.applySeed(provisioned.id, 'encoded-seed');
472
+
473
+ expect(result).toEqual({ outcome: 'abandoned' });
474
+ expect(store.get(provisioned.id)).toBeUndefined();
475
+ });
476
+
477
+ it('refuses to seed a terminated loan on entry', async () => {
478
+ const orch = new FakeOrchestrator();
479
+ const store = new DonationStore(join(tmpRoot, 'donations'));
480
+ const { grants, token } = makeGrants();
481
+ const svc = new DonationService({ orchestrator: orch, grants, store });
482
+
483
+ const provisioned = await svc.provision(baseRequest(token));
484
+ await svc.terminate(provisioned.id);
485
+ stubSeedFetch({ success: true, peersAdded: 2 });
486
+
487
+ await expect(svc.applySeed(provisioned.id, 'encoded-seed'))
488
+ .rejects.toMatchObject({ code: 'invalid_state' });
489
+ });
490
+ });
491
+
492
+ describe('DonationService.respawn', () => {
493
+ it('replays the persisted spawn inputs, swaps the handles, and leaves status alone', async () => {
494
+ const orch = new FakeOrchestrator();
495
+ const store = new DonationStore(join(tmpRoot, 'donations'));
496
+ const { grants, token } = makeGrants();
497
+ const svc = new DonationService({ orchestrator: orch, grants, store });
498
+
499
+ const provisioned = await svc.provision(baseRequest(token));
500
+ // Pretend the borrower seeded it, then the child died.
501
+ store.put({ ...store.get(provisioned.id)!, status: 'seeded' });
502
+
503
+ const result = await svc.respawn(provisioned.id);
504
+
505
+ expect(result).toMatchObject({ outcome: 'respawned' });
506
+ expect(result.outcome === 'respawned' && result.donation.status).toBe('seeded');
507
+ // The replayed spawn carried the persisted inputs, not fresh ones.
508
+ expect(orch.createCalls).toHaveLength(2);
509
+ expect(orch.createCalls[1]).toMatchObject({
510
+ containerId: provisioned.id,
511
+ partyId: 'party-P',
512
+ bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooReq'],
513
+ pinnedOwnerKeys: [OWNER_KEY],
514
+ profile: 'storage',
515
+ });
516
+
517
+ const stored = store.get(provisioned.id)!;
518
+ expect(stored.status).toBe('seeded');
519
+ expect(stored.dockerId).toBe('dock_2');
520
+ expect(stored.seedEndpoint).toBe('http://127.0.0.1:9002/seed');
521
+ expect(stored.seedToken).toBe('seed-token-2');
522
+ expect(stored.respawn?.attempts).toBe(1);
523
+ // Nothing was stopped or reclaimed — the workdir (identity key, node-local
524
+ // stores) has to survive for the respawn to be the same node.
525
+ expect(orch.stopped).toEqual([]);
526
+ expect(orch.removed).toEqual([]);
527
+ });
528
+
529
+ it('keeps awaiting_seed records awaiting_seed so a later seed hits the new endpoint', async () => {
530
+ const orch = new FakeOrchestrator();
531
+ const store = new DonationStore(join(tmpRoot, 'donations'));
532
+ const { grants, token } = makeGrants();
533
+ const svc = new DonationService({ orchestrator: orch, grants, store });
534
+
535
+ const provisioned = await svc.provision(baseRequest(token));
536
+ const result = await svc.respawn(provisioned.id);
537
+
538
+ expect(result).toMatchObject({ outcome: 'respawned' });
539
+ expect(result.outcome === 'respawned' && result.donation.status).toBe('awaiting_seed');
540
+ expect(store.get(provisioned.id)?.seedToken).toBe('seed-token-2');
541
+ });
542
+
543
+ it('skips a record written before the spawn inputs were persisted', async () => {
544
+ const orch = new FakeOrchestrator();
545
+ const store = new DonationStore(join(tmpRoot, 'donations'));
546
+ const { grants, token } = makeGrants();
547
+ const svc = new DonationService({ orchestrator: orch, grants, store });
548
+
549
+ // A pre-existing row on disk: no bootstrapNodes / ownerKeys to replay.
550
+ store.put({
551
+ id: 'grn_legacy',
552
+ grantToken: token,
553
+ partyId: 'party-P',
554
+ profile: 'storage',
555
+ status: 'seeded',
556
+ dockerId: 'dock_old',
557
+ createdAt: '2025-01-01T00:00:00.000Z',
558
+ updatedAt: '2025-01-01T00:00:00.000Z',
559
+ });
560
+
561
+ // A skip, not a throw — a sweep over the store must survive these.
562
+ await expect(svc.respawn('grn_legacy')).resolves.toEqual({ outcome: 'not_respawnable' });
563
+ expect(orch.createCalls).toHaveLength(0);
564
+ expect(store.get('grn_legacy')?.dockerId).toBe('dock_old');
565
+ });
566
+
567
+ it('refuses to resurrect a terminated loan', async () => {
568
+ const orch = new FakeOrchestrator();
569
+ const store = new DonationStore(join(tmpRoot, 'donations'));
570
+ const { grants, token } = makeGrants();
571
+ const svc = new DonationService({ orchestrator: orch, grants, store });
572
+
573
+ const provisioned = await svc.provision(baseRequest(token));
574
+ await svc.terminate(provisioned.id);
575
+
576
+ await expect(svc.respawn(provisioned.id)).rejects.toMatchObject({ code: 'invalid_state' });
577
+ expect(orch.createCalls).toHaveLength(1);
578
+ });
579
+
580
+ it('refuses a record whose provision is still in flight', async () => {
581
+ const orch = new FakeOrchestrator();
582
+ const store = new DonationStore(join(tmpRoot, 'donations'));
583
+ const { grants, token } = makeGrants();
584
+ const svc = new DonationService({ orchestrator: orch, grants, store });
585
+
586
+ const provisioned = await svc.provision(baseRequest(token));
587
+ // A host that died mid-provision leaves exactly this row: spawn inputs
588
+ // persisted, status never advanced. Replaying it would race the provision
589
+ // and strand the record in a status no reap sweep collects.
590
+ store.put({ ...store.get(provisioned.id)!, status: 'provisioning' });
591
+
592
+ await expect(svc.respawn(provisioned.id)).rejects.toMatchObject({ code: 'invalid_state' });
593
+ expect(orch.createCalls).toHaveLength(1);
594
+ });
595
+
596
+ it('records the attempt and throws when the spawn fails', async () => {
597
+ const orch = new FakeOrchestrator();
598
+ const store = new DonationStore(join(tmpRoot, 'donations'));
599
+ const { grants, token } = makeGrants();
600
+ const svc = new DonationService({ orchestrator: orch, grants, store });
601
+
602
+ const provisioned = await svc.provision(baseRequest(token));
603
+ orch.failCreate = true;
604
+
605
+ await expect(svc.respawn(provisioned.id)).rejects.toMatchObject({ code: 'orchestrator_error' });
606
+
607
+ const stored = store.get(provisioned.id)!;
608
+ expect(stored.respawn?.attempts).toBe(1);
609
+ // The old handles are untouched — the caller owns backoff, not cleanup.
610
+ expect(stored.dockerId).toBe('dock_1');
611
+ expect(stored.status).toBe('awaiting_seed');
612
+ });
613
+
614
+ it('stops — but never reclaims — the new child when the post-spawn write fails', async () => {
615
+ const orch = new FakeOrchestrator();
616
+ // Fails exactly once, on the write that follows the second createContainer.
617
+ // It must fail only once, or the merge write below fails too and the test
618
+ // proves nothing.
619
+ const store = new FlakyDonationStore(join(tmpRoot, 'donations'));
620
+ const { grants, token } = makeGrants();
621
+ const svc = new DonationService({ orchestrator: orch, grants, store });
622
+
623
+ const provisioned = await svc.provision(baseRequest(token));
624
+ store.failNextPut = true;
625
+
626
+ await expect(svc.respawn(provisioned.id)).rejects.toMatchObject({ code: 'orchestrator_error' });
627
+
628
+ // Stopped, not removed: `removeContainer` deletes the workdir, and the
629
+ // workdir is the identity key that makes a later respawn the SAME node.
630
+ expect(orch.stopped).toEqual(['dock_2']);
631
+ expect(orch.removed).toEqual([]);
632
+ // The record names the child that actually exists. `createContainer`
633
+ // already dropped the handle `dock_1` named, so leaving it there would aim
634
+ // every later stop/reclaim at an id the orchestrator cannot resolve — and
635
+ // strand the node's workdir on disk forever.
636
+ const stored = store.get(provisioned.id)!;
637
+ expect(stored.dockerId).toBe('dock_2');
638
+ expect(stored.seedEndpoint).toBe('http://127.0.0.1:9002/seed');
639
+ expect(stored.seedToken).toBe('seed-token-2');
640
+ // The attempt failed, so status and the reap clock are left alone.
641
+ expect(stored.status).toBe('awaiting_seed');
642
+ expect(stored.updatedAt).toBe(provisioned.updatedAt);
643
+ expect(stored.respawn?.attempts).toBe(1);
644
+ });
645
+
646
+ it('lets a borrower terminate that lands mid-spawn win, and cleans up the new child', async () => {
647
+ const orch = new FakeOrchestrator();
648
+ const store = new DonationStore(join(tmpRoot, 'donations'));
649
+ const { grants, token } = makeGrants();
650
+ const svc = new DonationService({ orchestrator: orch, grants, store });
651
+
652
+ const provisioned = await svc.provision(baseRequest(token));
653
+ store.put({ ...store.get(provisioned.id)!, status: 'seeded' });
654
+
655
+ // The borrower's DELETE lands inside the respawn's spawn window.
656
+ orch.createDelayMs = 20;
657
+ let terminated: Promise<void> | undefined;
658
+ orch.onCreate = () => { terminated ??= svc.terminate(provisioned.id); };
659
+
660
+ const result = await svc.respawn(provisioned.id);
661
+ await terminated;
662
+
663
+ expect(result).toEqual({ outcome: 'abandoned', status: 'terminated' });
664
+ const stored = store.get(provisioned.id)!;
665
+ // The ending's write stands whole: no new handles, no attempt counter.
666
+ expect(stored.status).toBe('terminated');
667
+ expect(stored.dockerId).toBe('dock_1');
668
+ expect(stored.seedToken).toBe('seed-token-1');
669
+ expect(stored.respawn).toBeUndefined();
670
+ // The child the ending could not see is stopped AND reclaimed — it is the
671
+ // only thing left holding that spawn's ports and workdir.
672
+ expect(orch.stopped).toContain('dock_2');
673
+ expect(orch.removed).toContain('dock_2');
674
+ // The ended loan no longer holds a quota slot.
675
+ expect(store.liveNodeCount(token)).toBe(0);
676
+ });
677
+
678
+ it('reclaims the new child because the terminate could not clean up the old one', async () => {
679
+ const orch = new FakeOrchestrator();
680
+ const store = new DonationStore(join(tmpRoot, 'donations'));
681
+ const { grants, token } = makeGrants();
682
+ const svc = new DonationService({ orchestrator: orch, grants, store });
683
+
684
+ const provisioned = await svc.provision(baseRequest(token));
685
+ store.put({ ...store.get(provisioned.id)!, status: 'seeded' });
686
+
687
+ // The borrower's DELETE lands in the window *after* the spawn dropped the
688
+ // old handle — the window `abandonRespawn` was written for. (The test above
689
+ // drives the same terminate from `onCreate`, i.e. before the drop, where the
690
+ // terminate's own cleanup still works.) No `createDelayMs` needed: `onSpawned`
691
+ // fires synchronously and `terminate` writes its `terminated` row before its
692
+ // first `await`, so the row is terminal by the time `respawn` re-reads.
693
+ let terminated: Promise<void> | undefined;
694
+ orch.onSpawned = () => { terminated ??= svc.terminate(provisioned.id); };
695
+
696
+ const result = await svc.respawn(provisioned.id);
697
+ await terminated;
698
+
699
+ expect(result).toEqual({ outcome: 'abandoned', status: 'terminated' });
700
+ // Exact equality, not `toContain`: dock_1's ABSENCE is the whole claim. The
701
+ // terminate aimed its own stop and reclaim at dock_1 — the handle this spawn
702
+ // had already dropped — so it cleaned up nothing at all. That is why the
703
+ // abandoned respawn has to reclaim dock_2 rather than merely stop it: dock_2
704
+ // is the only thing left holding that spawn's ports and workdir.
705
+ expect(orch.stopped).toEqual(['dock_2']);
706
+ expect(orch.removed).toEqual(['dock_2']);
707
+ // The ending's write stands whole, still naming the child it knew about.
708
+ expect(store.get(provisioned.id)!.status).toBe('terminated');
709
+ expect(store.get(provisioned.id)!.dockerId).toBe('dock_1');
710
+ expect(store.liveNodeCount(token)).toBe(0);
711
+ });
712
+
713
+ it('lets a stale-seed reap that lands mid-spawn win without restarting the TTL clock', async () => {
714
+ const orch = new FakeOrchestrator();
715
+ const store = new DonationStore(join(tmpRoot, 'donations'));
716
+ const { grants, token } = makeGrants();
717
+ let clock = new Date('2025-01-01T00:00:00.000Z');
718
+ const svc = new DonationService({ orchestrator: orch, grants, store, now: () => clock });
719
+
720
+ const provisioned = await svc.provision(baseRequest(token));
721
+
722
+ // Past the awaiting_seed TTL, so the sweep collects this record.
723
+ clock = new Date(clock.getTime() + DONATION_AWAITING_SEED_TTL_MS + 60_000);
724
+ const reapAtIso = clock.toISOString();
725
+ orch.createDelayMs = 20;
726
+ let reaped: Promise<string[]> | undefined;
727
+ orch.onCreate = () => {
728
+ // Move the clock on once the reap's terminal write has landed, so a
729
+ // respawn that overwrote it would show up as a *later* updatedAt.
730
+ reaped ??= svc.reapStaleAwaitingSeed().then((ids) => {
731
+ clock = new Date(clock.getTime() + 1_000);
732
+ return ids;
733
+ });
734
+ };
735
+
736
+ const result = await svc.respawn(provisioned.id);
737
+ await expect(reaped).resolves.toEqual([provisioned.id]);
738
+
739
+ expect(result).toEqual({ outcome: 'abandoned', status: 'terminated' });
740
+ const stored = store.get(provisioned.id)!;
741
+ expect(stored.status).toBe('terminated');
742
+ // The reap's own "now" — a respawn write here would restart the TTL clock.
743
+ expect(stored.updatedAt).toBe(reapAtIso);
744
+ expect(orch.stopped).toContain('dock_2');
745
+ expect(orch.removed).toContain('dock_2');
746
+ expect(store.liveNodeCount(token)).toBe(0);
747
+ });
748
+
749
+ it('stops but does not reclaim the new child when the record goes error mid-spawn', async () => {
750
+ const orch = new FakeOrchestrator();
751
+ const store = new DonationStore(join(tmpRoot, 'donations'));
752
+ const { grants, token } = makeGrants();
753
+ const svc = new DonationService({ orchestrator: orch, grants, store });
754
+
755
+ const provisioned = await svc.provision(baseRequest(token));
756
+
757
+ // A give-up write landing inside the spawn window. Both spawns share one
758
+ // workdir, so reclaiming here would delete the identity key `error`
759
+ // deliberately keeps.
760
+ orch.createDelayMs = 20;
761
+ orch.onCreate = () => {
762
+ store.put({ ...store.get(provisioned.id)!, status: 'error', error: 'gave up' });
763
+ };
764
+
765
+ const result = await svc.respawn(provisioned.id);
766
+
767
+ expect(result).toEqual({ outcome: 'abandoned', status: 'error' });
768
+ expect(orch.stopped).toContain('dock_2');
769
+ expect(orch.removed).toEqual([]);
770
+ const stored = store.get(provisioned.id)!;
771
+ expect(stored.status).toBe('error');
772
+ expect(stored.dockerId).toBe('dock_1');
773
+ });
774
+
775
+ it('abandons with no status when the record vanishes mid-spawn', async () => {
776
+ const orch = new FakeOrchestrator();
777
+ const store = new DonationStore(join(tmpRoot, 'donations'));
778
+ const { grants, token } = makeGrants();
779
+ const svc = new DonationService({ orchestrator: orch, grants, store });
780
+
781
+ const provisioned = await svc.provision(baseRequest(token));
782
+
783
+ orch.createDelayMs = 20;
784
+ orch.onCreate = () => { store.remove(provisioned.id); };
785
+
786
+ const result = await svc.respawn(provisioned.id);
787
+
788
+ // No row to protect, so nothing is written back and the child is fully
789
+ // reclaimed rather than left holding ports and a workdir nothing names.
790
+ expect(result).toEqual({ outcome: 'abandoned' });
791
+ expect(store.get(provisioned.id)).toBeUndefined();
792
+ expect(orch.stopped).toContain('dock_2');
793
+ expect(orch.removed).toContain('dock_2');
794
+ });
795
+ });
796
+
797
+ describe('DonationService.terminate', () => {
798
+ it('marks the record terminated BEFORE stopping the child', async () => {
799
+ const orch = new FakeOrchestrator();
800
+ const store = new DonationStore(join(tmpRoot, 'donations'));
801
+ const { grants, token } = makeGrants();
802
+ const svc = new DonationService({ orchestrator: orch, grants, store });
803
+
804
+ const provisioned = await svc.provision(baseRequest(token));
805
+ // The stop fires the orchestrator's state-change; a respawn supervisor
806
+ // reacting to it must already see a terminal record, or it resurrects a
807
+ // loan the borrower just ended.
808
+ let statusAtStop: string | undefined;
809
+ orch.onStop = () => { statusAtStop = store.get(provisioned.id)?.status; };
810
+
811
+ await svc.terminate(provisioned.id);
812
+
813
+ expect(statusAtStop).toBe('terminated');
814
+ expect(orch.stopped).toEqual(['dock_1']);
815
+ expect(orch.removed).toEqual(['dock_1']);
816
+ // `removeContainer` already deleted the workdir — reclaiming by name on top
817
+ // of that would be redundant, and would race the next spawn of the same id.
818
+ expect(orch.reclaimedWorkdirs).toEqual([]);
819
+ expect(store.liveNodeCount(token)).toBe(0);
820
+ });
821
+
822
+ it('reclaims the workdir by name for a record that never got a dockerId', async () => {
823
+ const orch = new FakeOrchestrator();
824
+ const store = new DonationStore(join(tmpRoot, 'donations'));
825
+ const { grants, token } = makeGrants();
826
+ const svc = new DonationService({ orchestrator: orch, grants, store });
827
+
828
+ // A record the host died mid-provision on: `ensureNodeIdentity` created
829
+ // `<rootDir>/grn_stuck` before anything produced a dockerId. Without this
830
+ // branch, terminating it would move it to `terminated` — which the
831
+ // stuck-`provisioning` reap no longer matches — stranding the directory.
832
+ store.put({
833
+ id: 'grn_stuck',
834
+ grantToken: token,
835
+ partyId: 'party-P',
836
+ bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooReq'],
837
+ ownerKeys: [OWNER_KEY],
838
+ profile: 'storage',
839
+ status: 'provisioning',
840
+ createdAt: '2025-01-01T00:00:00.000Z',
841
+ updatedAt: '2025-01-01T00:00:00.000Z',
842
+ });
843
+
844
+ await svc.terminate('grn_stuck');
845
+
846
+ expect(store.get('grn_stuck')?.status).toBe('terminated');
847
+ expect(orch.reclaimedWorkdirs).toEqual(['grn_stuck']);
848
+ expect(orch.stopped).toEqual([]);
849
+ expect(orch.removed).toEqual([]);
850
+ });
851
+
852
+ it('swallows the second terminate of the same donation', async () => {
853
+ const orch = new FakeOrchestrator();
854
+ const store = new DonationStore(join(tmpRoot, 'donations'));
855
+ const { grants, token } = makeGrants();
856
+ const svc = new DonationService({ orchestrator: orch, grants, store });
857
+
858
+ const provisioned = await svc.provision(baseRequest(token));
859
+ await svc.terminate(provisioned.id);
860
+
861
+ // `terminate` does not gate on status, so the second pass re-aims its stop
862
+ // and reclaim at a handle the first pass already removed. The orchestrator
863
+ // rejects both; `safeStop` / `safeReclaim` log and swallow, so a duplicate
864
+ // DELETE is a no-op rather than a 500. Reaps and retries both land here.
865
+ await expect(svc.terminate(provisioned.id)).resolves.toBeUndefined();
866
+ expect(orch.stopped).toEqual(['dock_1']);
867
+ expect(orch.removed).toEqual(['dock_1']);
868
+ expect(store.get(provisioned.id)!.status).toBe('terminated');
869
+ });
870
+ });
871
+
250
872
  describe('DonationService.reapStaleAwaitingSeed', () => {
251
873
  it('terminates awaiting_seed donations past the TTL and leaves fresh ones alone', async () => {
252
874
  const orch = new FakeOrchestrator();
@@ -275,6 +897,36 @@ describe('DonationService.reapStaleAwaitingSeed', () => {
275
897
  expect(store.liveNodeCount(token)).toBe(1);
276
898
  });
277
899
 
900
+ it('leaves a loan alone when a seed lands mid-sweep, before its turn comes up', async () => {
901
+ const orch = new FakeOrchestrator();
902
+ const store = new DonationStore(join(tmpRoot, 'donations'));
903
+ const { grants, token } = makeGrants({ maxNodes: 5 });
904
+ let clock = new Date('2025-01-01T00:00:00.000Z');
905
+ const svc = new DonationService({ orchestrator: orch, grants, store, now: () => clock });
906
+
907
+ const first = await svc.provision(baseRequest(token));
908
+ const second = await svc.provision(baseRequest(token));
909
+ clock = new Date(clock.getTime() + DONATION_AWAITING_SEED_TTL_MS + 60_000);
910
+
911
+ // The borrower seeds the second loan while the sweep is still awaiting the
912
+ // FIRST record's stop — the very write `applySeed` makes once its node
913
+ // answers. The sweep's candidate list predates it, so acting on that stale
914
+ // copy would terminate a loan that just came good.
915
+ orch.onStop = (dockerId) => {
916
+ if (dockerId !== 'dock_1') return;
917
+ store.put({ ...store.get(second.id)!, status: 'seeded', updatedAt: clock.toISOString() });
918
+ };
919
+
920
+ await expect(svc.reapStaleAwaitingSeed()).resolves.toEqual([first.id]);
921
+
922
+ expect(store.get(first.id)?.status).toBe('terminated');
923
+ expect(store.get(second.id)?.status).toBe('seeded');
924
+ expect(orch.stopped).toEqual(['dock_1']);
925
+ expect(orch.removed).toEqual(['dock_1']);
926
+ // The seeded loan still holds its quota slot; the reaped one does not.
927
+ expect(store.liveNodeCount(token)).toBe(1);
928
+ });
929
+
278
930
  it('is a no-op when nothing is stale', async () => {
279
931
  const orch = new FakeOrchestrator();
280
932
  const { grants, token } = makeGrants();
@@ -288,3 +940,129 @@ describe('DonationService.reapStaleAwaitingSeed', () => {
288
940
  expect(orch.removed).toEqual([]);
289
941
  });
290
942
  });
943
+
944
+ describe('DonationService.reapStaleProvisioning', () => {
945
+ /**
946
+ * A donation whose host died right after writing the `provisioning` row —
947
+ * exactly what `provisionLocked` writes before it ever calls the
948
+ * orchestrator (see `donation-service.ts`). No `dockerId`: nothing beyond
949
+ * this raw write ever happened for the record itself.
950
+ */
951
+ const stuckRecord = (opts: { id: string; token: string; at: string }): Donation => ({
952
+ id: opts.id,
953
+ grantToken: opts.token,
954
+ partyId: 'party-P',
955
+ bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooReq'],
956
+ ownerKeys: [OWNER_KEY],
957
+ profile: 'storage',
958
+ status: 'provisioning',
959
+ createdAt: opts.at,
960
+ updatedAt: opts.at,
961
+ });
962
+
963
+ it('reaps a donation stuck in provisioning past the TTL and marks it error', async () => {
964
+ const orch = new FakeOrchestrator();
965
+ const store = new DonationStore(join(tmpRoot, 'donations'));
966
+ const { grants, token } = makeGrants({ maxNodes: 5 });
967
+ let clock = new Date('2025-01-01T00:00:00.000Z');
968
+ const svc = new DonationService({ orchestrator: orch, grants, store, now: () => clock });
969
+
970
+ store.put(stuckRecord({ id: 'grn_stuck', token, at: clock.toISOString() }));
971
+ clock = new Date(clock.getTime() + DONATION_PROVISIONING_TTL_MS + 60_000);
972
+
973
+ const reaped = await svc.reapStaleProvisioning(DONATION_PROVISIONING_TTL_MS);
974
+
975
+ expect(reaped).toEqual(['grn_stuck']);
976
+ expect(store.get('grn_stuck')?.status).toBe('error');
977
+ expect(store.get('grn_stuck')?.error).toMatch(/provisioning/);
978
+ // Nothing was ever spawned — no dockerId to resolve, so nothing to stop or
979
+ // remove by handle. The workdir the dead spawn created is still reachable
980
+ // by container name, though, and this is the only path that will ever
981
+ // remove it.
982
+ expect(orch.stopped).toEqual([]);
983
+ expect(orch.removed).toEqual([]);
984
+ expect(orch.reclaimedWorkdirs).toEqual(['grn_stuck']);
985
+ expect(store.liveNodeCount(token)).toBe(0);
986
+ });
987
+
988
+ it('stops and reclaims the child when the orchestrator can resolve its dockerId', async () => {
989
+ const orch = new FakeOrchestrator();
990
+ const store = new DonationStore(join(tmpRoot, 'donations'));
991
+ const { grants, token } = makeGrants({ maxNodes: 5 });
992
+ let clock = new Date('2025-01-01T00:00:00.000Z');
993
+ const svc = new DonationService({ orchestrator: orch, grants, store, now: () => clock });
994
+
995
+ // Simulates a crash between the host spawning the child (the orchestrator
996
+ // already knows it by dockerId) and `provisionLocked` writing that dockerId
997
+ // onto the donation record — the resource-leak wrinkle from the ticket.
998
+ const spawn = await orch.createContainer({
999
+ containerId: 'grn_stuck',
1000
+ partyId: 'party-P',
1001
+ bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooReq'],
1002
+ profile: 'storage',
1003
+ pinnedOwnerKeys: [OWNER_KEY],
1004
+ });
1005
+ store.put(stuckRecord({ id: 'grn_stuck', token, at: clock.toISOString() }));
1006
+ clock = new Date(clock.getTime() + DONATION_PROVISIONING_TTL_MS + 60_000);
1007
+
1008
+ const reaped = await svc.reapStaleProvisioning(DONATION_PROVISIONING_TTL_MS);
1009
+
1010
+ expect(reaped).toEqual(['grn_stuck']);
1011
+ expect(store.get('grn_stuck')?.status).toBe('error');
1012
+ expect(orch.stopped).toEqual([spawn.dockerId]);
1013
+ expect(orch.removed).toEqual([spawn.dockerId]);
1014
+ // `removeContainer` deletes that child's workdir itself; reclaiming by name
1015
+ // on top of it would be redundant.
1016
+ expect(orch.reclaimedWorkdirs).toEqual([]);
1017
+ expect(store.liveNodeCount(token)).toBe(0);
1018
+ });
1019
+
1020
+ it('leaves a fresh provisioning record within the TTL alone', async () => {
1021
+ const orch = new FakeOrchestrator();
1022
+ const store = new DonationStore(join(tmpRoot, 'donations'));
1023
+ const { grants, token } = makeGrants({ maxNodes: 5 });
1024
+ const clock = new Date('2025-01-01T00:00:00.000Z');
1025
+ const svc = new DonationService({ orchestrator: orch, grants, store, now: () => clock });
1026
+
1027
+ store.put(stuckRecord({ id: 'grn_fresh', token, at: clock.toISOString() }));
1028
+
1029
+ expect(await svc.reapStaleProvisioning(DONATION_PROVISIONING_TTL_MS)).toEqual([]);
1030
+ expect(store.get('grn_fresh')?.status).toBe('provisioning');
1031
+ expect(orch.stopped).toEqual([]);
1032
+ });
1033
+
1034
+ it('leaves a record alone that legitimately advances between the sweep snapshot and its per-record re-read', async () => {
1035
+ const orch = new FakeOrchestrator();
1036
+ const store = new DonationStore(join(tmpRoot, 'donations'));
1037
+ const { grants, token } = makeGrants({ maxNodes: 5 });
1038
+ let clock = new Date('2025-01-01T00:00:00.000Z');
1039
+ const svc = new DonationService({ orchestrator: orch, grants, store, now: () => clock });
1040
+
1041
+ const firstSpawn = await orch.createContainer({
1042
+ containerId: 'grn_first',
1043
+ partyId: 'party-P',
1044
+ bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooReq'],
1045
+ profile: 'storage',
1046
+ pinnedOwnerKeys: [OWNER_KEY],
1047
+ });
1048
+ store.put(stuckRecord({ id: 'grn_first', token, at: clock.toISOString() }));
1049
+ store.put(stuckRecord({ id: 'grn_second', token, at: clock.toISOString() }));
1050
+ clock = new Date(clock.getTime() + DONATION_PROVISIONING_TTL_MS + 60_000);
1051
+
1052
+ // The second record's own in-flight `provisionLocked` call finally lands
1053
+ // while the sweep is still awaiting the FIRST record's stop — the sweep's
1054
+ // candidate list predates that write, so acting on the stale copy would
1055
+ // wrongly terminate a provision that just came good.
1056
+ orch.onStop = (dockerId) => {
1057
+ if (dockerId !== firstSpawn.dockerId) return;
1058
+ store.put({ ...store.get('grn_second')!, status: 'awaiting_seed', updatedAt: clock.toISOString() });
1059
+ };
1060
+
1061
+ const reaped = await svc.reapStaleProvisioning(DONATION_PROVISIONING_TTL_MS);
1062
+
1063
+ expect(reaped).toEqual(['grn_first']);
1064
+ expect(store.get('grn_first')?.status).toBe('error');
1065
+ expect(store.get('grn_second')?.status).toBe('awaiting_seed');
1066
+ expect(store.liveNodeCount(token)).toBe(1);
1067
+ });
1068
+ });