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
package/src/cli/commands/req.ts
CHANGED
|
@@ -14,7 +14,12 @@ import { createAgent, getTechLead, updateAgent } from '../../db/queries/agents.j
|
|
|
14
14
|
import { createLog } from '../../db/queries/logs.js';
|
|
15
15
|
import { createRequirement, updateRequirement } from '../../db/queries/requirements.js';
|
|
16
16
|
import { getAllTeams } from '../../db/queries/teams.js';
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
isTmuxAvailable,
|
|
19
|
+
isTmuxSessionRunning,
|
|
20
|
+
sendToTmuxSession,
|
|
21
|
+
spawnTmuxSession,
|
|
22
|
+
} from '../../tmux/manager.js';
|
|
18
23
|
import { getTechLeadSessionName } from '../../utils/instance.js';
|
|
19
24
|
import { withHiveContext } from '../../utils/with-hive-context.js';
|
|
20
25
|
import { startDashboard } from '../dashboard/index.js';
|
|
@@ -222,24 +227,32 @@ export const reqCommand = new Command('req')
|
|
|
222
227
|
);
|
|
223
228
|
|
|
224
229
|
try {
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
230
|
+
const sessionAlreadyRunning = await isTmuxSessionRunning(sessionName);
|
|
231
|
+
|
|
232
|
+
if (sessionAlreadyRunning) {
|
|
233
|
+
// Session is already running — send the prompt to the existing session
|
|
234
|
+
// rather than killing it and losing in-progress work.
|
|
235
|
+
await sendToTmuxSession(sessionName, techLeadPrompt);
|
|
236
|
+
} else {
|
|
237
|
+
// Build CLI command using the configured runtime for Tech Lead
|
|
238
|
+
const chromeEnabled =
|
|
239
|
+
config.agents?.chrome_enabled === true && techLeadCliTool === 'claude';
|
|
240
|
+
const commandArgs = getCliRuntimeBuilder(techLeadCliTool).buildSpawnCommand(
|
|
241
|
+
techLeadModel,
|
|
242
|
+
techLeadSafetyMode,
|
|
243
|
+
{ chrome: chromeEnabled }
|
|
244
|
+
);
|
|
233
245
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
246
|
+
// Pass the prompt as initialPrompt so it's included as a CLI positional
|
|
247
|
+
// argument via $(cat ...). This delivers the full multi-line prompt
|
|
248
|
+
// reliably without tmux send-keys newline issues.
|
|
249
|
+
await spawnTmuxSession({
|
|
250
|
+
sessionName,
|
|
251
|
+
workDir: root,
|
|
252
|
+
commandArgs,
|
|
253
|
+
initialPrompt: techLeadPrompt,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
243
256
|
|
|
244
257
|
// Update agent and log spawning/planning events (atomic transaction)
|
|
245
258
|
await withTransaction(db.db, () => {
|
|
@@ -83,13 +83,24 @@ export async function startDashboard(options: DashboardOptions = {}): Promise<vo
|
|
|
83
83
|
tags: true,
|
|
84
84
|
});
|
|
85
85
|
|
|
86
|
+
// Paused flag — set to true while a panel is handling tmux attachment so
|
|
87
|
+
// the background scheduleRefresh() timer skips its render and avoids a
|
|
88
|
+
// double-render race after the terminal is restored.
|
|
89
|
+
let refreshPaused = false;
|
|
90
|
+
const pauseRefresh = () => {
|
|
91
|
+
refreshPaused = true;
|
|
92
|
+
};
|
|
93
|
+
const resumeRefresh = () => {
|
|
94
|
+
refreshPaused = false;
|
|
95
|
+
};
|
|
96
|
+
|
|
86
97
|
// Create panels
|
|
87
|
-
const agentsPanel = createAgentsPanel(screen, db.db);
|
|
98
|
+
const agentsPanel = createAgentsPanel(screen, db.db, pauseRefresh, resumeRefresh);
|
|
88
99
|
const storiesPanel = createStoriesPanel(screen, db.db);
|
|
89
100
|
const pipelinePanel = createPipelinePanel(screen, db.db);
|
|
90
101
|
const activityPanel = createActivityPanel(screen, db.db);
|
|
91
102
|
const mergeQueuePanel = createMergeQueuePanel(screen, db.db);
|
|
92
|
-
const escalationsPanel = createEscalationsPanel(screen, db.db);
|
|
103
|
+
const escalationsPanel = createEscalationsPanel(screen, db.db, pauseRefresh, resumeRefresh);
|
|
93
104
|
|
|
94
105
|
// Footer
|
|
95
106
|
blessed.box({
|
|
@@ -113,6 +124,7 @@ export async function startDashboard(options: DashboardOptions = {}): Promise<vo
|
|
|
113
124
|
|
|
114
125
|
// Refresh function - only reloads database when file has changed
|
|
115
126
|
const refresh = async () => {
|
|
127
|
+
if (refreshPaused) return;
|
|
116
128
|
try {
|
|
117
129
|
// Check if database file has been modified
|
|
118
130
|
const currentMtime = statSync(dbPath).mtimeMs;
|
|
@@ -19,7 +19,12 @@ function debugLog(msg: string) {
|
|
|
19
19
|
// Store agents for selection lookup
|
|
20
20
|
let currentAgents: AgentRow[] = [];
|
|
21
21
|
|
|
22
|
-
export function createAgentsPanel(
|
|
22
|
+
export function createAgentsPanel(
|
|
23
|
+
screen: Widgets.Screen,
|
|
24
|
+
db: Database,
|
|
25
|
+
pauseRefresh: () => void,
|
|
26
|
+
resumeRefresh: () => void
|
|
27
|
+
): Widgets.ListElement {
|
|
23
28
|
const list = blessed.list({
|
|
24
29
|
parent: screen,
|
|
25
30
|
top: 1,
|
|
@@ -59,6 +64,9 @@ export function createAgentsPanel(screen: Widgets.Screen, db: Database): Widgets
|
|
|
59
64
|
const agent = currentAgents[agentIndex];
|
|
60
65
|
debugLog(`Selected agent: ${agent.id}, tmux: ${agent.tmux_session}`);
|
|
61
66
|
if (agent.tmux_session) {
|
|
67
|
+
// Pause the background refresh timer so it cannot fire a concurrent
|
|
68
|
+
// render while we are restoring the terminal after tmux detach.
|
|
69
|
+
pauseRefresh();
|
|
62
70
|
// Temporarily suspend blessed while attached to tmux.
|
|
63
71
|
const resumeScreen = screen.program.pause();
|
|
64
72
|
|
|
@@ -69,8 +77,6 @@ export function createAgentsPanel(screen: Widgets.Screen, db: Database): Widgets
|
|
|
69
77
|
});
|
|
70
78
|
} finally {
|
|
71
79
|
// Restore dashboard in the same process (do not spawn nested dashboards).
|
|
72
|
-
// spawnSync blocks the event loop so the refresh timer cannot fire and
|
|
73
|
-
// the captured `db` reference remains valid.
|
|
74
80
|
resumeScreen();
|
|
75
81
|
// Force full redraw — after tmux detach the terminal buffer is clobbered.
|
|
76
82
|
// Use realloc() (dirty=true) so every cell is marked for redraw, preventing
|
|
@@ -82,6 +88,9 @@ export function createAgentsPanel(screen: Widgets.Screen, db: Database): Widgets
|
|
|
82
88
|
debugLog(`Failed to refresh agents panel after tmux detach: ${err}`);
|
|
83
89
|
}
|
|
84
90
|
screen.render();
|
|
91
|
+
// Allow the background refresh timer to render again now that the
|
|
92
|
+
// single explicit render above has fully completed.
|
|
93
|
+
resumeRefresh();
|
|
85
94
|
}
|
|
86
95
|
|
|
87
96
|
return;
|
|
@@ -21,7 +21,12 @@ function summarizeEscalationReason(reason: string): string {
|
|
|
21
21
|
return truncate(reason, 38);
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
export function createEscalationsPanel(
|
|
24
|
+
export function createEscalationsPanel(
|
|
25
|
+
screen: Widgets.Screen,
|
|
26
|
+
db: Database,
|
|
27
|
+
pauseRefresh: () => void,
|
|
28
|
+
resumeRefresh: () => void
|
|
29
|
+
): Widgets.ListElement {
|
|
25
30
|
const list = blessed.list({
|
|
26
31
|
parent: screen,
|
|
27
32
|
top: '55%+5',
|
|
@@ -53,6 +58,9 @@ export function createEscalationsPanel(screen: Widgets.Screen, db: Database): Wi
|
|
|
53
58
|
if (selectedIndex >= 0 && selectedIndex < currentEscalations.length) {
|
|
54
59
|
const escalation = currentEscalations[selectedIndex];
|
|
55
60
|
|
|
61
|
+
// Pause the background refresh timer so it cannot fire a concurrent
|
|
62
|
+
// render while we are restoring the terminal after tmux detach.
|
|
63
|
+
pauseRefresh();
|
|
56
64
|
// Temporarily suspend blessed while handling escalation.
|
|
57
65
|
const resumeScreen = screen.program.pause();
|
|
58
66
|
|
|
@@ -102,6 +110,9 @@ export function createEscalationsPanel(screen: Widgets.Screen, db: Database): Wi
|
|
|
102
110
|
console.error('Failed to update escalations panel:', err);
|
|
103
111
|
}
|
|
104
112
|
screen.render();
|
|
113
|
+
// Allow the background refresh timer to render again now that the
|
|
114
|
+
// single explicit render above has fully completed.
|
|
115
|
+
resumeRefresh();
|
|
105
116
|
}
|
|
106
117
|
|
|
107
118
|
return;
|
|
@@ -7,21 +7,53 @@ import type { ClusterEvent, VersionVector } from './replication.js';
|
|
|
7
7
|
interface DeltaRequest {
|
|
8
8
|
version_vector: VersionVector;
|
|
9
9
|
limit?: number;
|
|
10
|
+
fencing_token?: number;
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
interface DeltaResponse {
|
|
13
14
|
events: ClusterEvent[];
|
|
14
15
|
version_vector: VersionVector;
|
|
16
|
+
fencing_token: number;
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
const MAX_CLUSTER_REQUEST_BODY_BYTES = 1024 * 1024; // 1 MiB
|
|
18
20
|
|
|
21
|
+
export interface MembershipJoinRequest {
|
|
22
|
+
node_id: string;
|
|
23
|
+
url: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface MembershipJoinResponse {
|
|
27
|
+
success: boolean;
|
|
28
|
+
leader_id: string | null;
|
|
29
|
+
leader_url: string | null;
|
|
30
|
+
peers: Array<{ id: string; url: string }>;
|
|
31
|
+
term: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface MembershipLeaveRequest {
|
|
35
|
+
node_id: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface MembershipLeaveResponse {
|
|
39
|
+
success: boolean;
|
|
40
|
+
peers: Array<{ id: string; url: string }>;
|
|
41
|
+
}
|
|
42
|
+
|
|
19
43
|
export interface ClusterHttpHandlers {
|
|
20
44
|
getStatus: () => unknown;
|
|
21
45
|
handleVoteRequest: (body: unknown) => unknown;
|
|
22
46
|
handleHeartbeat: (body: unknown) => unknown;
|
|
23
47
|
getDeltaFromCache: (vector: VersionVector, limit: number) => ClusterEvent[];
|
|
24
48
|
getVersionVectorCache: () => VersionVector;
|
|
49
|
+
getReplicationLag: () => unknown;
|
|
50
|
+
getFencingToken: () => number;
|
|
51
|
+
validateFencingToken: (token: number) => boolean;
|
|
52
|
+
isLeaderLeaseValid: () => boolean;
|
|
53
|
+
handleMembershipJoin: (body: MembershipJoinRequest) => MembershipJoinResponse;
|
|
54
|
+
handleMembershipLeave: (body: MembershipLeaveRequest) => MembershipLeaveResponse;
|
|
55
|
+
/** Returns a full snapshot of all replicated tables for state recovery. */
|
|
56
|
+
getSnapshot: () => unknown;
|
|
25
57
|
}
|
|
26
58
|
|
|
27
59
|
export class ClusterHttpServer {
|
|
@@ -72,6 +104,11 @@ export class ClusterHttpServer {
|
|
|
72
104
|
return;
|
|
73
105
|
}
|
|
74
106
|
|
|
107
|
+
if (method === 'GET' && path === '/cluster/v1/snapshot') {
|
|
108
|
+
sendJson(res, 200, this.handlers.getSnapshot());
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
75
112
|
if (method === 'POST' && path === '/cluster/v1/election/request-vote') {
|
|
76
113
|
const body = await readJsonBody(req);
|
|
77
114
|
const response = this.handlers.handleVoteRequest(body);
|
|
@@ -86,8 +123,25 @@ export class ClusterHttpServer {
|
|
|
86
123
|
return;
|
|
87
124
|
}
|
|
88
125
|
|
|
126
|
+
if (method === 'GET' && path === '/cluster/v1/replication-lag') {
|
|
127
|
+
sendJson(res, 200, this.handlers.getReplicationLag());
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
89
131
|
if (method === 'POST' && path === '/cluster/v1/events/delta') {
|
|
90
132
|
const body = (await readJsonBody(req)) as Partial<DeltaRequest>;
|
|
133
|
+
|
|
134
|
+
// Validate fencing token if provided — reject stale-leader requests
|
|
135
|
+
if (typeof body.fencing_token === 'number') {
|
|
136
|
+
if (!this.handlers.validateFencingToken(body.fencing_token)) {
|
|
137
|
+
sendJson(res, 409, {
|
|
138
|
+
error: 'Fencing token rejected: stale leader epoch',
|
|
139
|
+
fencing_token: this.handlers.getFencingToken(),
|
|
140
|
+
});
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
91
145
|
const vector = toVersionVector(body.version_vector);
|
|
92
146
|
const limit =
|
|
93
147
|
typeof body.limit === 'number' && Number.isFinite(body.limit) && body.limit > 0
|
|
@@ -98,10 +152,36 @@ export class ClusterHttpServer {
|
|
|
98
152
|
sendJson(res, 200, {
|
|
99
153
|
events,
|
|
100
154
|
version_vector: this.handlers.getVersionVectorCache(),
|
|
155
|
+
fencing_token: this.handlers.getFencingToken(),
|
|
101
156
|
} satisfies DeltaResponse);
|
|
102
157
|
return;
|
|
103
158
|
}
|
|
104
159
|
|
|
160
|
+
if (method === 'POST' && path === '/cluster/v1/membership/join') {
|
|
161
|
+
const body = (await readJsonBody(req)) as Partial<MembershipJoinRequest>;
|
|
162
|
+
if (typeof body.node_id !== 'string' || typeof body.url !== 'string') {
|
|
163
|
+
sendJson(res, 400, { error: 'node_id and url are required' });
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const response = this.handlers.handleMembershipJoin({
|
|
167
|
+
node_id: body.node_id,
|
|
168
|
+
url: body.url,
|
|
169
|
+
});
|
|
170
|
+
sendJson(res, response.success ? 200 : 307, response);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (method === 'POST' && path === '/cluster/v1/membership/leave') {
|
|
175
|
+
const body = (await readJsonBody(req)) as Partial<MembershipLeaveRequest>;
|
|
176
|
+
if (typeof body.node_id !== 'string') {
|
|
177
|
+
sendJson(res, 400, { error: 'node_id is required' });
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const response = this.handlers.handleMembershipLeave({ node_id: body.node_id });
|
|
181
|
+
sendJson(res, response.success ? 200 : 400, response);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
105
185
|
sendJson(res, 404, { error: 'Not found' });
|
|
106
186
|
} catch (error) {
|
|
107
187
|
if (error instanceof HttpRequestError) {
|
|
@@ -131,10 +131,14 @@ describe('distributed runtime transport and status', () => {
|
|
|
131
131
|
is_leader: false,
|
|
132
132
|
leader_id: null,
|
|
133
133
|
leader_url: null,
|
|
134
|
+
fencing_token: 0,
|
|
135
|
+
leader_lease_valid: false,
|
|
136
|
+
leader_lease_duration_ms: 0,
|
|
134
137
|
raft_commit_index: 12,
|
|
135
138
|
raft_last_applied: 0,
|
|
136
139
|
raft_last_log_index: 9,
|
|
137
140
|
peers: [{ id: 'peer-a', url: 'http://127.0.0.1:8080' }],
|
|
141
|
+
is_catching_up: false,
|
|
138
142
|
} satisfies ClusterStatus);
|
|
139
143
|
});
|
|
140
144
|
|
|
@@ -477,6 +481,11 @@ describe('distributed runtime sync behavior', () => {
|
|
|
477
481
|
imported_events_applied: 0,
|
|
478
482
|
merged_duplicate_stories: 0,
|
|
479
483
|
durable_log_entries_appended: 0,
|
|
484
|
+
log_entries_compacted: 0,
|
|
485
|
+
cluster_events_pruned: 0,
|
|
486
|
+
used_snapshot_recovery: false,
|
|
487
|
+
catch_up_applied: 0,
|
|
488
|
+
catch_up_total: 0,
|
|
480
489
|
});
|
|
481
490
|
|
|
482
491
|
db.close();
|
|
@@ -646,6 +646,174 @@ describe('durable raft metadata store', () => {
|
|
|
646
646
|
});
|
|
647
647
|
});
|
|
648
648
|
|
|
649
|
+
describe('log compaction and snapshotting', () => {
|
|
650
|
+
it('creates a snapshot and compacts the raft log', () => {
|
|
651
|
+
const dir = mkdtempSync(join(tmpdir(), 'hive-compaction-'));
|
|
652
|
+
tempDirs.push(dir);
|
|
653
|
+
|
|
654
|
+
const store = new RaftMetadataStore({ clusterDir: dir, nodeId: 'node-compact' });
|
|
655
|
+
store.setState({ current_term: 5 });
|
|
656
|
+
|
|
657
|
+
// Append 20 entries
|
|
658
|
+
for (let i = 0; i < 20; i++) {
|
|
659
|
+
store.appendEntry({ type: 'runtime', metadata: { seq: i } });
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
expect(store.getLogEntryCount()).toBe(20);
|
|
663
|
+
expect(store.getState().last_log_index).toBe(20);
|
|
664
|
+
|
|
665
|
+
// Create snapshot at current state
|
|
666
|
+
const snapshot = store.createSnapshot({ 'node-compact': 10 });
|
|
667
|
+
expect(snapshot.last_included_index).toBe(20);
|
|
668
|
+
expect(snapshot.last_included_term).toBe(5);
|
|
669
|
+
expect(snapshot.version_vector).toEqual({ 'node-compact': 10 });
|
|
670
|
+
expect(existsSync(join(dir, 'raft-snapshot.json'))).toBe(true);
|
|
671
|
+
|
|
672
|
+
// Compact the log
|
|
673
|
+
const result = store.compactLog();
|
|
674
|
+
expect(result.entries_removed).toBe(20);
|
|
675
|
+
expect(result.entries_retained).toBe(0);
|
|
676
|
+
expect(result.snapshot_index).toBe(20);
|
|
677
|
+
expect(store.getLogEntryCount()).toBe(0);
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
it('retains entries after the snapshot index during compaction', () => {
|
|
681
|
+
const dir = mkdtempSync(join(tmpdir(), 'hive-compaction-'));
|
|
682
|
+
tempDirs.push(dir);
|
|
683
|
+
|
|
684
|
+
const store = new RaftMetadataStore({ clusterDir: dir, nodeId: 'node-retain' });
|
|
685
|
+
|
|
686
|
+
// Append 10 entries
|
|
687
|
+
for (let i = 0; i < 10; i++) {
|
|
688
|
+
store.appendEntry({ type: 'runtime', metadata: { seq: i } });
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// Snapshot at index 5 (manually set to test partial compaction)
|
|
692
|
+
const snapshot = store.createSnapshot({ 'node-retain': 5 });
|
|
693
|
+
expect(snapshot.last_included_index).toBe(10);
|
|
694
|
+
|
|
695
|
+
// Append 5 more entries AFTER snapshot
|
|
696
|
+
for (let i = 0; i < 5; i++) {
|
|
697
|
+
store.appendEntry({ type: 'runtime', metadata: { seq: 10 + i } });
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
expect(store.getLogEntryCount()).toBe(15);
|
|
701
|
+
|
|
702
|
+
const result = store.compactLog();
|
|
703
|
+
expect(result.entries_removed).toBe(10);
|
|
704
|
+
expect(result.entries_retained).toBe(5);
|
|
705
|
+
expect(store.getLogEntryCount()).toBe(5);
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
it('restores snapshot and event IDs across restarts', () => {
|
|
709
|
+
const dir = mkdtempSync(join(tmpdir(), 'hive-snapshot-restart-'));
|
|
710
|
+
tempDirs.push(dir);
|
|
711
|
+
|
|
712
|
+
const first = new RaftMetadataStore({ clusterDir: dir, nodeId: 'node-snap' });
|
|
713
|
+
first.setState({ current_term: 3 });
|
|
714
|
+
|
|
715
|
+
const events: ClusterEvent[] = [
|
|
716
|
+
buildStoryEvent({
|
|
717
|
+
event_id: 'node-a:1',
|
|
718
|
+
row_id: 'STORY-A',
|
|
719
|
+
version: { actor_id: 'node-a', actor_counter: 1, logical_ts: 1000 },
|
|
720
|
+
}),
|
|
721
|
+
buildStoryEvent({
|
|
722
|
+
event_id: 'node-b:1',
|
|
723
|
+
row_id: 'STORY-B',
|
|
724
|
+
version: { actor_id: 'node-b', actor_counter: 1, logical_ts: 2000 },
|
|
725
|
+
}),
|
|
726
|
+
];
|
|
727
|
+
first.appendClusterEvents(events, 3);
|
|
728
|
+
first.createSnapshot({ 'node-a': 1, 'node-b': 1 });
|
|
729
|
+
first.compactLog();
|
|
730
|
+
|
|
731
|
+
// Restart
|
|
732
|
+
const second = new RaftMetadataStore({ clusterDir: dir, nodeId: 'node-snap' });
|
|
733
|
+
const restored = second.getState();
|
|
734
|
+
const snap = second.getSnapshot();
|
|
735
|
+
|
|
736
|
+
expect(restored.last_log_index).toBeGreaterThanOrEqual(2);
|
|
737
|
+
expect(snap).not.toBeNull();
|
|
738
|
+
expect(snap!.version_vector).toEqual({ 'node-a': 1, 'node-b': 1 });
|
|
739
|
+
|
|
740
|
+
// Event IDs should be restored from snapshot
|
|
741
|
+
expect(second.hasEvent('node-a:1')).toBe(true);
|
|
742
|
+
expect(second.hasEvent('node-b:1')).toBe(true);
|
|
743
|
+
|
|
744
|
+
// Deduplication should still work
|
|
745
|
+
const deduped = second.appendClusterEvents(events, 3);
|
|
746
|
+
expect(deduped).toBe(0);
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
it('compaction without snapshot is a no-op', () => {
|
|
750
|
+
const dir = mkdtempSync(join(tmpdir(), 'hive-no-snap-'));
|
|
751
|
+
tempDirs.push(dir);
|
|
752
|
+
|
|
753
|
+
const store = new RaftMetadataStore({ clusterDir: dir, nodeId: 'node-nosn' });
|
|
754
|
+
for (let i = 0; i < 5; i++) {
|
|
755
|
+
store.appendEntry({ type: 'runtime', metadata: { seq: i } });
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
const result = store.compactLog();
|
|
759
|
+
expect(result.entries_removed).toBe(0);
|
|
760
|
+
expect(result.entries_retained).toBe(0);
|
|
761
|
+
expect(store.getLogEntryCount()).toBe(5);
|
|
762
|
+
});
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
describe('cluster_events pruning', () => {
|
|
766
|
+
it('prunes old events keeping the most recent ones', async () => {
|
|
767
|
+
const db = await createTestDatabase();
|
|
768
|
+
ensureClusterTables(db, 'node-prune');
|
|
769
|
+
|
|
770
|
+
// Emit 10 events
|
|
771
|
+
for (let i = 0; i < 10; i++) {
|
|
772
|
+
const { emitLocalEvent } = await import('./events.js');
|
|
773
|
+
emitLocalEvent(db, 'node-prune', {
|
|
774
|
+
table_name: 'stories',
|
|
775
|
+
row_id: `STORY-${i}`,
|
|
776
|
+
op: 'upsert',
|
|
777
|
+
payload: storyPayload(`STORY-${i}`),
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
const { getClusterEventCount, pruneClusterEvents } = await import('./events.js');
|
|
782
|
+
expect(getClusterEventCount(db)).toBe(10);
|
|
783
|
+
|
|
784
|
+
// Prune to keep only 5
|
|
785
|
+
const pruned = pruneClusterEvents(db, 5);
|
|
786
|
+
expect(pruned).toBe(5);
|
|
787
|
+
expect(getClusterEventCount(db)).toBe(5);
|
|
788
|
+
|
|
789
|
+
db.close();
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
it('does not prune when count is below threshold', async () => {
|
|
793
|
+
const db = await createTestDatabase();
|
|
794
|
+
ensureClusterTables(db, 'node-no-prune');
|
|
795
|
+
|
|
796
|
+
const { emitLocalEvent } = await import('./events.js');
|
|
797
|
+
for (let i = 0; i < 3; i++) {
|
|
798
|
+
emitLocalEvent(db, 'node-no-prune', {
|
|
799
|
+
table_name: 'stories',
|
|
800
|
+
row_id: `STORY-${i}`,
|
|
801
|
+
op: 'upsert',
|
|
802
|
+
payload: storyPayload(`STORY-${i}`),
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
const { getClusterEventCount, pruneClusterEvents } = await import('./events.js');
|
|
807
|
+
expect(getClusterEventCount(db)).toBe(3);
|
|
808
|
+
|
|
809
|
+
const pruned = pruneClusterEvents(db, 10);
|
|
810
|
+
expect(pruned).toBe(0);
|
|
811
|
+
expect(getClusterEventCount(db)).toBe(3);
|
|
812
|
+
|
|
813
|
+
db.close();
|
|
814
|
+
});
|
|
815
|
+
});
|
|
816
|
+
|
|
649
817
|
function storyPayload(
|
|
650
818
|
id: string,
|
|
651
819
|
overrides: Partial<Record<string, unknown>> = {}
|
package/src/cluster/events.ts
CHANGED
|
@@ -96,6 +96,13 @@ export function ensureClusterTables(db: Database, nodeId: string): void {
|
|
|
96
96
|
`
|
|
97
97
|
);
|
|
98
98
|
|
|
99
|
+
// Add snapshot_version_vector column if it doesn't exist yet (backward-compat migration)
|
|
100
|
+
try {
|
|
101
|
+
run(db, 'ALTER TABLE cluster_state ADD COLUMN snapshot_version_vector TEXT');
|
|
102
|
+
} catch {
|
|
103
|
+
// Column already exists — ignore
|
|
104
|
+
}
|
|
105
|
+
|
|
99
106
|
const state = queryOne<{ id: number }>(db, 'SELECT id FROM cluster_state WHERE id = 1');
|
|
100
107
|
const now = new Date().toISOString();
|
|
101
108
|
|
|
@@ -128,6 +135,53 @@ export function getVersionVector(db: Database): VersionVector {
|
|
|
128
135
|
return vector;
|
|
129
136
|
}
|
|
130
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Returns the snapshot version vector stored after the last snapshot-based recovery.
|
|
140
|
+
* Empty object if no snapshot has been applied.
|
|
141
|
+
*/
|
|
142
|
+
export function getSnapshotVersionVector(db: Database): VersionVector {
|
|
143
|
+
const row = queryOne<{ snapshot_version_vector: string | null }>(
|
|
144
|
+
db,
|
|
145
|
+
'SELECT snapshot_version_vector FROM cluster_state WHERE id = 1'
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
if (!row?.snapshot_version_vector) return {};
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
return JSON.parse(row.snapshot_version_vector) as VersionVector;
|
|
152
|
+
} catch {
|
|
153
|
+
return {};
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Persists the snapshot version vector so that future delta requests start
|
|
159
|
+
* from this point rather than from the (empty) event log.
|
|
160
|
+
*/
|
|
161
|
+
export function setSnapshotVersionVector(db: Database, vector: VersionVector): void {
|
|
162
|
+
run(db, 'UPDATE cluster_state SET snapshot_version_vector = ? WHERE id = 1', [
|
|
163
|
+
JSON.stringify(vector),
|
|
164
|
+
]);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Returns the effective version vector for delta-sync requests.
|
|
169
|
+
* Takes the max per actor between the event-log-derived vector and any
|
|
170
|
+
* snapshot vector stored from a previous snapshot-based recovery.
|
|
171
|
+
* This prevents re-requesting events that were already covered by a snapshot.
|
|
172
|
+
*/
|
|
173
|
+
export function getEffectiveVersionVector(db: Database): VersionVector {
|
|
174
|
+
const eventVector = getVersionVector(db);
|
|
175
|
+
const snapshotVector = getSnapshotVersionVector(db);
|
|
176
|
+
|
|
177
|
+
const effective: VersionVector = { ...snapshotVector };
|
|
178
|
+
for (const [actor, counter] of Object.entries(eventVector)) {
|
|
179
|
+
effective[actor] = Math.max(effective[actor] ?? 0, counter);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return effective;
|
|
183
|
+
}
|
|
184
|
+
|
|
131
185
|
export function getAllClusterEvents(db: Database): ClusterEvent[] {
|
|
132
186
|
const rows = queryAll<ClusterEventRow>(
|
|
133
187
|
db,
|
|
@@ -204,6 +258,42 @@ export function emitLocalEvent(
|
|
|
204
258
|
);
|
|
205
259
|
}
|
|
206
260
|
|
|
261
|
+
/**
|
|
262
|
+
* Prune old cluster_events rows, retaining only the most recent `retainCount` events.
|
|
263
|
+
* Returns the number of rows deleted.
|
|
264
|
+
*/
|
|
265
|
+
export function pruneClusterEvents(db: Database, retainCount: number): number {
|
|
266
|
+
if (retainCount <= 0) return 0;
|
|
267
|
+
|
|
268
|
+
const countRow = queryOne<{ total: number }>(db, 'SELECT COUNT(*) as total FROM cluster_events');
|
|
269
|
+
const total = countRow?.total || 0;
|
|
270
|
+
|
|
271
|
+
if (total <= retainCount) return 0;
|
|
272
|
+
|
|
273
|
+
// Delete events that are not in the most recent `retainCount` by logical_ts ordering.
|
|
274
|
+
// We keep the newest events and delete the oldest.
|
|
275
|
+
run(
|
|
276
|
+
db,
|
|
277
|
+
`
|
|
278
|
+
DELETE FROM cluster_events
|
|
279
|
+
WHERE event_id NOT IN (
|
|
280
|
+
SELECT event_id FROM cluster_events
|
|
281
|
+
ORDER BY logical_ts DESC, actor_id DESC, actor_counter DESC
|
|
282
|
+
LIMIT ?
|
|
283
|
+
)
|
|
284
|
+
`,
|
|
285
|
+
[retainCount]
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
const afterRow = queryOne<{ total: number }>(db, 'SELECT COUNT(*) as total FROM cluster_events');
|
|
289
|
+
return total - (afterRow?.total || 0);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function getClusterEventCount(db: Database): number {
|
|
293
|
+
const row = queryOne<{ total: number }>(db, 'SELECT COUNT(*) as total FROM cluster_events');
|
|
294
|
+
return row?.total || 0;
|
|
295
|
+
}
|
|
296
|
+
|
|
207
297
|
export function fetchTableSnapshots(db: Database, adapter: TableAdapter): TableRowSnapshot[] {
|
|
208
298
|
const rows = queryAll<Record<string, unknown>>(db, adapter.selectSql);
|
|
209
299
|
|