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
@@ -6,13 +6,20 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
6
6
  vi.mock('../../cluster/runtime.js', () => ({
7
7
  fetchClusterStatusFromUrl: vi.fn(),
8
8
  fetchLocalClusterStatus: vi.fn(),
9
+ fetchLocalClusterEvents: vi.fn(),
10
+ postToLocalCluster: vi.fn(),
11
+ postToPeerCluster: vi.fn(),
9
12
  }));
10
13
 
11
14
  vi.mock('../../config/loader.js', () => ({
12
15
  loadConfig: vi.fn(() => ({
13
16
  cluster: {
14
17
  enabled: false,
18
+ node_id: 'node-test',
19
+ public_url: 'http://127.0.0.1:8787',
15
20
  peers: [],
21
+ auth_token: undefined,
22
+ request_timeout_ms: 500,
16
23
  },
17
24
  })),
18
25
  }));
@@ -22,33 +29,404 @@ vi.mock('../../utils/paths.js', () => ({
22
29
  getHivePaths: vi.fn(() => ({ hiveDir: '/tmp/.hive' })),
23
30
  }));
24
31
 
32
+ import * as runtimeModule from '../../cluster/runtime.js';
33
+ import * as configModule from '../../config/loader.js';
25
34
  import { clusterCommand } from './cluster.js';
26
35
 
