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.
- package/dist/cli/commands/cluster.d.ts.map +1 -1
- package/dist/cli/commands/cluster.js +348 -1
- package/dist/cli/commands/cluster.js.map +1 -1
- package/dist/cli/commands/cluster.test.js +313 -9
- package/dist/cli/commands/cluster.test.js.map +1 -1
- package/dist/cli/commands/req-spawn.test.d.ts +2 -0
- package/dist/cli/commands/req-spawn.test.d.ts.map +1 -0
- package/dist/cli/commands/req-spawn.test.js +116 -0
- package/dist/cli/commands/req-spawn.test.js.map +1 -0
- package/dist/cli/commands/req.d.ts.map +1 -1
- package/dist/cli/commands/req.js +21 -13
- package/dist/cli/commands/req.js.map +1 -1
- package/dist/cluster/cluster-http-server.d.ts +32 -0
- package/dist/cluster/cluster-http-server.d.ts.map +1 -1
- package/dist/cluster/cluster-http-server.js +42 -0
- package/dist/cluster/cluster-http-server.js.map +1 -1
- package/dist/cluster/distributed-runtime-coverage.test.js +9 -0
- package/dist/cluster/distributed-runtime-coverage.test.js.map +1 -1
- package/dist/cluster/distributed-system.test.js +135 -0
- package/dist/cluster/distributed-system.test.js.map +1 -1
- package/dist/cluster/events.d.ts +23 -0
- package/dist/cluster/events.d.ts.map +1 -1
- package/dist/cluster/events.js +74 -0
- package/dist/cluster/events.js.map +1 -1
- package/dist/cluster/heartbeat-manager.d.ts +2 -0
- package/dist/cluster/heartbeat-manager.d.ts.map +1 -1
- package/dist/cluster/heartbeat-manager.js +42 -6
- package/dist/cluster/heartbeat-manager.js.map +1 -1
- package/dist/cluster/membership.test.d.ts +2 -0
- package/dist/cluster/membership.test.d.ts.map +1 -0
- package/dist/cluster/membership.test.js +416 -0
- package/dist/cluster/membership.test.js.map +1 -0
- package/dist/cluster/partition-safety.test.d.ts +2 -0
- package/dist/cluster/partition-safety.test.d.ts.map +1 -0
- package/dist/cluster/partition-safety.test.js +440 -0
- package/dist/cluster/partition-safety.test.js.map +1 -0
- package/dist/cluster/raft-state-machine.d.ts +33 -1
- package/dist/cluster/raft-state-machine.d.ts.map +1 -1
- package/dist/cluster/raft-state-machine.js +65 -3
- package/dist/cluster/raft-state-machine.js.map +1 -1
- package/dist/cluster/raft-store.d.ts +26 -1
- package/dist/cluster/raft-store.d.ts.map +1 -1
- package/dist/cluster/raft-store.js +137 -0
- package/dist/cluster/raft-store.js.map +1 -1
- package/dist/cluster/replication-lag.test.d.ts +2 -0
- package/dist/cluster/replication-lag.test.d.ts.map +1 -0
- package/dist/cluster/replication-lag.test.js +239 -0
- package/dist/cluster/replication-lag.test.js.map +1 -0
- package/dist/cluster/replication.d.ts +2 -2
- package/dist/cluster/replication.d.ts.map +1 -1
- package/dist/cluster/replication.js +1 -1
- package/dist/cluster/replication.js.map +1 -1
- package/dist/cluster/runtime.d.ts +78 -0
- package/dist/cluster/runtime.d.ts.map +1 -1
- package/dist/cluster/runtime.js +400 -13
- package/dist/cluster/runtime.js.map +1 -1
- package/dist/cluster/state-recovery.test.d.ts +2 -0
- package/dist/cluster/state-recovery.test.d.ts.map +1 -0
- package/dist/cluster/state-recovery.test.js +310 -0
- package/dist/cluster/state-recovery.test.js.map +1 -0
- package/dist/cluster/types.d.ts +30 -0
- package/dist/cluster/types.d.ts.map +1 -1
- package/dist/config/schema.d.ts +48 -0
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +11 -0
- package/dist/config/schema.js.map +1 -1
- package/dist/context-files/generator.js +1 -1
- package/dist/context-files/generator.js.map +1 -1
- package/dist/context-files/generator.test.js +51 -0
- package/dist/context-files/generator.test.js.map +1 -1
- package/dist/orchestrator/orphan-recovery.d.ts +1 -1
- package/dist/orchestrator/orphan-recovery.d.ts.map +1 -1
- package/dist/orchestrator/orphan-recovery.js +4 -4
- package/dist/orchestrator/orphan-recovery.js.map +1 -1
- package/dist/orchestrator/prompt-templates.d.ts +3 -1
- package/dist/orchestrator/prompt-templates.d.ts.map +1 -1
- package/dist/orchestrator/prompt-templates.js +45 -8
- package/dist/orchestrator/prompt-templates.js.map +1 -1
- package/dist/orchestrator/prompt-templates.test.js +210 -0
- package/dist/orchestrator/prompt-templates.test.js.map +1 -1
- package/dist/orchestrator/scheduler.d.ts +1 -0
- package/dist/orchestrator/scheduler.d.ts.map +1 -1
- package/dist/orchestrator/scheduler.js +15 -10
- package/dist/orchestrator/scheduler.js.map +1 -1
- package/dist/orchestrator/scheduler.test.js +97 -6
- package/dist/orchestrator/scheduler.test.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/commands/cluster.test.ts +387 -9
- package/src/cli/commands/cluster.ts +486 -1
- package/src/cli/commands/req-spawn.test.ts +153 -0
- package/src/cli/commands/req.ts +31 -18
- package/src/cluster/cluster-http-server.ts +80 -0
- package/src/cluster/distributed-runtime-coverage.test.ts +9 -0
- package/src/cluster/distributed-system.test.ts +168 -0
- package/src/cluster/events.ts +90 -0
- package/src/cluster/heartbeat-manager.ts +48 -6
- package/src/cluster/membership.test.ts +498 -0
- package/src/cluster/partition-safety.test.ts +523 -0
- package/src/cluster/raft-state-machine.ts +76 -4
- package/src/cluster/raft-store.ts +167 -1
- package/src/cluster/replication-lag.test.ts +284 -0
- package/src/cluster/replication.ts +6 -0
- package/src/cluster/runtime.ts +551 -12
- package/src/cluster/state-recovery.test.ts +420 -0
- package/src/cluster/types.ts +32 -0
- package/src/config/schema.ts +11 -0
- package/src/context-files/generator.test.ts +55 -0
- package/src/context-files/generator.ts +5 -5
- package/src/orchestrator/orphan-recovery.ts +32 -13
- package/src/orchestrator/prompt-templates.test.ts +263 -0
- package/src/orchestrator/prompt-templates.ts +49 -8
- package/src/orchestrator/scheduler.test.ts +129 -6
- package/src/orchestrator/scheduler.ts +46 -20
|
@@ -2,7 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
import chalk from 'chalk';
|
|
4
4
|
import { Command } from 'commander';
|
|
5
|
-
import {
|
|
5
|
+
import type {
|
|
6
|
+
MembershipJoinResponse,
|
|
7
|
+
MembershipLeaveResponse,
|
|
8
|
+
} from '../../cluster/cluster-http-server.js';
|
|
9
|
+
import {
|
|
10
|
+
fetchClusterStatusFromUrl,
|
|
11
|
+
fetchLocalClusterEvents,
|
|
12
|
+
fetchLocalClusterStatus,
|
|
13
|
+
fetchReplicationLag,
|
|
14
|
+
postToLocalCluster,
|
|
15
|
+
postToPeerCluster,
|
|
16
|
+
} from '../../cluster/runtime.js';
|
|
6
17
|
import { loadConfig } from '../../config/loader.js';
|
|
7
18
|
import { findHiveRoot, getHivePaths } from '../../utils/paths.js';
|
|
8
19
|
|
|
@@ -13,6 +24,15 @@ interface PeerStatus {
|
|
|
13
24
|
status: Awaited<ReturnType<typeof fetchLocalClusterStatus>>;
|
|
14
25
|
}
|
|
15
26
|
|
|
27
|
+
interface PeerHealth {
|
|
28
|
+
id: string;
|
|
29
|
+
url: string;
|
|
30
|
+
reachable: boolean;
|
|
31
|
+
latencyMs: number | null;
|
|
32
|
+
role: string | null;
|
|
33
|
+
term: number | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
16
36
|
export const clusterCommand = new Command('cluster').description('Distributed cluster operations');
|
|
17
37
|
|
|
18
38
|
clusterCommand
|
|
@@ -122,5 +142,470 @@ clusterCommand
|
|
|
122
142
|
);
|
|
123
143
|
}
|
|
124
144
|
|
|
145
|
+
// Fetch and display replication lag
|
|
146
|
+
const lagSummary = await fetchReplicationLag(config.cluster);
|
|
147
|
+
if (lagSummary && lagSummary.peers.length > 0) {
|
|
148
|
+
console.log(chalk.bold('\nReplication Lag'));
|
|
149
|
+
if (lagSummary.last_sync_at) {
|
|
150
|
+
console.log(chalk.gray(`Last sync: ${lagSummary.last_sync_at}`));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
for (const peer of lagSummary.peers) {
|
|
154
|
+
if (!peer.reachable) {
|
|
155
|
+
console.log(
|
|
156
|
+
` ${chalk.red('UNREACHABLE')} ${peer.peer_id} ${chalk.gray(`(last sync: ${peer.last_sync_at || 'never'})`)}`
|
|
157
|
+
);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const lagColor =
|
|
162
|
+
peer.events_behind === 0
|
|
163
|
+
? chalk.green
|
|
164
|
+
: peer.events_behind > 100
|
|
165
|
+
? chalk.red
|
|
166
|
+
: chalk.yellow;
|
|
167
|
+
const lagLabel =
|
|
168
|
+
peer.events_behind === 0 ? 'IN_SYNC' : `${peer.events_behind} events behind`;
|
|
169
|
+
const duration =
|
|
170
|
+
peer.last_sync_duration_ms !== null ? ` ${peer.last_sync_duration_ms}ms` : '';
|
|
171
|
+
console.log(
|
|
172
|
+
` ${lagColor(lagLabel)} ${peer.peer_id} applied=${peer.last_sync_events_applied}${duration}`
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
console.log();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
clusterCommand
|
|
181
|
+
.command('replication-lag')
|
|
182
|
+
.description('Show per-peer replication lag details')
|
|
183
|
+
.option('--json', 'Output as JSON')
|
|
184
|
+
.action(async (options: { json?: boolean }) => {
|
|
185
|
+
const root = findHiveRoot();
|
|
186
|
+
if (!root) {
|
|
187
|
+
console.error(chalk.red('Not in a Hive workspace.'));
|
|
188
|
+
process.exit(1);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const paths = getHivePaths(root);
|
|
192
|
+
const config = loadConfig(paths.hiveDir);
|
|
193
|
+
|
|
194
|
+
if (!config.cluster.enabled) {
|
|
195
|
+
if (options.json) {
|
|
196
|
+
console.log(JSON.stringify({ enabled: false }, null, 2));
|
|
197
|
+
} else {
|
|
198
|
+
console.log(chalk.yellow('Cluster mode is disabled.'));
|
|
199
|
+
}
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const lagSummary = await fetchReplicationLag(config.cluster);
|
|
204
|
+
|
|
205
|
+
if (!lagSummary) {
|
|
206
|
+
if (options.json) {
|
|
207
|
+
console.log(
|
|
208
|
+
JSON.stringify({ error: 'Unable to fetch replication lag from local runtime' }, null, 2)
|
|
209
|
+
);
|
|
210
|
+
} else {
|
|
211
|
+
console.log(chalk.red('Unable to fetch replication lag from local runtime.'));
|
|
212
|
+
console.log(chalk.gray('Start it with: hive manager start'));
|
|
213
|
+
}
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (options.json) {
|
|
218
|
+
console.log(JSON.stringify(lagSummary, null, 2));
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
console.log(chalk.bold('\nReplication Lag Summary\n'));
|
|
223
|
+
console.log(chalk.gray(`Node: ${lagSummary.node_id}`));
|
|
224
|
+
console.log(chalk.gray(`Local events: ${lagSummary.total_local_events}`));
|
|
225
|
+
console.log(chalk.gray(`Last sync: ${lagSummary.last_sync_at || 'never'}`));
|
|
226
|
+
|
|
227
|
+
if (Object.keys(lagSummary.version_vector).length > 0) {
|
|
228
|
+
console.log(chalk.bold('\nVersion Vector'));
|
|
229
|
+
for (const [actor, counter] of Object.entries(lagSummary.version_vector)) {
|
|
230
|
+
console.log(chalk.gray(` ${actor}: ${counter}`));
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
console.log(chalk.bold('\nPeer Lag'));
|
|
235
|
+
if (lagSummary.peers.length === 0) {
|
|
236
|
+
console.log(chalk.gray(' No peers configured.'));
|
|
237
|
+
} else {
|
|
238
|
+
for (const peer of lagSummary.peers) {
|
|
239
|
+
if (!peer.reachable) {
|
|
240
|
+
console.log(` ${chalk.red('UNREACHABLE')} ${peer.peer_id} ${chalk.gray(peer.peer_url)}`);
|
|
241
|
+
console.log(chalk.gray(` Last sync: ${peer.last_sync_at || 'never'}`));
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const lagColor =
|
|
246
|
+
peer.events_behind === 0
|
|
247
|
+
? chalk.green
|
|
248
|
+
: peer.events_behind > 100
|
|
249
|
+
? chalk.red
|
|
250
|
+
: chalk.yellow;
|
|
251
|
+
const lagLabel =
|
|
252
|
+
peer.events_behind === 0 ? 'IN_SYNC' : `${peer.events_behind} events behind`;
|
|
253
|
+
console.log(` ${lagColor(lagLabel)} ${peer.peer_id} ${chalk.gray(peer.peer_url)}`);
|
|
254
|
+
console.log(
|
|
255
|
+
chalk.gray(
|
|
256
|
+
` applied=${peer.last_sync_events_applied} duration=${peer.last_sync_duration_ms ?? '-'}ms last_sync=${peer.last_sync_at || 'never'}`
|
|
257
|
+
)
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
console.log();
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
clusterCommand
|
|
266
|
+
.command('health')
|
|
267
|
+
.description('Check connectivity and latency to all cluster peers')
|
|
268
|
+
.option('--json', 'Output as JSON')
|
|
269
|
+
.action(async (options: { json?: boolean }) => {
|
|
270
|
+
const root = findHiveRoot();
|
|
271
|
+
if (!root) {
|
|
272
|
+
console.error(chalk.red('Not in a Hive workspace.'));
|
|
273
|
+
process.exit(1);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const paths = getHivePaths(root);
|
|
277
|
+
const config = loadConfig(paths.hiveDir);
|
|
278
|
+
|
|
279
|
+
if (!config.cluster.enabled) {
|
|
280
|
+
if (options.json) {
|
|
281
|
+
console.log(JSON.stringify({ enabled: false }, null, 2));
|
|
282
|
+
} else {
|
|
283
|
+
console.log(chalk.yellow('Cluster mode is disabled.'));
|
|
284
|
+
}
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const peerHealthResults: PeerHealth[] = await Promise.all(
|
|
289
|
+
config.cluster.peers.map(async peer => {
|
|
290
|
+
const start = Date.now();
|
|
291
|
+
const status = await fetchClusterStatusFromUrl(
|
|
292
|
+
`${peer.url.replace(/\/$/, '')}/cluster/v1/status`,
|
|
293
|
+
{
|
|
294
|
+
authToken: config.cluster.auth_token,
|
|
295
|
+
timeoutMs: config.cluster.request_timeout_ms,
|
|
296
|
+
}
|
|
297
|
+
);
|
|
298
|
+
const latencyMs = status ? Date.now() - start : null;
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
id: peer.id,
|
|
302
|
+
url: peer.url,
|
|
303
|
+
reachable: status !== null,
|
|
304
|
+
latencyMs,
|
|
305
|
+
role: status?.role ?? null,
|
|
306
|
+
term: status?.term ?? null,
|
|
307
|
+
};
|
|
308
|
+
})
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
// Also check self
|
|
312
|
+
const selfStart = Date.now();
|
|
313
|
+
const selfStatus = await fetchLocalClusterStatus(config.cluster);
|
|
314
|
+
const selfLatencyMs = selfStatus ? Date.now() - selfStart : null;
|
|
315
|
+
|
|
316
|
+
const selfHealth: PeerHealth = {
|
|
317
|
+
id: config.cluster.node_id,
|
|
318
|
+
url: config.cluster.public_url,
|
|
319
|
+
reachable: selfStatus !== null,
|
|
320
|
+
latencyMs: selfLatencyMs,
|
|
321
|
+
role: selfStatus?.role ?? null,
|
|
322
|
+
term: selfStatus?.term ?? null,
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
const allNodes = [selfHealth, ...peerHealthResults];
|
|
326
|
+
const reachableCount = allNodes.filter(n => n.reachable).length;
|
|
327
|
+
|
|
328
|
+
if (options.json) {
|
|
329
|
+
console.log(
|
|
330
|
+
JSON.stringify(
|
|
331
|
+
{
|
|
332
|
+
enabled: true,
|
|
333
|
+
total_nodes: allNodes.length,
|
|
334
|
+
reachable: reachableCount,
|
|
335
|
+
nodes: allNodes,
|
|
336
|
+
},
|
|
337
|
+
null,
|
|
338
|
+
2
|
|
339
|
+
)
|
|
340
|
+
);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
console.log(chalk.bold('\nCluster Health\n'));
|
|
345
|
+
console.log(chalk.gray(`${reachableCount}/${allNodes.length} nodes reachable`));
|
|
346
|
+
|
|
347
|
+
for (const node of allNodes) {
|
|
348
|
+
const isSelf = node.id === config.cluster.node_id;
|
|
349
|
+
const label = isSelf ? chalk.cyan('(self)') : '';
|
|
350
|
+
|
|
351
|
+
if (!node.reachable) {
|
|
352
|
+
console.log(` ${chalk.red('✗')} ${node.id} ${label} ${chalk.gray(node.url)}`);
|
|
353
|
+
console.log(` ${chalk.red('UNREACHABLE')}`);
|
|
354
|
+
} else {
|
|
355
|
+
const latency = node.latencyMs !== null ? `${node.latencyMs}ms` : '?';
|
|
356
|
+
const roleLabel = node.role?.toUpperCase() ?? 'UNKNOWN';
|
|
357
|
+
console.log(` ${chalk.green('✓')} ${node.id} ${label} ${chalk.gray(node.url)}`);
|
|
358
|
+
console.log(
|
|
359
|
+
` ${chalk.gray(roleLabel)} term=${node.term ?? '?'} latency=${chalk.cyan(latency)}`
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
125
364
|
console.log();
|
|
126
365
|
});
|
|
366
|
+
|
|
367
|
+
clusterCommand
|
|
368
|
+
.command('events')
|
|
369
|
+
.description('Show recent cluster replication events')
|
|
370
|
+
.option('--limit <n>', 'Maximum number of events to show', '50')
|
|
371
|
+
.option('--table <name>', 'Filter by table name')
|
|
372
|
+
.option('--json', 'Output as JSON')
|
|
373
|
+
.action(async (options: { limit?: string; table?: string; json?: boolean }) => {
|
|
374
|
+
const root = findHiveRoot();
|
|
375
|
+
if (!root) {
|
|
376
|
+
console.error(chalk.red('Not in a Hive workspace.'));
|
|
377
|
+
process.exit(1);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const paths = getHivePaths(root);
|
|
381
|
+
const config = loadConfig(paths.hiveDir);
|
|
382
|
+
|
|
383
|
+
if (!config.cluster.enabled) {
|
|
384
|
+
if (options.json) {
|
|
385
|
+
console.log(JSON.stringify({ enabled: false, events: [] }, null, 2));
|
|
386
|
+
} else {
|
|
387
|
+
console.log(chalk.yellow('Cluster mode is disabled.'));
|
|
388
|
+
}
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const limit = Math.max(1, Math.min(1000, parseInt(options.limit ?? '50', 10) || 50));
|
|
393
|
+
const events = await fetchLocalClusterEvents(config.cluster, limit);
|
|
394
|
+
|
|
395
|
+
if (!events) {
|
|
396
|
+
if (options.json) {
|
|
397
|
+
console.log(JSON.stringify({ error: 'Local cluster runtime is not reachable.' }, null, 2));
|
|
398
|
+
} else {
|
|
399
|
+
console.error(chalk.red('Local cluster runtime is not reachable.'));
|
|
400
|
+
console.log(chalk.gray('Start it with: hive manager start'));
|
|
401
|
+
}
|
|
402
|
+
process.exit(1);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const filtered = options.table ? events.filter(e => e.table_name === options.table) : events;
|
|
406
|
+
|
|
407
|
+
if (options.json) {
|
|
408
|
+
console.log(
|
|
409
|
+
JSON.stringify({ enabled: true, total: filtered.length, events: filtered }, null, 2)
|
|
410
|
+
);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
console.log(chalk.bold(`\nCluster Events (${filtered.length})\n`));
|
|
415
|
+
|
|
416
|
+
if (filtered.length === 0) {
|
|
417
|
+
console.log(chalk.gray('No events found.'));
|
|
418
|
+
console.log();
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
for (const event of filtered) {
|
|
423
|
+
const opColor = event.op === 'upsert' ? chalk.green : chalk.red;
|
|
424
|
+
const ts = new Date(event.created_at).toLocaleString();
|
|
425
|
+
console.log(
|
|
426
|
+
`${chalk.gray(ts)} ${opColor(event.op.toUpperCase())} ${chalk.cyan(event.table_name)} ${event.row_id}`
|
|
427
|
+
);
|
|
428
|
+
console.log(
|
|
429
|
+
chalk.gray(
|
|
430
|
+
` actor=${event.version.actor_id} counter=${event.version.actor_counter} logical_ts=${event.version.logical_ts}`
|
|
431
|
+
)
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
console.log();
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
clusterCommand
|
|
439
|
+
.command('join')
|
|
440
|
+
.description('Join this node to an existing cluster via a peer URL')
|
|
441
|
+
.argument('<peer-url>', 'URL of an existing cluster node (e.g. http://10.0.0.2:8787)')
|
|
442
|
+
.option('--json', 'Output as JSON')
|
|
443
|
+
.action(async (peerUrl: string, options: { json?: boolean }) => {
|
|
444
|
+
const root = findHiveRoot();
|
|
445
|
+
if (!root) {
|
|
446
|
+
console.error(chalk.red('Not in a Hive workspace.'));
|
|
447
|
+
process.exit(1);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const paths = getHivePaths(root);
|
|
451
|
+
const config = loadConfig(paths.hiveDir);
|
|
452
|
+
|
|
453
|
+
if (!config.cluster.enabled) {
|
|
454
|
+
console.error(chalk.red('Cluster mode is disabled.'));
|
|
455
|
+
console.log(chalk.gray('Set `cluster.enabled: true` in `.hive/hive.config.yaml` to enable.'));
|
|
456
|
+
process.exit(1);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const response = await postToPeerCluster<MembershipJoinResponse>(
|
|
460
|
+
peerUrl,
|
|
461
|
+
'/cluster/v1/membership/join',
|
|
462
|
+
{ node_id: config.cluster.node_id, url: config.cluster.public_url },
|
|
463
|
+
{ authToken: config.cluster.auth_token, timeoutMs: config.cluster.request_timeout_ms }
|
|
464
|
+
);
|
|
465
|
+
|
|
466
|
+
if (!response) {
|
|
467
|
+
if (options.json) {
|
|
468
|
+
console.log(JSON.stringify({ success: false, error: 'Peer unreachable.' }, null, 2));
|
|
469
|
+
} else {
|
|
470
|
+
console.error(chalk.red(`Failed to reach peer at ${peerUrl}.`));
|
|
471
|
+
}
|
|
472
|
+
process.exit(1);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (!response.success && response.leader_url) {
|
|
476
|
+
// Peer redirected us to the leader
|
|
477
|
+
const leaderResponse = await postToPeerCluster<MembershipJoinResponse>(
|
|
478
|
+
response.leader_url,
|
|
479
|
+
'/cluster/v1/membership/join',
|
|
480
|
+
{ node_id: config.cluster.node_id, url: config.cluster.public_url },
|
|
481
|
+
{ authToken: config.cluster.auth_token, timeoutMs: config.cluster.request_timeout_ms }
|
|
482
|
+
);
|
|
483
|
+
|
|
484
|
+
if (options.json) {
|
|
485
|
+
console.log(
|
|
486
|
+
JSON.stringify(
|
|
487
|
+
leaderResponse ?? { success: false, error: 'Leader unreachable.' },
|
|
488
|
+
null,
|
|
489
|
+
2
|
|
490
|
+
)
|
|
491
|
+
);
|
|
492
|
+
} else if (leaderResponse?.success) {
|
|
493
|
+
console.log(chalk.green(`✓ Joined cluster via leader ${response.leader_id ?? 'unknown'}.`));
|
|
494
|
+
console.log(chalk.gray(`Peers: ${leaderResponse.peers.map(p => p.id).join(', ')}`));
|
|
495
|
+
} else {
|
|
496
|
+
console.error(chalk.red('Failed to join cluster via leader.'));
|
|
497
|
+
process.exit(1);
|
|
498
|
+
}
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
if (options.json) {
|
|
503
|
+
console.log(JSON.stringify(response, null, 2));
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if (response.success) {
|
|
508
|
+
console.log(chalk.green(`✓ Joined cluster.`));
|
|
509
|
+
console.log(chalk.gray(`Leader: ${response.leader_id ?? 'unknown'}`));
|
|
510
|
+
console.log(chalk.gray(`Peers: ${response.peers.map(p => p.id).join(', ')}`));
|
|
511
|
+
} else {
|
|
512
|
+
console.error(chalk.red('Failed to join cluster (node not leader and no leader available).'));
|
|
513
|
+
process.exit(1);
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
clusterCommand
|
|
518
|
+
.command('leave')
|
|
519
|
+
.description('Gracefully remove this node from the cluster')
|
|
520
|
+
.option('--json', 'Output as JSON')
|
|
521
|
+
.action(async (options: { json?: boolean }) => {
|
|
522
|
+
const root = findHiveRoot();
|
|
523
|
+
if (!root) {
|
|
524
|
+
console.error(chalk.red('Not in a Hive workspace.'));
|
|
525
|
+
process.exit(1);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const paths = getHivePaths(root);
|
|
529
|
+
const config = loadConfig(paths.hiveDir);
|
|
530
|
+
|
|
531
|
+
if (!config.cluster.enabled) {
|
|
532
|
+
console.error(chalk.red('Cluster mode is disabled.'));
|
|
533
|
+
process.exit(1);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// First get local status to find the leader
|
|
537
|
+
const localStatus = await fetchLocalClusterStatus(config.cluster);
|
|
538
|
+
|
|
539
|
+
if (!localStatus) {
|
|
540
|
+
if (options.json) {
|
|
541
|
+
console.log(
|
|
542
|
+
JSON.stringify(
|
|
543
|
+
{ success: false, error: 'Local cluster runtime is not reachable.' },
|
|
544
|
+
null,
|
|
545
|
+
2
|
|
546
|
+
)
|
|
547
|
+
);
|
|
548
|
+
} else {
|
|
549
|
+
console.error(chalk.red('Local cluster runtime is not reachable.'));
|
|
550
|
+
console.log(chalk.gray('Start it with: hive manager start'));
|
|
551
|
+
}
|
|
552
|
+
process.exit(1);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// If we are the leader, cannot leave (would need to transfer leadership first)
|
|
556
|
+
if (localStatus.is_leader) {
|
|
557
|
+
if (options.json) {
|
|
558
|
+
console.log(
|
|
559
|
+
JSON.stringify(
|
|
560
|
+
{
|
|
561
|
+
success: false,
|
|
562
|
+
error: 'This node is the leader. Transfer leadership before leaving.',
|
|
563
|
+
},
|
|
564
|
+
null,
|
|
565
|
+
2
|
|
566
|
+
)
|
|
567
|
+
);
|
|
568
|
+
} else {
|
|
569
|
+
console.error(chalk.red('This node is the current leader and cannot leave directly.'));
|
|
570
|
+
console.log(chalk.gray('Wait for a new leader to be elected, then retry.'));
|
|
571
|
+
}
|
|
572
|
+
process.exit(1);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// POST leave to the local runtime (which will forward to leader or handle directly)
|
|
576
|
+
const response = await postToLocalCluster<MembershipLeaveResponse>(
|
|
577
|
+
config.cluster,
|
|
578
|
+
'/cluster/v1/membership/leave',
|
|
579
|
+
{ node_id: config.cluster.node_id }
|
|
580
|
+
);
|
|
581
|
+
|
|
582
|
+
if (!response) {
|
|
583
|
+
if (options.json) {
|
|
584
|
+
console.log(
|
|
585
|
+
JSON.stringify(
|
|
586
|
+
{ success: false, error: 'Failed to contact local cluster runtime.' },
|
|
587
|
+
null,
|
|
588
|
+
2
|
|
589
|
+
)
|
|
590
|
+
);
|
|
591
|
+
} else {
|
|
592
|
+
console.error(chalk.red('Failed to contact local cluster runtime.'));
|
|
593
|
+
}
|
|
594
|
+
process.exit(1);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
if (options.json) {
|
|
598
|
+
console.log(JSON.stringify(response, null, 2));
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
if (response.success) {
|
|
603
|
+
console.log(chalk.green(`✓ Left cluster successfully.`));
|
|
604
|
+
console.log(
|
|
605
|
+
chalk.gray(`Remaining peers: ${response.peers.map(p => p.id).join(', ') || 'none'}`)
|
|
606
|
+
);
|
|
607
|
+
} else {
|
|
608
|
+
console.error(chalk.red('Failed to leave cluster.'));
|
|
609
|
+
process.exit(1);
|
|
610
|
+
}
|
|
611
|
+
});
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// Licensed under the Hungry Ghost Hive License. See LICENSE.
|
|
2
|
+
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
|
|
5
|
+
// Mock all external dependencies so we can exercise the session-routing logic
|
|
6
|
+
// without a real filesystem, database, or tmux.
|
|
7
|
+
|
|
8
|
+
vi.mock('../../cli-runtimes/index.js', () => ({
|
|
9
|
+
getCliRuntimeBuilder: vi.fn(() => ({
|
|
10
|
+
buildSpawnCommand: vi.fn(() => ['claude', '--dangerously-skip-permissions']),
|
|
11
|
+
})),
|
|
12
|
+
resolveRuntimeModelForCli: vi.fn(model => model),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
vi.mock('../../cluster/runtime.js', () => ({
|
|
16
|
+
fetchLocalClusterStatus: vi.fn(() => null),
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
vi.mock('../../config/loader.js', () => ({
|
|
20
|
+
loadConfig: vi.fn(() => ({
|
|
21
|
+
models: {
|
|
22
|
+
tech_lead: {
|
|
23
|
+
cli_tool: 'claude',
|
|
24
|
+
safety_mode: 'bypassPermissions',
|
|
25
|
+
model: 'claude-sonnet-4-6',
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
integrations: {
|
|
29
|
+
project_management: { provider: 'none' },
|
|
30
|
+
},
|
|
31
|
+
agents: { chrome_enabled: false },
|
|
32
|
+
cluster: { enabled: false },
|
|
33
|
+
})),
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
vi.mock('../../connectors/registry.js', () => ({
|
|
37
|
+
registry: {
|
|
38
|
+
getProjectManagement: vi.fn(() => null),
|
|
39
|
+
},
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
vi.mock('../../db/client.js', () => ({
|
|
43
|
+
withTransaction: vi.fn(async (_db: unknown, fn: () => unknown) => fn()),
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
vi.mock('../../db/queries/agents.js', () => ({
|
|
47
|
+
createAgent: vi.fn(() => ({ id: 'agent-1' })),
|
|
48
|
+
getTechLead: vi.fn(() => ({ id: 'agent-1' })),
|
|
49
|
+
updateAgent: vi.fn(),
|
|
50
|
+
}));
|
|
51
|
+
|
|
52
|
+
vi.mock('../../db/queries/logs.js', () => ({
|
|
53
|
+
createLog: vi.fn(),
|
|
54
|
+
}));
|
|
55
|
+
|
|
56
|
+
vi.mock('../../db/queries/requirements.js', () => ({
|
|
57
|
+
createRequirement: vi.fn(() => ({ id: 'req-1', godmode: 0 })),
|
|
58
|
+
updateRequirement: vi.fn(),
|
|
59
|
+
}));
|
|
60
|
+
|
|
61
|
+
vi.mock('../../db/queries/teams.js', () => ({
|
|
62
|
+
getAllTeams: vi.fn(() => [{ id: 'team-1', name: 'alpha' }]),
|
|
63
|
+
}));
|
|
64
|
+
|
|
65
|
+
vi.mock('../../tmux/manager.js', () => ({
|
|
66
|
+
isTmuxAvailable: vi.fn(),
|
|
67
|
+
isTmuxSessionRunning: vi.fn(),
|
|
68
|
+
sendToTmuxSession: vi.fn(),
|
|
69
|
+
spawnTmuxSession: vi.fn(),
|
|
70
|
+
}));
|
|
71
|
+
|
|
72
|
+
vi.mock('../../utils/instance.js', () => ({
|
|
73
|
+
getTechLeadSessionName: vi.fn(() => 'hive-tech-lead'),
|
|
74
|
+
}));
|
|
75
|
+
|
|
76
|
+
vi.mock('../../utils/with-hive-context.js', () => ({
|
|
77
|
+
withHiveContext: vi.fn(
|
|
78
|
+
(cb: (ctx: { root: string; paths: { hiveDir: string }; db: { db: object } }) => unknown) =>
|
|
79
|
+
cb({ root: '/tmp/hive', paths: { hiveDir: '/tmp/hive/.hive' }, db: { db: {} } })
|
|
80
|
+
),
|
|
81
|
+
}));
|
|
82
|
+
|
|
83
|
+
vi.mock('../dashboard/index.js', () => ({
|
|
84
|
+
startDashboard: vi.fn(),
|
|
85
|
+
}));
|
|
86
|
+
|
|
87
|
+
// ora returns a chainable spinner stub
|
|
88
|
+
vi.mock('ora', () => ({
|
|
89
|
+
default: vi.fn(() => ({
|
|
90
|
+
start: vi.fn().mockReturnThis(),
|
|
91
|
+
succeed: vi.fn().mockReturnThis(),
|
|
92
|
+
fail: vi.fn().mockReturnThis(),
|
|
93
|
+
warn: vi.fn().mockReturnThis(),
|
|
94
|
+
text: '',
|
|
95
|
+
})),
|
|
96
|
+
}));
|
|
97
|
+
|
|
98
|
+
import * as tmuxManager from '../../tmux/manager.js';
|
|
99
|
+
import { reqCommand } from './req.js';
|
|
100
|
+
|
|
101
|
+
describe('req command - tech lead session routing', () => {
|
|
102
|
+
beforeEach(() => {
|
|
103
|
+
vi.clearAllMocks();
|
|
104
|
+
vi.mocked(tmuxManager.isTmuxAvailable).mockResolvedValue(true);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('spawns a new session when no session is running', async () => {
|
|
108
|
+
vi.mocked(tmuxManager.isTmuxSessionRunning).mockResolvedValue(false);
|
|
109
|
+
|
|
110
|
+
await reqCommand.parseAsync(['node', 'req', 'Build a new feature', '--target-branch', 'main'], {
|
|
111
|
+
from: 'user',
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
expect(tmuxManager.spawnTmuxSession).toHaveBeenCalledOnce();
|
|
115
|
+
expect(tmuxManager.sendToTmuxSession).not.toHaveBeenCalled();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('sends prompt to existing session when session is already running', async () => {
|
|
119
|
+
vi.mocked(tmuxManager.isTmuxSessionRunning).mockResolvedValue(true);
|
|
120
|
+
|
|
121
|
+
await reqCommand.parseAsync(['node', 'req', 'Add another feature', '--target-branch', 'main'], {
|
|
122
|
+
from: 'user',
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
expect(tmuxManager.sendToTmuxSession).toHaveBeenCalledOnce();
|
|
126
|
+
expect(tmuxManager.spawnTmuxSession).not.toHaveBeenCalled();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('sends prompt to existing session with the correct session name', async () => {
|
|
130
|
+
vi.mocked(tmuxManager.isTmuxSessionRunning).mockResolvedValue(true);
|
|
131
|
+
|
|
132
|
+
await reqCommand.parseAsync(['node', 'req', 'Fix the scheduler', '--target-branch', 'main'], {
|
|
133
|
+
from: 'user',
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
expect(tmuxManager.sendToTmuxSession).toHaveBeenCalledWith(
|
|
137
|
+
'hive-tech-lead',
|
|
138
|
+
expect.any(String)
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('spawns session with correct session name when session is not running', async () => {
|
|
143
|
+
vi.mocked(tmuxManager.isTmuxSessionRunning).mockResolvedValue(false);
|
|
144
|
+
|
|
145
|
+
await reqCommand.parseAsync(['node', 'req', 'Fix the scheduler', '--target-branch', 'main'], {
|
|
146
|
+
from: 'user',
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
expect(tmuxManager.spawnTmuxSession).toHaveBeenCalledWith(
|
|
150
|
+
expect.objectContaining({ sessionName: 'hive-tech-lead' })
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
});
|