hungry-ghost-hive 0.45.0 → 0.46.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/cli/commands/cluster.d.ts.map +1 -1
  2. package/dist/cli/commands/cluster.js +348 -1
  3. package/dist/cli/commands/cluster.js.map +1 -1
  4. package/dist/cli/commands/cluster.test.js +313 -9
  5. package/dist/cli/commands/cluster.test.js.map +1 -1
  6. package/dist/cli/commands/req-spawn.test.d.ts +2 -0
  7. package/dist/cli/commands/req-spawn.test.d.ts.map +1 -0
  8. package/dist/cli/commands/req-spawn.test.js +116 -0
  9. package/dist/cli/commands/req-spawn.test.js.map +1 -0
  10. package/dist/cli/commands/req.d.ts.map +1 -1
  11. package/dist/cli/commands/req.js +21 -13
  12. package/dist/cli/commands/req.js.map +1 -1
  13. package/dist/cluster/cluster-http-server.d.ts +32 -0
  14. package/dist/cluster/cluster-http-server.d.ts.map +1 -1
  15. package/dist/cluster/cluster-http-server.js +42 -0
  16. package/dist/cluster/cluster-http-server.js.map +1 -1
  17. package/dist/cluster/distributed-runtime-coverage.test.js +9 -0
  18. package/dist/cluster/distributed-runtime-coverage.test.js.map +1 -1
  19. package/dist/cluster/distributed-system.test.js +135 -0
  20. package/dist/cluster/distributed-system.test.js.map +1 -1
  21. package/dist/cluster/events.d.ts +23 -0
  22. package/dist/cluster/events.d.ts.map +1 -1
  23. package/dist/cluster/events.js +74 -0
  24. package/dist/cluster/events.js.map +1 -1
  25. package/dist/cluster/heartbeat-manager.d.ts +2 -0
  26. package/dist/cluster/heartbeat-manager.d.ts.map +1 -1
  27. package/dist/cluster/heartbeat-manager.js +42 -6
  28. package/dist/cluster/heartbeat-manager.js.map +1 -1
  29. package/dist/cluster/membership.test.d.ts +2 -0
  30. package/dist/cluster/membership.test.d.ts.map +1 -0
  31. package/dist/cluster/membership.test.js +416 -0
  32. package/dist/cluster/membership.test.js.map +1 -0
  33. package/dist/cluster/partition-safety.test.d.ts +2 -0
  34. package/dist/cluster/partition-safety.test.d.ts.map +1 -0
  35. package/dist/cluster/partition-safety.test.js +440 -0
  36. package/dist/cluster/partition-safety.test.js.map +1 -0
  37. package/dist/cluster/raft-state-machine.d.ts +33 -1
  38. package/dist/cluster/raft-state-machine.d.ts.map +1 -1
  39. package/dist/cluster/raft-state-machine.js +65 -3
  40. package/dist/cluster/raft-state-machine.js.map +1 -1
  41. package/dist/cluster/raft-store.d.ts +26 -1
  42. package/dist/cluster/raft-store.d.ts.map +1 -1
  43. package/dist/cluster/raft-store.js +137 -0
  44. package/dist/cluster/raft-store.js.map +1 -1
  45. package/dist/cluster/replication-lag.test.d.ts +2 -0
  46. package/dist/cluster/replication-lag.test.d.ts.map +1 -0
  47. package/dist/cluster/replication-lag.test.js +239 -0
  48. package/dist/cluster/replication-lag.test.js.map +1 -0
  49. package/dist/cluster/replication.d.ts +2 -2
  50. package/dist/cluster/replication.d.ts.map +1 -1
  51. package/dist/cluster/replication.js +1 -1
  52. package/dist/cluster/replication.js.map +1 -1
  53. package/dist/cluster/runtime.d.ts +78 -0
  54. package/dist/cluster/runtime.d.ts.map +1 -1
  55. package/dist/cluster/runtime.js +400 -13
  56. package/dist/cluster/runtime.js.map +1 -1
  57. package/dist/cluster/state-recovery.test.d.ts +2 -0
  58. package/dist/cluster/state-recovery.test.d.ts.map +1 -0
  59. package/dist/cluster/state-recovery.test.js +310 -0
  60. package/dist/cluster/state-recovery.test.js.map +1 -0
  61. package/dist/cluster/types.d.ts +30 -0
  62. package/dist/cluster/types.d.ts.map +1 -1
  63. package/dist/config/schema.d.ts +48 -0
  64. package/dist/config/schema.d.ts.map +1 -1
  65. package/dist/config/schema.js +11 -0
  66. package/dist/config/schema.js.map +1 -1
  67. package/dist/context-files/generator.js +1 -1
  68. package/dist/context-files/generator.js.map +1 -1
  69. package/dist/context-files/generator.test.js +51 -0
  70. package/dist/context-files/generator.test.js.map +1 -1
  71. package/dist/orchestrator/orphan-recovery.d.ts +1 -1
  72. package/dist/orchestrator/orphan-recovery.d.ts.map +1 -1
  73. package/dist/orchestrator/orphan-recovery.js +4 -4
  74. package/dist/orchestrator/orphan-recovery.js.map +1 -1
  75. package/dist/orchestrator/prompt-templates.d.ts +3 -1
  76. package/dist/orchestrator/prompt-templates.d.ts.map +1 -1
  77. package/dist/orchestrator/prompt-templates.js +45 -8
  78. package/dist/orchestrator/prompt-templates.js.map +1 -1
  79. package/dist/orchestrator/prompt-templates.test.js +210 -0
  80. package/dist/orchestrator/prompt-templates.test.js.map +1 -1
  81. package/dist/orchestrator/scheduler.d.ts +1 -0
  82. package/dist/orchestrator/scheduler.d.ts.map +1 -1
  83. package/dist/orchestrator/scheduler.js +15 -10
  84. package/dist/orchestrator/scheduler.js.map +1 -1
  85. package/dist/orchestrator/scheduler.test.js +97 -6
  86. package/dist/orchestrator/scheduler.test.js.map +1 -1
  87. package/package.json +1 -1
  88. package/src/cli/commands/cluster.test.ts +387 -9
  89. package/src/cli/commands/cluster.ts +486 -1
  90. package/src/cli/commands/req-spawn.test.ts +153 -0
  91. package/src/cli/commands/req.ts +31 -18
  92. package/src/cluster/cluster-http-server.ts +80 -0
  93. package/src/cluster/distributed-runtime-coverage.test.ts +9 -0
  94. package/src/cluster/distributed-system.test.ts +168 -0
  95. package/src/cluster/events.ts +90 -0
  96. package/src/cluster/heartbeat-manager.ts +48 -6
  97. package/src/cluster/membership.test.ts +498 -0
  98. package/src/cluster/partition-safety.test.ts +523 -0
  99. package/src/cluster/raft-state-machine.ts +76 -4
  100. package/src/cluster/raft-store.ts +167 -1
  101. package/src/cluster/replication-lag.test.ts +284 -0
  102. package/src/cluster/replication.ts +6 -0
  103. package/src/cluster/runtime.ts +551 -12
  104. package/src/cluster/state-recovery.test.ts +420 -0
  105. package/src/cluster/types.ts +32 -0
  106. package/src/config/schema.ts +11 -0
  107. package/src/context-files/generator.test.ts +55 -0
  108. package/src/context-files/generator.ts +5 -5
  109. package/src/orchestrator/orphan-recovery.ts +32 -13
  110. package/src/orchestrator/prompt-templates.test.ts +263 -0
  111. package/src/orchestrator/prompt-templates.ts +49 -8
  112. package/src/orchestrator/scheduler.test.ts +129 -6
  113. package/src/orchestrator/scheduler.ts +46 -20