36
+ const mockFetchLocal = runtimeModule.fetchLocalClusterStatus as ReturnType<typeof vi.fn>;
37
+ const mockFetchFromUrl = runtimeModule.fetchClusterStatusFromUrl as ReturnType<typeof vi.fn>;
38
+ const mockFetchEvents = runtimeModule.fetchLocalClusterEvents as ReturnType<typeof vi.fn>;
39
+ const mockPostLocal = runtimeModule.postToLocalCluster as ReturnType<typeof vi.fn>;
40
+ const mockPostPeer = runtimeModule.postToPeerCluster as ReturnType<typeof vi.fn>;
41
+ const mockLoadConfig = configModule.loadConfig as ReturnType<typeof vi.fn>;
42
+
43
+ function makeClusterConfig(overrides: Record<string, unknown> = {}) {
44
+ return {
45
+ enabled: true,
46
+ node_id: 'node-test',
47
+ public_url: 'http://127.0.0.1:8787',
48
+ peers: [],
49
+ auth_token: undefined,
50
+ request_timeout_ms: 500,
51
+ ...overrides,
52
+ };
53
+ }
54
+
55
+ function makeStatus(overrides: Record<string, unknown> = {}) {
56
+ return {
57
+ enabled: true,
58
+ node_id: 'node-test',
59
+ role: 'leader',
60
+ term: 1,
61
+ voted_for: null,
62
+ is_leader: true,
63
+ leader_id: 'node-test',
64
+ leader_url: null,
65
+ fencing_token: 1,
66
+ leader_lease_valid: true,
67
+ leader_lease_duration_ms: 300,
68
+ raft_commit_index: 0,
69
+ raft_last_applied: 0,
70
+ raft_last_log_index: 0,
71
+ peers: [],
72
+ is_catching_up: false,
73
+ ...overrides,
74
+ };
75
+ }
76
+
27
77
  describe('cluster command', () => {
28
78
  beforeEach(() => {
29
79
  vi.clearAllMocks();
80
+ mockLoadConfig.mockReturnValue({ cluster: makeClusterConfig() });
30
81
  });
31
82
 
32
83
  describe('command structure', () => {
33
- it('should have cluster command with correct name', () => {
84
+ it('has cluster command with correct name', () => {
34
85
  expect(clusterCommand.name()).toBe('cluster');
35
86
  });
36
87
 
37
- it('should have description', () => {
88
+ it('has description', () => {
38
89
  expect(clusterCommand.description()).toContain('cluster');
39
90
  });
40
91
 
41
- it('should have status subcommand', () => {
42
- const statusCmd = clusterCommand.commands.find(cmd => cmd.name() === 'status');
43
- expect(statusCmd).toBeDefined();
92
+ it('has status subcommand', () => {
93
+ const cmd = clusterCommand.commands.find(c => c.name() === 'status');
94
+ expect(cmd).toBeDefined();
95
+ });
96
+
97
+ it('has health subcommand', () => {
98
+ const cmd = clusterCommand.commands.find(c => c.name() === 'health');
99
+ expect(cmd).toBeDefined();
100
+ });
101
+
102
+ it('has events subcommand', () => {
103
+ const cmd = clusterCommand.commands.find(c => c.name() === 'events');
104
+ expect(cmd).toBeDefined();
105
+ });
106
+
107
+ it('has join subcommand', () => {
108
+ const cmd = clusterCommand.commands.find(c => c.name() === 'join');
109
+ expect(cmd).toBeDefined();
110
+ });
111
+
112
+ it('has leave subcommand', () => {
113
+ const cmd = clusterCommand.commands.find(c => c.name() === 'leave');
114
+ expect(cmd).toBeDefined();
44
115
  });
45
116
  });
46
117
 
118
+ describe('--json option presence', () => {
119
+ for (const name of ['status', 'health', 'events', 'join', 'leave']) {
120
+ it(`${name} has --json option`, () => {
121
+ const cmd = clusterCommand.commands.find(c => c.name() === name);
122
+ const jsonOpt = cmd?.options.find(o => o.long === '--json');
123
+ expect(jsonOpt).toBeDefined();
124
+ });
125
+ }
126
+ });
127
+
47
128
  describe('status subcommand', () => {
48
- it('should have --json option', () => {
49
- const statusCmd = clusterCommand.commands.find(cmd => cmd.name() === 'status');
50
- const jsonOpt = statusCmd?.options.find(opt => opt.long === '--json');
51
- expect(jsonOpt).toBeDefined();
129
+ it('outputs disabled message in json when cluster disabled', async () => {
130
+ mockLoadConfig.mockReturnValue({ cluster: makeClusterConfig({ enabled: false }) });
131
+ const logs: string[] = [];
132
+ vi.spyOn(console, 'log').mockImplementation((...args) => logs.push(args.join(' ')));
133
+
134
+ await clusterCommand.parseAsync(['node', 'cluster', 'status', '--json']);
135
+
136
+ const parsed = JSON.parse(logs[0]);
137
+ expect(parsed.enabled).toBe(false);
138
+ });
139
+
140
+ it('includes local and peers in json output when enabled', async () => {
141
+ mockFetchLocal.mockResolvedValue(makeStatus());
142
+ mockFetchFromUrl.mockResolvedValue(
143
+ makeStatus({ node_id: 'node-b', role: 'follower', is_leader: false })
144
+ );
145
+ mockLoadConfig.mockReturnValue({
146
+ cluster: makeClusterConfig({ peers: [{ id: 'node-b', url: 'http://10.0.0.2:8787' }] }),
147
+ });
148
+
149
+ const logs: string[] = [];
150
+ vi.spyOn(console, 'log').mockImplementation((...args) => logs.push(args.join(' ')));
151
+
152
+ await clusterCommand.parseAsync(['node', 'cluster', 'status', '--json']);
153
+
154
+ const parsed = JSON.parse(logs[0]);
155
+ expect(parsed.enabled).toBe(true);
156
+ expect(parsed.local).toBeDefined();
157
+ expect(parsed.peers).toHaveLength(1);
158
+ });
159
+ });
160
+
161
+ describe('health subcommand', () => {
162
+ it('outputs disabled message in json when cluster disabled', async () => {
163
+ mockLoadConfig.mockReturnValue({ cluster: makeClusterConfig({ enabled: false }) });
164
+ const logs: string[] = [];
165
+ vi.spyOn(console, 'log').mockImplementation((...args) => logs.push(args.join(' ')));
166
+
167
+ await clusterCommand.parseAsync(['node', 'cluster', 'health', '--json']);
168
+
169
+ const parsed = JSON.parse(logs[0]);
170
+ expect(parsed.enabled).toBe(false);
171
+ });
172
+
173
+ it('reports latency and reachability for all nodes', async () => {
174
+ mockFetchLocal.mockResolvedValue(makeStatus());
175
+ mockFetchFromUrl.mockResolvedValue(
176
+ makeStatus({ node_id: 'node-b', role: 'follower', is_leader: false })
177
+ );
178
+ mockLoadConfig.mockReturnValue({
179
+ cluster: makeClusterConfig({ peers: [{ id: 'node-b', url: 'http://10.0.0.2:8787' }] }),
180
+ });
181
+
182
+ const logs: string[] = [];
183
+ vi.spyOn(console, 'log').mockImplementation((...args) => logs.push(args.join(' ')));
184
+
185
+ await clusterCommand.parseAsync(['node', 'cluster', 'health', '--json']);
186
+
187
+ const parsed = JSON.parse(logs[0]);
188
+ expect(parsed.total_nodes).toBe(2);
189
+ expect(parsed.reachable).toBe(2);
190
+ expect(parsed.nodes[0].reachable).toBe(true);
191
+ expect(typeof parsed.nodes[0].latencyMs).toBe('number');
192
+ });
193
+
194
+ it('marks unreachable nodes correctly', async () => {
195
+ mockFetchLocal.mockResolvedValue(makeStatus());
196
+ mockFetchFromUrl.mockResolvedValue(null);
197
+ mockLoadConfig.mockReturnValue({
198
+ cluster: makeClusterConfig({ peers: [{ id: 'node-b', url: 'http://10.0.0.2:8787' }] }),
199
+ });
200
+
201
+ const logs: string[] = [];
202
+ vi.spyOn(console, 'log').mockImplementation((...args) => logs.push(args.join(' ')));
203
+
204
+ await clusterCommand.parseAsync(['node', 'cluster', 'health', '--json']);
205
+
206
+ const parsed = JSON.parse(logs[0]);
207
+ expect(parsed.reachable).toBe(1);
208
+ const peerNode = parsed.nodes.find((n: { id: string }) => n.id === 'node-b');
209
+ expect(peerNode.reachable).toBe(false);
210
+ expect(peerNode.latencyMs).toBeNull();
211
+ });
212
+ });
213
+
214
+ describe('events subcommand', () => {
215
+ it('returns disabled message when cluster disabled', async () => {
216
+ mockLoadConfig.mockReturnValue({ cluster: makeClusterConfig({ enabled: false }) });
217
+ const logs: string[] = [];
218
+ vi.spyOn(console, 'log').mockImplementation((...args) => logs.push(args.join(' ')));
219
+
220
+ await clusterCommand.parseAsync(['node', 'cluster', 'events', '--json']);
221
+
222
+ const parsed = JSON.parse(logs[0]);
223
+ expect(parsed.enabled).toBe(false);
224
+ });
225
+
226
+ it('returns events list from local runtime', async () => {
227
+ const fakeEvent = {
228
+ event_id: 'evt-1',
229
+ table_name: 'stories',
230
+ row_id: 'STORY-1',
231
+ op: 'upsert',
232
+ payload: {},
233
+ version: { actor_id: 'node-test', actor_counter: 1, logical_ts: 100 },
234
+ created_at: new Date().toISOString(),
235
+ };
236
+ mockFetchEvents.mockResolvedValue([fakeEvent]);
237
+
238
+ const logs: string[] = [];
239
+ vi.spyOn(console, 'log').mockImplementation((...args) => logs.push(args.join(' ')));
240
+
241
+ await clusterCommand.parseAsync(['node', 'cluster', 'events', '--json']);
242
+
243
+ const parsed = JSON.parse(logs[0]);
244
+ expect(parsed.total).toBe(1);
245
+ expect(parsed.events[0].event_id).toBe('evt-1');
246
+ });
247
+
248
+ it('filters events by table name', async () => {
249
+ const events = [
250
+ {
251
+ event_id: 'e1',
252
+ table_name: 'stories',
253
+ row_id: 'S1',
254
+ op: 'upsert',
255
+ payload: {},
256
+ version: { actor_id: 'n', actor_counter: 1, logical_ts: 1 },
257
+ created_at: new Date().toISOString(),
258
+ },
259
+ {
260
+ event_id: 'e2',
261
+ table_name: 'agents',
262
+ row_id: 'A1',
263
+ op: 'upsert',
264
+ payload: {},
265
+ version: { actor_id: 'n', actor_counter: 2, logical_ts: 2 },
266
+ created_at: new Date().toISOString(),
267
+ },
268
+ ];
269
+ mockFetchEvents.mockResolvedValue(events);
270
+
271
+ const logs: string[] = [];
272
+ vi.spyOn(console, 'log').mockImplementation((...args) => logs.push(args.join(' ')));
273
+
274
+ await clusterCommand.parseAsync([
275
+ 'node',
276
+ 'cluster',
277
+ 'events',
278
+ '--table',
279
+ 'stories',
280
+ '--json',
281
+ ]);
282
+
283
+ const parsed = JSON.parse(logs[0]);
284
+ expect(parsed.total).toBe(1);
285
+ expect(parsed.events[0].table_name).toBe('stories');
286
+ });
287
+
288
+ it('passes limit to fetchLocalClusterEvents', async () => {
289
+ mockFetchEvents.mockResolvedValue([]);
290
+ const logs: string[] = [];
291
+ vi.spyOn(console, 'log').mockImplementation((...args) => logs.push(args.join(' ')));
292
+
293
+ await clusterCommand.parseAsync(['node', 'cluster', 'events', '--limit', '10', '--json']);
294
+
295
+ expect(mockFetchEvents).toHaveBeenCalledWith(expect.anything(), 10);
296
+ });
297
+ });
298
+
299
+ describe('join subcommand', () => {
300
+ it('posts join request to peer and reports success', async () => {
301
+ mockPostPeer.mockResolvedValue({
302
+ success: true,
303
+ leader_id: 'node-b',
304
+ leader_url: 'http://10.0.0.2:8787',
305
+ peers: [
306
+ { id: 'node-test', url: 'http://127.0.0.1:8787' },
307
+ { id: 'node-b', url: 'http://10.0.0.2:8787' },
308
+ ],
309
+ term: 2,
310
+ });
311
+
312
+ const logs: string[] = [];
313
+ vi.spyOn(console, 'log').mockImplementation((...args) => logs.push(args.join(' ')));
314
+
315
+ await clusterCommand.parseAsync([
316
+ 'node',
317
+ 'cluster',
318
+ 'join',
319
+ 'http://10.0.0.2:8787',
320
+ '--json',
321
+ ]);
322
+
323
+ const parsed = JSON.parse(logs[0]);
324
+ expect(parsed.success).toBe(true);
325
+ });
326
+
327
+ it('follows redirect to leader when peer is not leader', async () => {
328
+ // First call: peer returns not-leader redirect
329
+ mockPostPeer
330
+ .mockResolvedValueOnce({
331
+ success: false,
332
+ leader_id: 'node-leader',
333
+ leader_url: 'http://10.0.0.3:8787',
334
+ peers: [],
335
+ term: 3,
336
+ })
337
+ // Second call: leader accepts
338
+ .mockResolvedValueOnce({
339
+ success: true,
340
+ leader_id: 'node-leader',
341
+ leader_url: 'http://10.0.0.3:8787',
342
+ peers: [{ id: 'node-test', url: 'http://127.0.0.1:8787' }],
343
+ term: 3,
344
+ });
345
+
346
+ const logs: string[] = [];
347
+ vi.spyOn(console, 'log').mockImplementation((...args) => logs.push(args.join(' ')));
348
+
349
+ await clusterCommand.parseAsync([
350
+ 'node',
351
+ 'cluster',
352
+ 'join',
353
+ 'http://10.0.0.2:8787',
354
+ '--json',
355
+ ]);
356
+
357
+ expect(mockPostPeer).toHaveBeenCalledTimes(2);
358
+ const parsed = JSON.parse(logs[0]);
359
+ expect(parsed.success).toBe(true);
360
+ });
361
+
362
+ it('exits non-zero when peer unreachable', async () => {
363
+ mockPostPeer.mockResolvedValue(null);
364
+
365
+ const errors: string[] = [];
366
+ vi.spyOn(console, 'error').mockImplementation((...args) => errors.push(args.join(' ')));
367
+
368
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {
369
+ throw new Error('process.exit called');
370
+ });
371
+
372
+ await expect(
373
+ clusterCommand.parseAsync(['node', 'cluster', 'join', 'http://bad:8787'])
374
+ ).rejects.toThrow();
375
+
376
+ expect(exitSpy).toHaveBeenCalledWith(1);
377
+ exitSpy.mockRestore();
378
+ });
379
+ });
380
+
381
+ describe('leave subcommand', () => {
382
+ it('posts leave request and reports success', async () => {
383
+ mockFetchLocal.mockResolvedValue(makeStatus({ role: 'follower', is_leader: false }));
384
+ mockPostLocal.mockResolvedValue({
385
+ success: true,
386
+ peers: [{ id: 'node-b', url: 'http://10.0.0.2:8787' }],
387
+ });
388
+
389
+ const logs: string[] = [];
390
+ vi.spyOn(console, 'log').mockImplementation((...args) => logs.push(args.join(' ')));
391
+
392
+ await clusterCommand.parseAsync(['node', 'cluster', 'leave', '--json']);
393
+
394
+ const parsed = JSON.parse(logs[0]);
395
+ expect(parsed.success).toBe(true);
396
+ });
397
+
398
+ it('rejects leave when this node is the leader', async () => {
399
+ mockFetchLocal.mockResolvedValue(makeStatus({ role: 'leader', is_leader: true }));
400
+
401
+ const logs: string[] = [];
402
+ vi.spyOn(console, 'log').mockImplementation((...args) => logs.push(args.join(' ')));
403
+
404
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {
405
+ throw new Error('process.exit called');
406
+ });
407
+
408
+ await expect(
409
+ clusterCommand.parseAsync(['node', 'cluster', 'leave', '--json'])
410
+ ).rejects.toThrow();
411
+
412
+ expect(exitSpy).toHaveBeenCalledWith(1);
413
+ const parsed = JSON.parse(logs[0]);
414
+ expect(parsed.success).toBe(false);
415
+
416
+ exitSpy.mockRestore();
417
+ });
418
+
419
+ it('exits non-zero when local runtime unavailable', async () => {
420
+ mockFetchLocal.mockResolvedValue(null);
421
+
422
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {
423
+ throw new Error('process.exit called');
424
+ });
425
+
426
+ await expect(clusterCommand.parseAsync(['node', 'cluster', 'leave'])).rejects.toThrow();
427
+
428
+ expect(exitSpy).toHaveBeenCalledWith(1);
429
+ exitSpy.mockRestore();
52
430
  });
53
431
  });
54
432
  });