hungry-ghost-hive 0.43.0 → 0.43.2

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 (133) hide show
  1. package/dist/cli/commands/agents.d.ts.map +1 -1
  2. package/dist/cli/commands/agents.js +4 -11
  3. package/dist/cli/commands/agents.js.map +1 -1
  4. package/dist/cli/commands/approach.d.ts.map +1 -1
  5. package/dist/cli/commands/approach.js +2 -6
  6. package/dist/cli/commands/approach.js.map +1 -1
  7. package/dist/cli/commands/init.d.ts.map +1 -1
  8. package/dist/cli/commands/init.js +9 -0
  9. package/dist/cli/commands/init.js.map +1 -1
  10. package/dist/cli/commands/init.test.js +3 -0
  11. package/dist/cli/commands/init.test.js.map +1 -1
  12. package/dist/cli/commands/manager/index.d.ts +2 -27
  13. package/dist/cli/commands/manager/index.d.ts.map +1 -1
  14. package/dist/cli/commands/manager/index.js +23 -1519
  15. package/dist/cli/commands/manager/index.js.map +1 -1
  16. package/dist/cli/commands/manager/manager-utils.d.ts +9 -0
  17. package/dist/cli/commands/manager/manager-utils.d.ts.map +1 -0
  18. package/dist/cli/commands/manager/manager-utils.js +49 -0
  19. package/dist/cli/commands/manager/manager-utils.js.map +1 -0
  20. package/dist/cli/commands/manager/pr-sync-orchestrator.d.ts +7 -0
  21. package/dist/cli/commands/manager/pr-sync-orchestrator.d.ts.map +1 -0
  22. package/dist/cli/commands/manager/pr-sync-orchestrator.js +537 -0
  23. package/dist/cli/commands/manager/pr-sync-orchestrator.js.map +1 -0
  24. package/dist/cli/commands/manager/qa-review-handler.d.ts +15 -0
  25. package/dist/cli/commands/manager/qa-review-handler.d.ts.map +1 -0
  26. package/dist/cli/commands/manager/qa-review-handler.js +290 -0
  27. package/dist/cli/commands/manager/qa-review-handler.js.map +1 -0
  28. package/dist/cli/commands/manager/stuck-story-helpers.d.ts +32 -0
  29. package/dist/cli/commands/manager/stuck-story-helpers.d.ts.map +1 -0
  30. package/dist/cli/commands/manager/stuck-story-helpers.js +163 -0
  31. package/dist/cli/commands/manager/stuck-story-helpers.js.map +1 -0
  32. package/dist/cli/commands/manager/stuck-story-processor.d.ts +8 -0
  33. package/dist/cli/commands/manager/stuck-story-processor.d.ts.map +1 -0
  34. package/dist/cli/commands/manager/stuck-story-processor.js +392 -0
  35. package/dist/cli/commands/manager/stuck-story-processor.js.map +1 -0
  36. package/dist/cli/commands/manager/tech-lead-lifecycle.d.ts +3 -0
  37. package/dist/cli/commands/manager/tech-lead-lifecycle.d.ts.map +1 -0
  38. package/dist/cli/commands/manager/tech-lead-lifecycle.js +141 -0
  39. package/dist/cli/commands/manager/tech-lead-lifecycle.js.map +1 -0
  40. package/dist/cli/commands/my-stories.d.ts.map +1 -1
  41. package/dist/cli/commands/my-stories.js +5 -20
  42. package/dist/cli/commands/my-stories.js.map +1 -1
  43. package/dist/cli/commands/pr.js +7 -22
  44. package/dist/cli/commands/pr.js.map +1 -1
  45. package/dist/cli/commands/progress.d.ts.map +1 -1
  46. package/dist/cli/commands/progress.js +2 -5
  47. package/dist/cli/commands/progress.js.map +1 -1
  48. package/dist/cli/commands/resume.d.ts.map +1 -1
  49. package/dist/cli/commands/resume.js +3 -6
  50. package/dist/cli/commands/resume.js.map +1 -1
  51. package/dist/cli/commands/status.d.ts.map +1 -1
  52. package/dist/cli/commands/status.js +2 -5
  53. package/dist/cli/commands/status.js.map +1 -1
  54. package/dist/cli/commands/stories.d.ts.map +1 -1
  55. package/dist/cli/commands/stories.js +2 -5
  56. package/dist/cli/commands/stories.js.map +1 -1
  57. package/dist/cluster/adapters.d.ts +3 -2
  58. package/dist/cluster/adapters.d.ts.map +1 -1
  59. package/dist/cluster/adapters.js +2 -11
  60. package/dist/cluster/adapters.js.map +1 -1
  61. package/dist/cluster/cluster-http-server.d.ts +20 -0
  62. package/dist/cluster/cluster-http-server.d.ts.map +1 -0
  63. package/dist/cluster/cluster-http-server.js +140 -0
  64. package/dist/cluster/cluster-http-server.js.map +1 -0
  65. package/dist/cluster/heartbeat-manager.d.ts +24 -0
  66. package/dist/cluster/heartbeat-manager.d.ts.map +1 -0
  67. package/dist/cluster/heartbeat-manager.js +74 -0
  68. package/dist/cluster/heartbeat-manager.js.map +1 -0
  69. package/dist/cluster/raft-state-machine.d.ts +48 -0
  70. package/dist/cluster/raft-state-machine.d.ts.map +1 -0
  71. package/dist/cluster/raft-state-machine.js +207 -0
  72. package/dist/cluster/raft-state-machine.js.map +1 -0
  73. package/dist/cluster/runtime.d.ts +5 -29
  74. package/dist/cluster/runtime.d.ts.map +1 -1
  75. package/dist/cluster/runtime.js +58 -406
  76. package/dist/cluster/runtime.js.map +1 -1
  77. package/dist/integrations/jira/sync.d.ts +2 -5
  78. package/dist/integrations/jira/sync.d.ts.map +1 -1
  79. package/dist/integrations/jira/sync.js +116 -178
  80. package/dist/integrations/jira/sync.js.map +1 -1
  81. package/dist/utils/cli-helpers.d.ts +19 -0
  82. package/dist/utils/cli-helpers.d.ts.map +1 -0
  83. package/dist/utils/cli-helpers.js +51 -0
  84. package/dist/utils/cli-helpers.js.map +1 -0
  85. package/dist/utils/cli-helpers.test.d.ts +2 -0
  86. package/dist/utils/cli-helpers.test.d.ts.map +1 -0
  87. package/dist/utils/cli-helpers.test.js +100 -0
  88. package/dist/utils/cli-helpers.test.js.map +1 -0
  89. package/dist/utils/github-cli.d.ts +3 -0
  90. package/dist/utils/github-cli.d.ts.map +1 -0
  91. package/dist/utils/github-cli.js +4 -0
  92. package/dist/utils/github-cli.js.map +1 -0
  93. package/dist/utils/pr-sync.d.ts.map +1 -1
  94. package/dist/utils/pr-sync.js +1 -2
  95. package/dist/utils/pr-sync.js.map +1 -1
  96. package/dist/utils/story-status.d.ts +19 -0
  97. package/dist/utils/story-status.d.ts.map +1 -0
  98. package/dist/utils/story-status.js +58 -0
  99. package/dist/utils/story-status.js.map +1 -0
  100. package/dist/utils/story-status.test.d.ts +2 -0
  101. package/dist/utils/story-status.test.d.ts.map +1 -0
  102. package/dist/utils/story-status.test.js +65 -0
  103. package/dist/utils/story-status.test.js.map +1 -0
  104. package/package.json +1 -1
  105. package/src/cli/commands/agents.ts +3 -11
  106. package/src/cli/commands/approach.ts +2 -7
  107. package/src/cli/commands/init.test.ts +4 -0
  108. package/src/cli/commands/init.ts +9 -0
  109. package/src/cli/commands/manager/index.ts +166 -2236
  110. package/src/cli/commands/manager/manager-utils.ts +85 -0
  111. package/src/cli/commands/manager/pr-sync-orchestrator.ts +659 -0
  112. package/src/cli/commands/manager/qa-review-handler.ts +399 -0
  113. package/src/cli/commands/manager/stuck-story-helpers.ts +255 -0
  114. package/src/cli/commands/manager/stuck-story-processor.ts +604 -0
  115. package/src/cli/commands/manager/tech-lead-lifecycle.ts +210 -0
  116. package/src/cli/commands/my-stories.ts +5 -30
  117. package/src/cli/commands/pr.ts +6 -22
  118. package/src/cli/commands/progress.ts +2 -7
  119. package/src/cli/commands/resume.ts +3 -6
  120. package/src/cli/commands/status.ts +2 -5
  121. package/src/cli/commands/stories.ts +2 -5
  122. package/src/cluster/adapters.ts +3 -12
  123. package/src/cluster/cluster-http-server.ts +187 -0
  124. package/src/cluster/heartbeat-manager.ts +112 -0
  125. package/src/cluster/raft-state-machine.ts +267 -0
  126. package/src/cluster/runtime.ts +71 -515
  127. package/src/integrations/jira/sync.ts +157 -215
  128. package/src/utils/cli-helpers.test.ts +138 -0
  129. package/src/utils/cli-helpers.ts +61 -0
  130. package/src/utils/github-cli.ts +4 -0
  131. package/src/utils/pr-sync.ts +1 -3
  132. package/src/utils/story-status.test.ts +74 -0
  133. package/src/utils/story-status.ts +62 -0
