hungry-ghost-hive 0.45.0 → 0.47.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/manager/index.js +6 -4
- package/dist/cli/commands/manager/index.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/cli/dashboard/index.d.ts.map +1 -1
- package/dist/cli/dashboard/index.js +14 -2
- package/dist/cli/dashboard/index.js.map +1 -1
- package/dist/cli/dashboard/panels/agents.d.ts +1 -1
- package/dist/cli/dashboard/panels/agents.d.ts.map +1 -1
- package/dist/cli/dashboard/panels/agents.js +7 -3
- package/dist/cli/dashboard/panels/agents.js.map +1 -1
- package/dist/cli/dashboard/panels/escalations.d.ts +1 -1
- package/dist/cli/dashboard/panels/escalations.d.ts.map +1 -1
- package/dist/cli/dashboard/panels/escalations.js +7 -1
- package/dist/cli/dashboard/panels/escalations.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/connectors/auth/jira.js +1 -1
- package/dist/connectors/auth/jira.js.map +1 -1
- package/dist/connectors/auth/jira.test.js +18 -0
- package/dist/connectors/auth/jira.test.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/manager/index.ts +6 -4
- package/src/cli/commands/req-spawn.test.ts +153 -0
- package/src/cli/commands/req.ts +31 -18
- package/src/cli/dashboard/index.ts +14 -2
- package/src/cli/dashboard/panels/agents.ts +12 -3
- package/src/cli/dashboard/panels/escalations.ts +12 -1
- 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/connectors/auth/jira.test.ts +21 -0
- package/src/connectors/auth/jira.ts +1 -1
- 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
|
+
});
|
|
@@ -257,7 +257,8 @@ managerCommand
|
|
|
257
257
|
.command('start')
|
|
258
258
|
.description('Start the manager daemon (runs every 60s)')
|
|
259
259
|
.option('-i, --interval <seconds>', 'Check interval in seconds', '60')
|
|
260
|
-
.option('-v, --verbose', 'Show detailed manager check logs')
|
|
260
|
+
.option('-v, --verbose', 'Show detailed manager check logs (default: true)')
|
|
261
|
+
.option('--no-verbose', 'Suppress verbose manager check logs')
|
|
261
262
|
.option('--once', 'Run once and exit')
|
|
262
263
|
.action(async (options: { interval: string; verbose?: boolean; once?: boolean }) => {
|
|
263
264
|
const { root, paths } = withHiveRoot(ctx => ctx);
|
|
@@ -310,7 +311,7 @@ managerCommand
|
|
|
310
311
|
);
|
|
311
312
|
}
|
|
312
313
|
|
|
313
|
-
const verbose = options.verbose
|
|
314
|
+
const verbose = options.verbose !== false;
|
|
314
315
|
let checkInProgress = false;
|
|
315
316
|
let checkQueued = false;
|
|
316
317
|
|
|
@@ -386,7 +387,8 @@ managerCommand
|
|
|
386
387
|
managerCommand
|
|
387
388
|
.command('check')
|
|
388
389
|
.description('Run a single manager check')
|
|
389
|
-
.option('-v, --verbose', 'Show detailed manager check logs')
|
|
390
|
+
.option('-v, --verbose', 'Show detailed manager check logs (default: true)')
|
|
391
|
+
.option('--no-verbose', 'Suppress verbose manager check logs')
|
|
390
392
|
.action(async (options: { verbose?: boolean }) => {
|
|
391
393
|
const { root, paths } = withHiveRoot(ctx => ctx);
|
|
392
394
|
const config = loadConfig(paths.hiveDir);
|
|
@@ -411,7 +413,7 @@ managerCommand
|
|
|
411
413
|
}
|
|
412
414
|
}
|
|
413
415
|
|
|
414
|
-
await managerCheck(root, config, undefined, options.verbose
|
|
416
|
+
await managerCheck(root, config, undefined, options.verbose !== false);
|
|
415
417
|
});
|
|
416
418
|
|
|
417
419
|
// Run health check to sync agents with tmux
|
|
@@ -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
|
+
});
|