@@ -0,0 +1,498 @@
1
+ // Licensed under the Hungry Ghost Hive License. See LICENSE.
2
+
3
+ import { mkdirSync, mkdtempSync, rmSync } from 'fs';
4
+ import { createServer as createNetServer } from 'net';
5
+ import { tmpdir } from 'os';
6
+ import { join } from 'path';
7
+ import { afterEach, describe, expect, it } from 'vitest';
8
+ import type { ClusterConfig } from '../config/schema.js';
9
+ import { ClusterRuntime } from './runtime.js';
10
+
11
+ interface RuntimeFixture {
12
+ root: string;
13
+ hiveDir: string;
14
+ config: ClusterConfig;
15
+ runtime: ClusterRuntime;
16
+ }
17
+
18
+ const tempRoots: string[] = [];
19
+ const activeRuntimes: ClusterRuntime[] = [];
20
+
21
+ afterEach(async () => {
22
+ for (const runtime of activeRuntimes.splice(0)) {
23
+ try {
24
+ await runtime.stop();
25
+ } catch {
26
+ // Best effort shutdown for test cleanup.
27
+ }
28
+ }
29
+
30
+ for (const root of tempRoots.splice(0)) {
31
+ rmSync(root, { recursive: true, force: true });
32
+ }
33
+ });
34
+
35
+ describe('dynamic membership join', () => {
36
+ it('leader accepts join request and adds peer to cluster', async () => {
37
+ if (!(await canListenOnLocalhost())) return;
38
+
39
+ const fixture = await startRuntimeFixture({
40
+ node_id: 'leader-join',
41
+ election_timeout_min_ms: 80,
42
+ election_timeout_max_ms: 120,
43
+ heartbeat_interval_ms: 60,
44
+ });
45
+
46
+ await waitFor(() => fixture.runtime.getStatus().is_leader, 4000);
47
+
48
+ const res = await postJson(fixture.config.public_url, '/cluster/v1/membership/join', {
49
+ node_id: 'new-node',
50
+ url: 'http://127.0.0.1:9999',
51
+ });
52
+
53
+ expect(res.success).toBe(true);
54
+ expect(res.leader_id).toBe('leader-join');
55
+ expect(res.peers).toContainEqual({ id: 'new-node', url: 'http://127.0.0.1:9999' });
56
+
57
+ const status = fixture.runtime.getStatus();
58
+ expect(status.peers).toContainEqual({ id: 'new-node', url: 'http://127.0.0.1:9999' });
59
+ });
60
+
61
+ it('follower redirects join request to leader', async () => {
62
+ if (!(await canListenOnLocalhost())) return;
63
+
64
+ const fixture = await startRuntimeFixture({
65
+ node_id: 'follower-join',
66
+ election_timeout_min_ms: 5000,
67
+ election_timeout_max_ms: 5000,
68
+ peers: [{ id: 'remote-leader', url: 'http://127.0.0.1:9998' }],
69
+ });
70
+
71
+ // Set the node as follower with a known leader
72
+ await postJson(fixture.config.public_url, '/cluster/v1/election/heartbeat', {
73
+ term: 3,
74
+ leader_id: 'remote-leader',
75
+ fencing_token: 3,
76
+ });
77
+
78
+ const res = await fetch(`${fixture.config.public_url}/cluster/v1/membership/join`, {
79
+ method: 'POST',
80
+ headers: { 'Content-Type': 'application/json' },
81
+ body: JSON.stringify({ node_id: 'joiner', url: 'http://127.0.0.1:9997' }),
82
+ });
83
+
84
+ expect(res.status).toBe(307);
85
+ const body = (await res.json()) as Record<string, unknown>;
86
+ expect(body.success).toBe(false);
87
+ expect(body.leader_id).toBe('remote-leader');
88
+ expect(body.leader_url).toBe('http://127.0.0.1:9998');
89
+ });
90
+
91
+ it('rejects join request with missing fields', async () => {
92
+ if (!(await canListenOnLocalhost())) return;
93
+
94
+ const fixture = await startRuntimeFixture({ node_id: 'leader-join-bad' });
95
+
96
+ const res = await fetch(`${fixture.config.public_url}/cluster/v1/membership/join`, {
97
+ method: 'POST',
98
+ headers: { 'Content-Type': 'application/json' },
99
+ body: JSON.stringify({ node_id: 'missing-url' }),
100
+ });
101
+
102
+ expect(res.status).toBe(400);
103
+ });
104
+
105
+ it('updates url for existing peer on re-join', async () => {
106
+ if (!(await canListenOnLocalhost())) return;
107
+
108
+ const fixture = await startRuntimeFixture({
109
+ node_id: 'leader-rejoin',
110
+ election_timeout_min_ms: 80,
111
+ election_timeout_max_ms: 120,
112
+ heartbeat_interval_ms: 60,
113
+ });
114
+
115
+ await waitFor(() => fixture.runtime.getStatus().is_leader, 4000);
116
+
117
+ // First add the peer
118
+ await postJson(fixture.config.public_url, '/cluster/v1/membership/join', {
119
+ node_id: 'existing-peer',
120
+ url: 'http://127.0.0.1:8000',
121
+ });
122
+
123
+ // Re-join with different URL
124
+ const res = await postJson(fixture.config.public_url, '/cluster/v1/membership/join', {
125
+ node_id: 'existing-peer',
126
+ url: 'http://127.0.0.1:9000',
127
+ });
128
+
129
+ expect(res.success).toBe(true);
130
+ expect(res.peers).toContainEqual({ id: 'existing-peer', url: 'http://127.0.0.1:9000' });
131
+ });
132
+
133
+ it('idempotent join with same url returns success', async () => {
134
+ if (!(await canListenOnLocalhost())) return;
135
+
136
+ const fixture = await startRuntimeFixture({
137
+ node_id: 'leader-idem',
138
+ election_timeout_min_ms: 80,
139
+ election_timeout_max_ms: 120,
140
+ heartbeat_interval_ms: 60,
141
+ });
142
+
143
+ await waitFor(() => fixture.runtime.getStatus().is_leader, 4000);
144
+
145
+ // Add peer first
146
+ await postJson(fixture.config.public_url, '/cluster/v1/membership/join', {
147
+ node_id: 'peer-x',
148
+ url: 'http://127.0.0.1:7777',
149
+ });
150
+
151
+ // Join again with same details — idempotent
152
+ const res = await postJson(fixture.config.public_url, '/cluster/v1/membership/join', {
153
+ node_id: 'peer-x',
154
+ url: 'http://127.0.0.1:7777',
155
+ });
156
+
157
+ expect(res.success).toBe(true);
158
+ });
159
+ });
160
+
161
+ describe('dynamic membership leave', () => {
162
+ it('leader removes peer on leave request', async () => {
163
+ if (!(await canListenOnLocalhost())) return;
164
+
165
+ const fixture = await startRuntimeFixture({
166
+ node_id: 'leader-leave',
167
+ election_timeout_min_ms: 80,
168
+ election_timeout_max_ms: 120,
169
+ heartbeat_interval_ms: 60,
170
+ });
171
+
172
+ await waitFor(() => fixture.runtime.getStatus().is_leader, 4000);
173
+
174
+ // Add peer first, then remove it
175
+ await postJson(fixture.config.public_url, '/cluster/v1/membership/join', {
176
+ node_id: 'departing-node',
177
+ url: 'http://127.0.0.1:8888',
178
+ });
179
+
180
+ const res = await postJson(fixture.config.public_url, '/cluster/v1/membership/leave', {
181
+ node_id: 'departing-node',
182
+ });
183
+
184
+ expect(res.success).toBe(true);
185
+ expect(res.peers).not.toContainEqual(expect.objectContaining({ id: 'departing-node' }));
186
+
187
+ const status = fixture.runtime.getStatus();
188
+ expect(status.peers.find(p => p.id === 'departing-node')).toBeUndefined();
189
+ });
190
+
191
+ it('follower rejects leave request', async () => {
192
+ if (!(await canListenOnLocalhost())) return;
193
+
194
+ const fixture = await startRuntimeFixture({
195
+ node_id: 'follower-leave',
196
+ election_timeout_min_ms: 5000,
197
+ election_timeout_max_ms: 5000,
198
+ });
199
+
200
+ const res = await fetch(`${fixture.config.public_url}/cluster/v1/membership/leave`, {
201
+ method: 'POST',
202
+ headers: { 'Content-Type': 'application/json' },
203
+ body: JSON.stringify({ node_id: 'some-node' }),
204
+ });
205
+
206
+ expect(res.status).toBe(400);
207
+ const body = (await res.json()) as Record<string, unknown>;
208
+ expect(body.success).toBe(false);
209
+ });
210
+
211
+ it('leader cannot remove itself', async () => {
212
+ if (!(await canListenOnLocalhost())) return;
213
+
214
+ const fixture = await startRuntimeFixture({
215
+ node_id: 'leader-self-leave',
216
+ election_timeout_min_ms: 80,
217
+ election_timeout_max_ms: 120,
218
+ heartbeat_interval_ms: 60,
219
+ });
220
+
221
+ await waitFor(() => fixture.runtime.getStatus().is_leader, 4000);
222
+
223
+ const res = await fetch(`${fixture.config.public_url}/cluster/v1/membership/leave`, {
224
+ method: 'POST',
225
+ headers: { 'Content-Type': 'application/json' },
226
+ body: JSON.stringify({ node_id: 'leader-self-leave' }),
227
+ });
228
+
229
+ expect(res.status).toBe(400);
230
+ });
231
+
232
+ it('leave for unknown node is a no-op success', async () => {
233
+ if (!(await canListenOnLocalhost())) return;
234
+
235
+ const fixture = await startRuntimeFixture({
236
+ node_id: 'leader-unknown-leave',
237
+ election_timeout_min_ms: 80,
238
+ election_timeout_max_ms: 120,
239
+ heartbeat_interval_ms: 60,
240
+ });
241
+
242
+ await waitFor(() => fixture.runtime.getStatus().is_leader, 4000);
243
+
244
+ const res = await postJson(fixture.config.public_url, '/cluster/v1/membership/leave', {
245
+ node_id: 'ghost-node',
246
+ });
247
+
248
+ expect(res.success).toBe(true);
249
+ });
250
+
251
+ it('rejects leave request with missing node_id', async () => {
252
+ if (!(await canListenOnLocalhost())) return;
253
+
254
+ const fixture = await startRuntimeFixture({ node_id: 'leader-leave-bad' });
255
+
256
+ const res = await fetch(`${fixture.config.public_url}/cluster/v1/membership/leave`, {
257
+ method: 'POST',
258
+ headers: { 'Content-Type': 'application/json' },
259
+ body: JSON.stringify({}),
260
+ });
261
+
262
+ expect(res.status).toBe(400);
263
+ });
264
+ });
265
+
266
+ describe('peer list propagation via heartbeat', () => {
267
+ it('leader propagates updated peer list to followers', async () => {
268
+ if (!(await canListenOnLocalhost())) return;
269
+
270
+ const portLeader = await getFreePort();
271
+ const portFollower = await getFreePort();
272
+
273
+ const leaderConfig = await buildConfig({
274
+ node_id: 'leader-prop',
275
+ listen_port: portLeader,
276
+ public_url: `http://127.0.0.1:${portLeader}`,
277
+ peers: [{ id: 'follower-prop', url: `http://127.0.0.1:${portFollower}` }],
278
+ election_timeout_min_ms: 80,
279
+ election_timeout_max_ms: 120,
280
+ heartbeat_interval_ms: 60,
281
+ });
282
+ const followerConfig = await buildConfig({
283
+ node_id: 'follower-prop',
284
+ listen_port: portFollower,
285
+ public_url: `http://127.0.0.1:${portFollower}`,
286
+ peers: [{ id: 'leader-prop', url: `http://127.0.0.1:${portLeader}` }],
287
+ election_timeout_min_ms: 5000,
288
+ election_timeout_max_ms: 5000,
289
+ });
290
+
291
+ const leaderFixture = await startRuntimeWithConfig(leaderConfig);
292
+ const followerFixture = await startRuntimeWithConfig(followerConfig);
293
+
294
+ // Wait for leader election
295
+ await waitFor(() => leaderFixture.runtime.getStatus().is_leader, 4000);
296
+
297
+ // Add a new peer via the leader
298
+ await postJson(leaderFixture.config.public_url, '/cluster/v1/membership/join', {
299
+ node_id: 'new-node-prop',
300
+ url: 'http://127.0.0.1:7777',
301
+ });
302
+
303
+ // Wait for heartbeat to propagate peer list to follower
304
+ await waitFor(() => {
305
+ const peers = followerFixture.runtime.getStatus().peers;
306
+ return peers.some(p => p.id === 'new-node-prop');
307
+ }, 4000);
308
+
309
+ const followerPeers = followerFixture.runtime.getStatus().peers;
310
+ expect(followerPeers).toContainEqual({ id: 'new-node-prop', url: 'http://127.0.0.1:7777' });
311
+ });
312
+
313
+ it('follower applies peer list from heartbeat', async () => {
314
+ if (!(await canListenOnLocalhost())) return;
315
+
316
+ const fixture = await startRuntimeFixture({
317
+ node_id: 'follower-apply',
318
+ election_timeout_min_ms: 5000,
319
+ election_timeout_max_ms: 5000,
320
+ });
321
+
322
+ // Send heartbeat with peer list
323
+ await postJson(fixture.config.public_url, '/cluster/v1/election/heartbeat', {
324
+ term: 5,
325
+ leader_id: 'external-leader',
326
+ fencing_token: 5,
327
+ peers: [
328
+ { id: 'external-leader', url: 'http://127.0.0.1:6000' },
329
+ { id: 'follower-apply', url: fixture.config.public_url },
330
+ { id: 'peer-z', url: 'http://127.0.0.1:6001' },
331
+ ],
332
+ });
333
+
334
+ const status = fixture.runtime.getStatus();
335
+ expect(status.peers).toHaveLength(3);
336
+ expect(status.peers).toContainEqual({ id: 'peer-z', url: 'http://127.0.0.1:6001' });
337
+ });
338
+ });
339
+
340
+ describe('quorum recalculation after membership change', () => {
341
+ it('quorum adjusts after adding a peer', async () => {
342
+ if (!(await canListenOnLocalhost())) return;
343
+
344
+ // Start as a single node (quorum = 1)
345
+ const fixture = await startRuntimeFixture({
346
+ node_id: 'quorum-node',
347
+ election_timeout_min_ms: 80,
348
+ election_timeout_max_ms: 120,
349
+ heartbeat_interval_ms: 60,
350
+ });
351
+
352
+ await waitFor(() => fixture.runtime.getStatus().is_leader, 4000);
353
+
354
+ // Single node: quorum = 1
355
+ // Add two peers: 3 nodes total, quorum = 2
356
+ await postJson(fixture.config.public_url, '/cluster/v1/membership/join', {
357
+ node_id: 'peer-1',
358
+ url: 'http://127.0.0.1:9001',
359
+ });
360
+ await postJson(fixture.config.public_url, '/cluster/v1/membership/join', {
361
+ node_id: 'peer-2',
362
+ url: 'http://127.0.0.1:9002',
363
+ });
364
+
365
+ const status = fixture.runtime.getStatus();
366
+ expect(status.peers).toHaveLength(2);
367
+ // The node should still be functional with updated peer list
368
+ expect(status.is_leader).toBe(true);
369
+ });
370
+ });
371
+
372
+ // --- Test helpers ---
373
+
374
+ async function startRuntimeFixture(
375
+ overrides: Partial<ClusterConfig> = {}
376
+ ): Promise<RuntimeFixture> {
377
+ const attempts = overrides.listen_port ? 1 : 5;
378
+ let lastError: unknown;
379
+
380
+ for (let i = 0; i < attempts; i++) {
381
+ const config = await buildConfig(overrides);
382
+ try {
383
+ return await startRuntimeWithConfig(config);
384
+ } catch (error) {
385
+ lastError = error;
386
+ const err = error as NodeJS.ErrnoException;
387
+ if (!overrides.listen_port && err.code === 'EADDRINUSE') {
388
+ continue;
389
+ }
390
+ throw error;
391
+ }
392
+ }
393
+
394
+ throw lastError instanceof Error ? lastError : new Error('Failed to start runtime fixture');
395
+ }
396
+
397
+ async function startRuntimeWithConfig(config: ClusterConfig): Promise<RuntimeFixture> {
398
+ const root = mkdtempSync(join(tmpdir(), `hive-membership-${config.node_id}-`));
399
+ const hiveDir = join(root, '.hive');
400
+ mkdirSync(hiveDir, { recursive: true });
401
+
402
+ const runtime = new ClusterRuntime(config, { hiveDir });
403
+ try {
404
+ await runtime.start();
405
+ activeRuntimes.push(runtime);
406
+ tempRoots.push(root);
407
+
408
+ return { root, hiveDir, config, runtime };
409
+ } catch (error) {
410
+ try {
411
+ await runtime.stop();
412
+ } catch {
413
+ // Best effort cleanup for partial starts.
414
+ }
415
+ rmSync(root, { recursive: true, force: true });
416
+ throw error;
417
+ }
418
+ }
419
+
420
+ async function buildConfig(overrides: Partial<ClusterConfig> = {}): Promise<ClusterConfig> {
421
+ const port = overrides.listen_port ?? (await getFreePort());
422
+ const base: ClusterConfig = {
423
+ enabled: true,
424
+ node_id: 'node-test',
425
+ listen_host: '127.0.0.1',
426
+ listen_port: port,
427
+ public_url: `http://127.0.0.1:${port}`,
428
+ peers: [],
429
+ heartbeat_interval_ms: 100,
430
+ election_timeout_min_ms: 150,
431
+ election_timeout_max_ms: 250,
432
+ sync_interval_ms: 200,
433
+ request_timeout_ms: 600,
434
+ story_similarity_threshold: 0.8,
435
+ };
436
+
437
+ return {
438
+ ...base,
439
+ ...overrides,
440
+ public_url: overrides.public_url || base.public_url,
441
+ peers: overrides.peers || base.peers,
442
+ };
443
+ }
444
+
445
+ async function postJson(
446
+ baseUrl: string,
447
+ path: string,
448
+ body: Record<string, unknown>
449
+ ): Promise<Record<string, any>> {
450
+ const res = await fetch(`${baseUrl}${path}`, {
451
+ method: 'POST',
452
+ headers: { 'Content-Type': 'application/json' },
453
+ body: JSON.stringify(body),
454
+ });
455
+
456
+ return (await res.json()) as Record<string, any>;
457
+ }
458
+
459
+ async function waitFor(predicate: () => boolean, timeoutMs: number): Promise<void> {
460
+ const start = Date.now();
461
+ while (Date.now() - start < timeoutMs) {
462
+ if (predicate()) return;
463
+ await new Promise(resolve => setTimeout(resolve, 25));
464
+ }
465
+ throw new Error('Timed out waiting for condition');
466
+ }
467
+
468
+ async function getFreePort(): Promise<number> {
469
+ return new Promise((resolve, reject) => {
470
+ const server = createNetServer();
471
+ server.once('error', reject);
472
+ server.listen(0, '127.0.0.1', () => {
473
+ const address = server.address();
474
+ if (!address || typeof address === 'string') {
475
+ server.close(() => reject(new Error('Failed to allocate free port')));
476
+ return;
477
+ }
478
+
479
+ const port = address.port;
480
+ server.close(err => {
481
+ if (err) {
482
+ reject(err);
483
+ return;
484
+ }
485
+ resolve(port);
486
+ });
487
+ });
488
+ });
489
+ }
490
+
491
+ async function canListenOnLocalhost(): Promise<boolean> {
492
+ try {
493
+ await getFreePort();
494
+ return true;
495
+ } catch {
496
+ return false;
497
+ }
498
+ }