@@ -1,10 +1,11 @@
1
1
  // Licensed under the Hungry Ghost Hive License. See LICENSE.
2
2
 
3
- import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'http';
4
3
  import { join } from 'path';
5
4
  import type { Database } from 'sql.js';
6
5
  import type { ClusterConfig, ClusterPeerConfig } from '../config/schema.js';
7
- import { RaftMetadataStore } from './raft-store.js';
6
+ import { ClusterHttpServer } from './cluster-http-server.js';
7
+ import { HeartbeatManager } from './heartbeat-manager.js';
8
+ import { RaftStateMachine } from './raft-state-machine.js';
8
9
  import {
9
10
  applyRemoteEvents,
10
11
  ensureClusterTables,
@@ -18,37 +19,6 @@ import {
18
19
 
19
20
  type NodeRole = 'leader' | 'follower' | 'candidate';
20
21
 
21
- interface VoteRequest {
22
- term: number;
23
- candidate_id: string;
24
- }
25
-
26
- interface VoteResponse {
27
- term: number;
28
- vote_granted: boolean;
29
- leader_id: string | null;
30
- }
31
-
32
- interface HeartbeatRequest {
33
- term: number;
34
- leader_id: string;
35
- }
36
-
37
- interface HeartbeatResponse {
38
- term: number;
39
- success: boolean;
40
- }
41
-
42
- interface DeltaRequest {
43
- version_vector: VersionVector;
44
- limit?: number;
45
- }
46
-
47
- interface DeltaResponse {
48
- events: ClusterEvent[];
49
- version_vector: VersionVector;
50
- }
51
-
52
22
  interface ClusterRuntimeOptions {
53
23
  hiveDir?: string;
54
24
  }
@@ -58,7 +28,10 @@ interface ClusterStatusFetchOptions {
58
28
  timeoutMs: number;
59
29
  }
60
30
 
61
- const MAX_CLUSTER_REQUEST_BODY_BYTES = 1024 * 1024; // 1 MiB
31
+ interface DeltaResponse {
32
+ events: ClusterEvent[];
33
+ version_vector: VersionVector;
34
+ }
62
35
 
63
36
  export interface ClusterStatus {
64
37
  enabled: boolean;
@@ -83,41 +56,57 @@ export interface ClusterSyncResult {
83
56
  }
84
57
 
85
58
  export class ClusterRuntime {
86
- private server: Server | null = null;
87
- private electionTimer: NodeJS.Timeout | null = null;
88
- private heartbeatTimer: NodeJS.Timeout | null = null;
89
59
  private started = false;
90
60
  private stopping = false;
91
61
 
92
- private role: NodeRole = 'follower';
93
- private currentTerm = 0;
94
- private votedFor: string | null = null;
95
- private leaderId: string | null = null;
96
- private electionDeadline = 0;
97
- private electionInFlight = false;
98
-
99
62
  private eventCache: ClusterEvent[] = [];
100
63
  private versionVectorCache: VersionVector = {};
101
- private raftStore: RaftMetadataStore | null = null;
64
+
65
+ private readonly raft: RaftStateMachine;
66
+ private readonly heartbeat: HeartbeatManager;
67
+ private readonly httpServer: ClusterHttpServer;
102
68
 
103
69
  constructor(
104
70
  private readonly config: ClusterConfig,
105
71
  private readonly options: ClusterRuntimeOptions = {}
106
- ) {}
72
+ ) {
73
+ this.raft = new RaftStateMachine(config, {
74
+ postJson: (peer, path, body) => this.postJson(peer, path, body),
75
+ isActive: () => this.started && !this.stopping,
76
+ handleBackgroundError: error => this.handleBackgroundError(error),
77
+ });
78
+
79
+ this.heartbeat = new HeartbeatManager(config, {
80
+ raft: this.raft,
81
+ postJson: (peer, path, body) => this.postJson(peer, path, body),
82
+ isActive: () => this.started && !this.stopping,
83
+ handleBackgroundError: error => this.handleBackgroundError(error),
84
+ });
85
+
86
+ this.httpServer = new ClusterHttpServer(config, {
87
+ getStatus: () => this.getStatus(),
88
+ handleVoteRequest: body => this.raft.handleVoteRequest(body),
89
+ handleHeartbeat: body => this.heartbeat.handleHeartbeat(body),
90
+ getDeltaFromCache: (vector, limit) => this.getDeltaFromCache(vector, limit),
91
+ getVersionVectorCache: () => this.versionVectorCache,
92
+ });
93
+ }
107
94
 
108
95
  async start(): Promise<void> {
109
96
  if (!this.config.enabled || this.started) return;
110
97
 
111
98
  this.stopping = false;
112
99
  this.validateNetworkSecurity();
113
- this.initializeRaftStore();
114
- this.resetElectionDeadline();
115
- await this.startServer();
116
- this.startElectionLoop();
117
- this.startHeartbeatLoop();
100
+
101
+ const hiveDir = this.options.hiveDir || join(process.cwd(), '.hive');
102
+ this.raft.initializeRaftStore(hiveDir);
103
+
104
+ await this.httpServer.startServer();
105
+ this.raft.startElectionLoop();
106
+ this.heartbeat.startHeartbeatLoop();
118
107
  this.started = true;
119
108
 
120
- this.appendDurableEntry('runtime', {
109
+ this.raft.appendDurableEntry('runtime', {
121
110
  event: 'runtime_start',
122
111
  node_id: this.config.node_id,
123
112
  });
@@ -126,31 +115,18 @@ export class ClusterRuntime {
126
115
  async stop(): Promise<void> {
127
116
  this.stopping = true;
128
117
 
129
- if (this.electionTimer) {
130
- clearInterval(this.electionTimer);
131
- this.electionTimer = null;
132
- }
133
-
134
- if (this.heartbeatTimer) {
135
- clearInterval(this.heartbeatTimer);
136
- this.heartbeatTimer = null;
137
- }
118
+ this.raft.stopElectionLoop();
119
+ this.heartbeat.stopHeartbeatLoop();
138
120
 
139
- this.appendDurableEntry('runtime', {
121
+ this.raft.appendDurableEntry('runtime', {
140
122
  event: 'runtime_stop',
141
123
  node_id: this.config.node_id,
142
124
  });
143
125
 
144
- if (this.server) {
145
- await new Promise<void>(resolve => {
146
- this.server?.close(() => resolve());
147
- });
148
- this.server = null;
149
- }
126
+ await this.httpServer.stopServer();
150
127
 
151
128
  this.started = false;
152
- this.raftStore = null;
153
- this.electionInFlight = false;
129
+ this.raft.clearRaftStore();
154
130
  }
155
131
 
156
132
  isEnabled(): boolean {
@@ -159,21 +135,21 @@ export class ClusterRuntime {
159
135
 
160
136
  isLeader(): boolean {
161
137
  if (!this.config.enabled) return true;
162
- return this.role === 'leader';
138
+ return this.raft.role === 'leader';
163
139
  }
164
140
 
165
141
  getStatus(): ClusterStatus {
166
- const raftState = this.raftStore?.getState();
142
+ const raftState = this.raft.getRaftStoreState();
167
143
 
168
144
  return {
169
145
  enabled: this.config.enabled,
170
146
  node_id: this.config.node_id,
171
- role: this.role,
172
- term: this.currentTerm,
173
- voted_for: this.votedFor,
147
+ role: this.raft.role,
148
+ term: this.raft.currentTerm,
149
+ voted_for: this.raft.votedFor,
174
150
  is_leader: this.isLeader(),
175
- leader_id: this.leaderId,
176
- leader_url: this.getLeaderUrl(),
151
+ leader_id: this.raft.leaderId,
152
+ leader_url: this.raft.getLeaderUrl(),
177
153
  raft_commit_index: raftState?.commit_index || 0,
178
154
  raft_last_applied: raftState?.last_applied || 0,
179
155
  raft_last_log_index: raftState?.last_log_index || 0,
@@ -191,9 +167,8 @@ export class ClusterRuntime {
191
167
  };
192
168
  }
193
169
 
194
- if (!this.raftStore) {
195
- this.initializeRaftStore();
196
- }
170
+ const hiveDir = this.options.hiveDir || join(process.cwd(), '.hive');
171
+ this.raft.initializeRaftStore(hiveDir);
197
172
 
198
173
  ensureClusterTables(db, this.config.node_id);
199
174
 
@@ -205,7 +180,9 @@ export class ClusterRuntime {
205
180
 
206
181
  this.refreshCache(db);
207
182
 
208
- const durableLogEntriesAppended = this.appendClusterEventsToDurableLog(getAllClusterEvents(db));
183
+ const durableLogEntriesAppended = this.raft.appendClusterEventsToDurableLog(
184
+ getAllClusterEvents(db)
185
+ );
209
186
 
210
187
  return {
211
188
  local_events_emitted: localEventsBefore + localEventsAfter,
@@ -215,37 +192,11 @@ export class ClusterRuntime {
215
192
  };
216
193
  }
217
194
 
218
- private initializeRaftStore(): void {
219
- if (this.raftStore) return;
220
-
221
- const hiveDir = this.options.hiveDir || join(process.cwd(), '.hive');
222
- const clusterDir = join(hiveDir, 'cluster');
223
-
224
- this.raftStore = new RaftMetadataStore({
225
- clusterDir,
226
- nodeId: this.config.node_id,
227
- });
228
-
229
- const persisted = this.raftStore.getState();
230
- this.currentTerm = persisted.current_term;
231
- this.votedFor = persisted.voted_for;
232
- this.leaderId = persisted.leader_id;
233
- this.role = 'follower';
234
- }
235
-
236
195
  private refreshCache(db: Database): void {
237
196
  this.eventCache = getAllClusterEvents(db).slice(-20000);
238
197
  this.versionVectorCache = getVersionVector(db);
239
198
  }
240
199
 
241
- private appendClusterEventsToDurableLog(events: ClusterEvent[]): number {
242
- if (!this.raftStore) return 0;
243
-
244
- const appended = this.raftStore.appendClusterEvents(events, this.currentTerm);
245
- this.persistRaftState({});
246
- return appended;
247
- }
248
-
249
200
  private async pullEventsFromPeers(db: Database): Promise<number> {
250
201
  if (this.config.peers.length === 0) return 0;
251
202
 
@@ -275,338 +226,6 @@ export class ClusterRuntime {
275
226
  });
276
227
  }
277
228
 
278
- private startElectionLoop(): void {
279
- this.electionTimer = setInterval(() => {
280
- if (!this.config.enabled) return;
281
- if (this.role === 'leader') return;
282
-
283
- if (Date.now() >= this.electionDeadline) {
284
- void this.startElection().catch(error => this.handleBackgroundError(error));
285
- }
286
- }, 250);
287
- }
288
-
289
- private startHeartbeatLoop(): void {
290
- this.heartbeatTimer = setInterval(() => {
291
- if (!this.config.enabled) return;
292
- if (this.role !== 'leader') return;
293
- void this.sendHeartbeats().catch(error => this.handleBackgroundError(error));
294
- }, this.config.heartbeat_interval_ms);
295
- }
296
-
297
- private async startElection(): Promise<void> {
298
- if (!this.config.enabled || this.electionInFlight || !this.started || this.stopping) return;
299
-
300
- this.electionInFlight = true;
301
- const electionTerm = this.currentTerm + 1;
302
-
303
- this.currentTerm = electionTerm;
304
- this.role = 'candidate';
305
- this.votedFor = this.config.node_id;
306
- this.leaderId = null;
307
- this.resetElectionDeadline();
308
- this.persistRaftState({});
309
- this.appendDurableEntry('election_start', {
310
- term: electionTerm,
311
- candidate_id: this.config.node_id,
312
- });
313
-
314
- let votes = 1;
315
-
316
- try {
317
- await Promise.all(
318
- this.config.peers
319
- .filter(peer => peer.id !== this.config.node_id)
320
- .map(async peer => {
321
- const response = await this.postJson<VoteResponse>(
322
- peer,
323
- '/cluster/v1/election/request-vote',
324
- {
325
- term: electionTerm,
326
- candidate_id: this.config.node_id,
327
- } satisfies VoteRequest
328
- );
329
-
330
- if (!response) return;
331
-
332
- if (response.term > this.currentTerm) {
333
- this.stepDown(response.term, response.leader_id);
334
- return;
335
- }
336
-
337
- if (
338
- this.role === 'candidate' &&
339
- this.currentTerm === electionTerm &&
340
- response.vote_granted
341
- ) {
342
- votes += 1;
343
- }
344
- })
345
- );
346
-
347
- if (
348
- this.role === 'candidate' &&
349
- this.currentTerm === electionTerm &&
350
- votes >= this.quorum()
351
- ) {
352
- this.role = 'leader';
353
- this.leaderId = this.config.node_id;
354
- this.votedFor = null;
355
- this.persistRaftState({});
356
- this.appendDurableEntry('election_won', {
357
- term: electionTerm,
358
- votes,
359
- quorum: this.quorum(),
360
- leader_id: this.config.node_id,
361
- });
362
- }
363
- } finally {
364
- this.electionInFlight = false;
365
- }
366
- }
367
-
368
- private async sendHeartbeats(): Promise<void> {
369
- if (!this.started || this.stopping) return;
370
-
371
- const heartbeat: HeartbeatRequest = {
372
- term: this.currentTerm,
373
- leader_id: this.config.node_id,
374
- };
375
-
376
- this.appendDurableEntry('heartbeat_sent', {
377
- term: this.currentTerm,
378
- leader_id: this.config.node_id,
379
- peer_count: this.config.peers.filter(peer => peer.id !== this.config.node_id).length,
380
- });
381
-
382
- await Promise.all(
383
- this.config.peers
384
- .filter(peer => peer.id !== this.config.node_id)
385
- .map(async peer => {
386
- const response = await this.postJson<HeartbeatResponse>(
387
- peer,
388
- '/cluster/v1/election/heartbeat',
389
- heartbeat
390
- );
391
-
392
- if (response && response.term > this.currentTerm) {
393
- this.stepDown(response.term, peer.id);
394
- }
395
- })
396
- );
397
- }
398
-
399
- private handleVoteRequest(body: unknown): VoteResponse {
400
- const request = body as Partial<VoteRequest>;
401
- const term = Number(request.term || 0);
402
- const candidateId = typeof request.candidate_id === 'string' ? request.candidate_id : '';
403
-
404
- if (!candidateId) {
405
- return { term: this.currentTerm, vote_granted: false, leader_id: this.leaderId };
406
- }
407
-
408
- if (term < this.currentTerm) {
409
- return { term: this.currentTerm, vote_granted: false, leader_id: this.leaderId };
410
- }
411
-
412
- if (term > this.currentTerm) {
413
- this.stepDown(term, null);
414
- }
415
-
416
- const canVote = this.votedFor === null || this.votedFor === candidateId;
417
- if (canVote) {
418
- this.votedFor = candidateId;
419
- this.resetElectionDeadline();
420
- this.persistRaftState({});
421
- this.appendDurableEntry('vote_granted', {
422
- term: this.currentTerm,
423
- candidate_id: candidateId,
424
- });
425
- return { term: this.currentTerm, vote_granted: true, leader_id: this.leaderId };
426
- }
427
-
428
- return { term: this.currentTerm, vote_granted: false, leader_id: this.leaderId };
429
- }
430
-
431
- private handleHeartbeat(body: unknown): HeartbeatResponse {
432
- const request = body as Partial<HeartbeatRequest>;
433
- const term = Number(request.term || 0);
434
- const leaderId = typeof request.leader_id === 'string' ? request.leader_id : null;
435
-
436
- if (term < this.currentTerm) {
437
- return { term: this.currentTerm, success: false };
438
- }
439
-
440
- const changed =
441
- term > this.currentTerm || leaderId !== this.leaderId || this.role !== 'follower';
442
-
443
- if (term > this.currentTerm) {
444
- this.stepDown(term, leaderId);
445
- } else {
446
- this.role = 'follower';
447
- this.leaderId = leaderId;
448
- this.persistRaftState({});
449
- }
450
-
451
- this.resetElectionDeadline();
452
-
453
- if (changed) {
454
- this.appendDurableEntry('heartbeat_received', {
455
- term,
456
- leader_id: leaderId,
457
- });
458
- }
459
-
460
- return { term: this.currentTerm, success: true };
461
- }
462
-
463
- private stepDown(term: number, leaderId: string | null): void {
464
- const previousRole = this.role;
465
- const previousTerm = this.currentTerm;
466
-
467
- this.currentTerm = term;
468
- this.role = 'follower';
469
- this.votedFor = null;
470
- this.leaderId = leaderId;
471
- this.resetElectionDeadline();
472
- this.persistRaftState({});
473
-
474
- this.appendDurableEntry('state_transition', {
475
- previous_role: previousRole,
476
- previous_term: previousTerm,
477
- current_term: this.currentTerm,
478
- leader_id: leaderId,
479
- });
480
- }
481
-
482
- private quorum(): number {
483
- const nodes = this.config.peers.length + 1;
484
- return Math.floor(nodes / 2) + 1;
485
- }
486
-
487
- private resetElectionDeadline(): void {
488
- const min = this.config.election_timeout_min_ms;
489
- const max = Math.max(min, this.config.election_timeout_max_ms);
490
- const spread = max - min;
491
- const jitter = spread === 0 ? 0 : Math.floor(Math.random() * spread);
492
- this.electionDeadline = Date.now() + min + jitter;
493
- }
494
-
495
- private persistRaftState(_patch: Partial<Record<string, unknown>>): void {
496
- if (!this.raftStore) return;
497
-
498
- this.raftStore.setState({
499
- current_term: this.currentTerm,
500
- voted_for: this.votedFor,
501
- leader_id: this.leaderId,
502
- });
503
- }
504
-
505
- private appendDurableEntry(
506
- type: Parameters<RaftMetadataStore['appendEntry']>[0]['type'],
507
- metadata: Record<string, unknown>
508
- ): void {
509
- if (!this.raftStore || this.stopping) return;
510
-
511
- try {
512
- this.raftStore.appendEntry({ type, term: this.currentTerm, metadata });
513
- } catch (error) {
514
- const err = error as NodeJS.ErrnoException;
515
- if (err.code === 'ENOENT') return;
516
- throw error;
517
- }
518
- }
519
-
520
- private handleBackgroundError(error: unknown): void {
521
- if (!this.started || this.stopping) return;
522
- const err = error as NodeJS.ErrnoException;
523
- if (err.code === 'ENOENT') return;
524
- // Keep runtime alive; surface diagnostic without crashing the process.
525
- console.error('Cluster runtime background task failed:', error);
526
- }
527
-
528
- private validateNetworkSecurity(): void {
529
- if (isLoopbackHost(this.config.listen_host)) return;
530
- if (this.config.auth_token) return;
531
-
532
- throw new Error(
533
- `Cluster auth_token is required when listen_host is not loopback (received: ${this.config.listen_host})`
534
- );
535
- }
536
-
537
- private async startServer(): Promise<void> {
538
- this.server = createServer((req, res) => {
539
- void this.handleHttpRequest(req, res);
540
- });
541
-
542
- await new Promise<void>((resolve, reject) => {
543
- if (!this.server) return reject(new Error('Cluster HTTP server not initialized'));
544
-
545
- this.server.once('error', reject);
546
- this.server.listen(this.config.listen_port, this.config.listen_host, () => {
547
- this.server?.removeListener('error', reject);
548
- resolve();
549
- });
550
- });
551
- }
552
-
553
- private async handleHttpRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
554
- try {
555
- if (!this.authorize(req)) {
556
- sendJson(res, 401, { error: 'Unauthorized' });
557
- return;
558
- }
559
-
560
- const method = req.method || 'GET';
561
- const path = req.url?.split('?')[0] || '/';
562
-
563
- if (method === 'GET' && path === '/cluster/v1/status') {
564
- sendJson(res, 200, this.getStatus());
565
- return;
566
- }
567
-
568
- if (method === 'POST' && path === '/cluster/v1/election/request-vote') {
569
- const body = await readJsonBody(req);
570
- const response = this.handleVoteRequest(body);
571
- sendJson(res, 200, response);
572
- return;
573
- }
574
-
575
- if (method === 'POST' && path === '/cluster/v1/election/heartbeat') {
576
- const body = await readJsonBody(req);
577
- const response = this.handleHeartbeat(body);
578
- sendJson(res, 200, response);
579
- return;
580
- }
581
-
582
- if (method === 'POST' && path === '/cluster/v1/events/delta') {
583
- const body = (await readJsonBody(req)) as Partial<DeltaRequest>;
584
- const vector = toVersionVector(body.version_vector);
585
- const limit =
586
- typeof body.limit === 'number' && Number.isFinite(body.limit) && body.limit > 0
587
- ? Math.floor(body.limit)
588
- : 2000;
589
-
590
- const events = this.getDeltaFromCache(vector, limit);
591
- sendJson(res, 200, {
592
- events,
593
- version_vector: this.versionVectorCache,
594
- } satisfies DeltaResponse);
595
- return;
596
- }
597
-
598
- sendJson(res, 404, { error: 'Not found' });
599
- } catch (error) {
600
- if (error instanceof HttpRequestError) {
601
- sendJson(res, error.statusCode, { error: error.message });
602
- return;
603
- }
604
-
605
- const message = error instanceof Error ? error.message : String(error);
606
- sendJson(res, 500, { error: message });
607
- }
608
- }
609
-
610
229
  private getDeltaFromCache(remoteVersionVector: VersionVector, limit: number): ClusterEvent[] {
611
230
  return this.eventCache
612
231
  .filter(event => {
@@ -616,16 +235,6 @@ export class ClusterRuntime {
616
235
  .slice(0, limit);
617
236
  }
618
237
 
619
- private authorize(req: IncomingMessage): boolean {
620
- if (!this.config.auth_token) return true;
621
-
622
- const authHeader = req.headers.authorization;
623
- if (!authHeader) return false;
624
-
625
- const expected = `Bearer ${this.config.auth_token}`;
626
- return authHeader === expected;
627
- }
628
-
629
238
  private async postJson<T>(
630
239
  peer: ClusterPeerConfig,
631
240
  path: string,
@@ -645,12 +254,20 @@ export class ClusterRuntime {
645
254
  );
646
255
  }
647
256
 
648
- private getLeaderUrl(): string | null {
649
- if (!this.leaderId) return null;
650
- if (this.leaderId === this.config.node_id) return this.config.public_url;
257
+ private handleBackgroundError(error: unknown): void {
258
+ if (!this.started || this.stopping) return;
259
+ const err = error as NodeJS.ErrnoException;
260
+ if (err.code === 'ENOENT') return;
261
+ console.error('Cluster runtime background task failed:', error);
262
+ }
263
+
264
+ private validateNetworkSecurity(): void {
265
+ if (isLoopbackHost(this.config.listen_host)) return;
266
+ if (this.config.auth_token) return;
651
267
 
652
- const peer = this.config.peers.find(item => item.id === this.leaderId);
653
- return peer?.url || null;
268
+ throw new Error(
269
+ `Cluster auth_token is required when listen_host is not loopback (received: ${this.config.listen_host})`
270
+ );
654
271
  }
655
272
  }
656
273
 
@@ -772,67 +389,6 @@ async function fetchClusterStatusOrPostJson<T>(
772
389
  }
773
390
  }
774
391
 
775
- function toVersionVector(input: unknown): VersionVector {
776
- if (!input || typeof input !== 'object') return {};
777
-
778
- const vector: VersionVector = {};
779
-
780
- for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
781
- const num = typeof value === 'number' ? value : Number(value);
782
- if (Number.isFinite(num) && num >= 0) {
783
- vector[key] = Math.floor(num);
784
- }
785
- }
786
-
787
- return vector;
788
- }
789
-
790
- class HttpRequestError extends Error {
791
- constructor(
792
- public readonly statusCode: number,
793
- message: string
794
- ) {
795
- super(message);
796
- this.name = 'HttpRequestError';
797
- }
798
- }
799
-
800
- async function readJsonBody(
801
- req: IncomingMessage,
802
- maxBytes: number = MAX_CLUSTER_REQUEST_BODY_BYTES
803
- ): Promise<unknown> {
804
- const chunks: Buffer[] = [];
805
- let totalBytes = 0;
806
-
807
- for await (const chunk of req) {
808
- const normalizedChunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
809
- totalBytes += normalizedChunk.length;
810
-
811
- if (totalBytes > maxBytes) {
812
- throw new HttpRequestError(413, `Payload too large (max ${maxBytes} bytes)`);
813
- }
814
-
815
- chunks.push(normalizedChunk);
816
- }
817
-
818
- if (chunks.length === 0) return {};
819
-
820
- const raw = Buffer.concat(chunks).toString('utf-8');
821
- if (!raw.trim()) return {};
822
-
823
- try {
824
- return JSON.parse(raw) as unknown;
825
- } catch {
826
- throw new HttpRequestError(400, 'Invalid JSON payload');
827
- }
828
- }
829
-
830
- function sendJson(res: ServerResponse, statusCode: number, body: unknown): void {
831
- res.statusCode = statusCode;
832
- res.setHeader('Content-Type', 'application/json');
833
- res.end(JSON.stringify(body));
834
- }
835
-
836
392
  function toInt(value: unknown): number {
837
393
  const parsed = typeof value === 'number' ? value : Number(value);
838
394
  if (!Number.isFinite(parsed)) return 0;