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.
- package/dist/cli/commands/agents.d.ts.map +1 -1
- package/dist/cli/commands/agents.js +4 -11
- package/dist/cli/commands/agents.js.map +1 -1
- package/dist/cli/commands/approach.d.ts.map +1 -1
- package/dist/cli/commands/approach.js +2 -6
- package/dist/cli/commands/approach.js.map +1 -1
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +9 -0
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/init.test.js +3 -0
- package/dist/cli/commands/init.test.js.map +1 -1
- package/dist/cli/commands/manager/index.d.ts +2 -27
- package/dist/cli/commands/manager/index.d.ts.map +1 -1
- package/dist/cli/commands/manager/index.js +23 -1519
- package/dist/cli/commands/manager/index.js.map +1 -1
- package/dist/cli/commands/manager/manager-utils.d.ts +9 -0
- package/dist/cli/commands/manager/manager-utils.d.ts.map +1 -0
- package/dist/cli/commands/manager/manager-utils.js +49 -0
- package/dist/cli/commands/manager/manager-utils.js.map +1 -0
- package/dist/cli/commands/manager/pr-sync-orchestrator.d.ts +7 -0
- package/dist/cli/commands/manager/pr-sync-orchestrator.d.ts.map +1 -0
- package/dist/cli/commands/manager/pr-sync-orchestrator.js +537 -0
- package/dist/cli/commands/manager/pr-sync-orchestrator.js.map +1 -0
- package/dist/cli/commands/manager/qa-review-handler.d.ts +15 -0
- package/dist/cli/commands/manager/qa-review-handler.d.ts.map +1 -0
- package/dist/cli/commands/manager/qa-review-handler.js +290 -0
- package/dist/cli/commands/manager/qa-review-handler.js.map +1 -0
- package/dist/cli/commands/manager/stuck-story-helpers.d.ts +32 -0
- package/dist/cli/commands/manager/stuck-story-helpers.d.ts.map +1 -0
- package/dist/cli/commands/manager/stuck-story-helpers.js +163 -0
- package/dist/cli/commands/manager/stuck-story-helpers.js.map +1 -0
- package/dist/cli/commands/manager/stuck-story-processor.d.ts +8 -0
- package/dist/cli/commands/manager/stuck-story-processor.d.ts.map +1 -0
- package/dist/cli/commands/manager/stuck-story-processor.js +392 -0
- package/dist/cli/commands/manager/stuck-story-processor.js.map +1 -0
- package/dist/cli/commands/manager/tech-lead-lifecycle.d.ts +3 -0
- package/dist/cli/commands/manager/tech-lead-lifecycle.d.ts.map +1 -0
- package/dist/cli/commands/manager/tech-lead-lifecycle.js +141 -0
- package/dist/cli/commands/manager/tech-lead-lifecycle.js.map +1 -0
- package/dist/cli/commands/my-stories.d.ts.map +1 -1
- package/dist/cli/commands/my-stories.js +5 -20
- package/dist/cli/commands/my-stories.js.map +1 -1
- package/dist/cli/commands/pr.js +7 -22
- package/dist/cli/commands/pr.js.map +1 -1
- package/dist/cli/commands/progress.d.ts.map +1 -1
- package/dist/cli/commands/progress.js +2 -5
- package/dist/cli/commands/progress.js.map +1 -1
- package/dist/cli/commands/resume.d.ts.map +1 -1
- package/dist/cli/commands/resume.js +3 -6
- package/dist/cli/commands/resume.js.map +1 -1
- package/dist/cli/commands/status.d.ts.map +1 -1
- package/dist/cli/commands/status.js +2 -5
- package/dist/cli/commands/status.js.map +1 -1
- package/dist/cli/commands/stories.d.ts.map +1 -1
- package/dist/cli/commands/stories.js +2 -5
- package/dist/cli/commands/stories.js.map +1 -1
- package/dist/cluster/adapters.d.ts +3 -2
- package/dist/cluster/adapters.d.ts.map +1 -1
- package/dist/cluster/adapters.js +2 -11
- package/dist/cluster/adapters.js.map +1 -1
- package/dist/cluster/cluster-http-server.d.ts +20 -0
- package/dist/cluster/cluster-http-server.d.ts.map +1 -0
- package/dist/cluster/cluster-http-server.js +140 -0
- package/dist/cluster/cluster-http-server.js.map +1 -0
- package/dist/cluster/heartbeat-manager.d.ts +24 -0
- package/dist/cluster/heartbeat-manager.d.ts.map +1 -0
- package/dist/cluster/heartbeat-manager.js +74 -0
- package/dist/cluster/heartbeat-manager.js.map +1 -0
- package/dist/cluster/raft-state-machine.d.ts +48 -0
- package/dist/cluster/raft-state-machine.d.ts.map +1 -0
- package/dist/cluster/raft-state-machine.js +207 -0
- package/dist/cluster/raft-state-machine.js.map +1 -0
- package/dist/cluster/runtime.d.ts +5 -29
- package/dist/cluster/runtime.d.ts.map +1 -1
- package/dist/cluster/runtime.js +58 -406
- package/dist/cluster/runtime.js.map +1 -1
- package/dist/integrations/jira/sync.d.ts +2 -5
- package/dist/integrations/jira/sync.d.ts.map +1 -1
- package/dist/integrations/jira/sync.js +116 -178
- package/dist/integrations/jira/sync.js.map +1 -1
- package/dist/utils/cli-helpers.d.ts +19 -0
- package/dist/utils/cli-helpers.d.ts.map +1 -0
- package/dist/utils/cli-helpers.js +51 -0
- package/dist/utils/cli-helpers.js.map +1 -0
- package/dist/utils/cli-helpers.test.d.ts +2 -0
- package/dist/utils/cli-helpers.test.d.ts.map +1 -0
- package/dist/utils/cli-helpers.test.js +100 -0
- package/dist/utils/cli-helpers.test.js.map +1 -0
- package/dist/utils/github-cli.d.ts +3 -0
- package/dist/utils/github-cli.d.ts.map +1 -0
- package/dist/utils/github-cli.js +4 -0
- package/dist/utils/github-cli.js.map +1 -0
- package/dist/utils/pr-sync.d.ts.map +1 -1
- package/dist/utils/pr-sync.js +1 -2
- package/dist/utils/pr-sync.js.map +1 -1
- package/dist/utils/story-status.d.ts +19 -0
- package/dist/utils/story-status.d.ts.map +1 -0
- package/dist/utils/story-status.js +58 -0
- package/dist/utils/story-status.js.map +1 -0
- package/dist/utils/story-status.test.d.ts +2 -0
- package/dist/utils/story-status.test.d.ts.map +1 -0
- package/dist/utils/story-status.test.js +65 -0
- package/dist/utils/story-status.test.js.map +1 -0
- package/package.json +1 -1
- package/src/cli/commands/agents.ts +3 -11
- package/src/cli/commands/approach.ts +2 -7
- package/src/cli/commands/init.test.ts +4 -0
- package/src/cli/commands/init.ts +9 -0
- package/src/cli/commands/manager/index.ts +166 -2236
- package/src/cli/commands/manager/manager-utils.ts +85 -0
- package/src/cli/commands/manager/pr-sync-orchestrator.ts +659 -0
- package/src/cli/commands/manager/qa-review-handler.ts +399 -0
- package/src/cli/commands/manager/stuck-story-helpers.ts +255 -0
- package/src/cli/commands/manager/stuck-story-processor.ts +604 -0
- package/src/cli/commands/manager/tech-lead-lifecycle.ts +210 -0
- package/src/cli/commands/my-stories.ts +5 -30
- package/src/cli/commands/pr.ts +6 -22
- package/src/cli/commands/progress.ts +2 -7
- package/src/cli/commands/resume.ts +3 -6
- package/src/cli/commands/status.ts +2 -5
- package/src/cli/commands/stories.ts +2 -5
- package/src/cluster/adapters.ts +3 -12
- package/src/cluster/cluster-http-server.ts +187 -0
- package/src/cluster/heartbeat-manager.ts +112 -0
- package/src/cluster/raft-state-machine.ts +267 -0
- package/src/cluster/runtime.ts +71 -515
- package/src/integrations/jira/sync.ts +157 -215
- package/src/utils/cli-helpers.test.ts +138 -0
- package/src/utils/cli-helpers.ts +61 -0
- package/src/utils/github-cli.ts +4 -0
- package/src/utils/pr-sync.ts +1 -3
- package/src/utils/story-status.test.ts +74 -0
- package/src/utils/story-status.ts +62 -0
package/src/cluster/runtime.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
114
|
-
this.
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
this.
|
|
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
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
195
|
-
|
|
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(
|
|
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
|
|
649
|
-
if (!this.
|
|
650
|
-
|
|
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
|
-
|
|
653
|
-
|
|
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;
|