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.
Files changed (136) hide show
  1. package/dist/cli/commands/cluster.d.ts.map +1 -1
  2. package/dist/cli/commands/cluster.js +348 -1
  3. package/dist/cli/commands/cluster.js.map +1 -1
  4. package/dist/cli/commands/cluster.test.js +313 -9
  5. package/dist/cli/commands/cluster.test.js.map +1 -1
  6. package/dist/cli/commands/manager/index.js +6 -4
  7. package/dist/cli/commands/manager/index.js.map +1 -1
  8. package/dist/cli/commands/req-spawn.test.d.ts +2 -0
  9. package/dist/cli/commands/req-spawn.test.d.ts.map +1 -0
  10. package/dist/cli/commands/req-spawn.test.js +116 -0
  11. package/dist/cli/commands/req-spawn.test.js.map +1 -0
  12. package/dist/cli/commands/req.d.ts.map +1 -1
  13. package/dist/cli/commands/req.js +21 -13
  14. package/dist/cli/commands/req.js.map +1 -1
  15. package/dist/cli/dashboard/index.d.ts.map +1 -1
  16. package/dist/cli/dashboard/index.js +14 -2
  17. package/dist/cli/dashboard/index.js.map +1 -1
  18. package/dist/cli/dashboard/panels/agents.d.ts +1 -1
  19. package/dist/cli/dashboard/panels/agents.d.ts.map +1 -1
  20. package/dist/cli/dashboard/panels/agents.js +7 -3
  21. package/dist/cli/dashboard/panels/agents.js.map +1 -1
  22. package/dist/cli/dashboard/panels/escalations.d.ts +1 -1
  23. package/dist/cli/dashboard/panels/escalations.d.ts.map +1 -1
  24. package/dist/cli/dashboard/panels/escalations.js +7 -1
  25. package/dist/cli/dashboard/panels/escalations.js.map +1 -1
  26. package/dist/cluster/cluster-http-server.d.ts +32 -0
  27. package/dist/cluster/cluster-http-server.d.ts.map +1 -1
  28. package/dist/cluster/cluster-http-server.js +42 -0
  29. package/dist/cluster/cluster-http-server.js.map +1 -1
  30. package/dist/cluster/distributed-runtime-coverage.test.js +9 -0
  31. package/dist/cluster/distributed-runtime-coverage.test.js.map +1 -1
  32. package/dist/cluster/distributed-system.test.js +135 -0
  33. package/dist/cluster/distributed-system.test.js.map +1 -1
  34. package/dist/cluster/events.d.ts +23 -0
  35. package/dist/cluster/events.d.ts.map +1 -1
  36. package/dist/cluster/events.js +74 -0
  37. package/dist/cluster/events.js.map +1 -1
  38. package/dist/cluster/heartbeat-manager.d.ts +2 -0
  39. package/dist/cluster/heartbeat-manager.d.ts.map +1 -1
  40. package/dist/cluster/heartbeat-manager.js +42 -6
  41. package/dist/cluster/heartbeat-manager.js.map +1 -1
  42. package/dist/cluster/membership.test.d.ts +2 -0
  43. package/dist/cluster/membership.test.d.ts.map +1 -0
  44. package/dist/cluster/membership.test.js +416 -0
  45. package/dist/cluster/membership.test.js.map +1 -0
  46. package/dist/cluster/partition-safety.test.d.ts +2 -0
  47. package/dist/cluster/partition-safety.test.d.ts.map +1 -0
  48. package/dist/cluster/partition-safety.test.js +440 -0
  49. package/dist/cluster/partition-safety.test.js.map +1 -0
  50. package/dist/cluster/raft-state-machine.d.ts +33 -1
  51. package/dist/cluster/raft-state-machine.d.ts.map +1 -1
  52. package/dist/cluster/raft-state-machine.js +65 -3
  53. package/dist/cluster/raft-state-machine.js.map +1 -1
  54. package/dist/cluster/raft-store.d.ts +26 -1
  55. package/dist/cluster/raft-store.d.ts.map +1 -1
  56. package/dist/cluster/raft-store.js +137 -0
  57. package/dist/cluster/raft-store.js.map +1 -1
  58. package/dist/cluster/replication-lag.test.d.ts +2 -0
  59. package/dist/cluster/replication-lag.test.d.ts.map +1 -0
  60. package/dist/cluster/replication-lag.test.js +239 -0
  61. package/dist/cluster/replication-lag.test.js.map +1 -0
  62. package/dist/cluster/replication.d.ts +2 -2
  63. package/dist/cluster/replication.d.ts.map +1 -1
  64. package/dist/cluster/replication.js +1 -1
  65. package/dist/cluster/replication.js.map +1 -1
  66. package/dist/cluster/runtime.d.ts +78 -0
  67. package/dist/cluster/runtime.d.ts.map +1 -1
  68. package/dist/cluster/runtime.js +400 -13
  69. package/dist/cluster/runtime.js.map +1 -1
  70. package/dist/cluster/state-recovery.test.d.ts +2 -0
  71. package/dist/cluster/state-recovery.test.d.ts.map +1 -0
  72. package/dist/cluster/state-recovery.test.js +310 -0
  73. package/dist/cluster/state-recovery.test.js.map +1 -0
  74. package/dist/cluster/types.d.ts +30 -0
  75. package/dist/cluster/types.d.ts.map +1 -1
  76. package/dist/config/schema.d.ts +48 -0
  77. package/dist/config/schema.d.ts.map +1 -1
  78. package/dist/config/schema.js +11 -0
  79. package/dist/config/schema.js.map +1 -1
  80. package/dist/connectors/auth/jira.js +1 -1
  81. package/dist/connectors/auth/jira.js.map +1 -1
  82. package/dist/connectors/auth/jira.test.js +18 -0
  83. package/dist/connectors/auth/jira.test.js.map +1 -1
  84. package/dist/context-files/generator.js +1 -1
  85. package/dist/context-files/generator.js.map +1 -1
  86. package/dist/context-files/generator.test.js +51 -0
  87. package/dist/context-files/generator.test.js.map +1 -1
  88. package/dist/orchestrator/orphan-recovery.d.ts +1 -1
  89. package/dist/orchestrator/orphan-recovery.d.ts.map +1 -1
  90. package/dist/orchestrator/orphan-recovery.js +4 -4
  91. package/dist/orchestrator/orphan-recovery.js.map +1 -1
  92. package/dist/orchestrator/prompt-templates.d.ts +3 -1
  93. package/dist/orchestrator/prompt-templates.d.ts.map +1 -1
  94. package/dist/orchestrator/prompt-templates.js +45 -8
  95. package/dist/orchestrator/prompt-templates.js.map +1 -1
  96. package/dist/orchestrator/prompt-templates.test.js +210 -0
  97. package/dist/orchestrator/prompt-templates.test.js.map +1 -1
  98. package/dist/orchestrator/scheduler.d.ts +1 -0
  99. package/dist/orchestrator/scheduler.d.ts.map +1 -1
  100. package/dist/orchestrator/scheduler.js +15 -10
  101. package/dist/orchestrator/scheduler.js.map +1 -1
  102. package/dist/orchestrator/scheduler.test.js +97 -6
  103. package/dist/orchestrator/scheduler.test.js.map +1 -1
  104. package/package.json +1 -1
  105. package/src/cli/commands/cluster.test.ts +387 -9
  106. package/src/cli/commands/cluster.ts +486 -1
  107. package/src/cli/commands/manager/index.ts +6 -4
  108. package/src/cli/commands/req-spawn.test.ts +153 -0
  109. package/src/cli/commands/req.ts +31 -18
  110. package/src/cli/dashboard/index.ts +14 -2
  111. package/src/cli/dashboard/panels/agents.ts +12 -3
  112. package/src/cli/dashboard/panels/escalations.ts +12 -1
  113. package/src/cluster/cluster-http-server.ts +80 -0
  114. package/src/cluster/distributed-runtime-coverage.test.ts +9 -0
  115. package/src/cluster/distributed-system.test.ts +168 -0
  116. package/src/cluster/events.ts +90 -0
  117. package/src/cluster/heartbeat-manager.ts +48 -6
  118. package/src/cluster/membership.test.ts +498 -0
  119. package/src/cluster/partition-safety.test.ts +523 -0
  120. package/src/cluster/raft-state-machine.ts +76 -4
  121. package/src/cluster/raft-store.ts +167 -1
  122. package/src/cluster/replication-lag.test.ts +284 -0
  123. package/src/cluster/replication.ts +6 -0
  124. package/src/cluster/runtime.ts +551 -12
  125. package/src/cluster/state-recovery.test.ts +420 -0
  126. package/src/cluster/types.ts +32 -0
  127. package/src/config/schema.ts +11 -0
  128. package/src/connectors/auth/jira.test.ts +21 -0
  129. package/src/connectors/auth/jira.ts +1 -1
  130. package/src/context-files/generator.test.ts +55 -0
  131. package/src/context-files/generator.ts +5 -5
  132. package/src/orchestrator/orphan-recovery.ts +32 -13
  133. package/src/orchestrator/prompt-templates.test.ts +263 -0
  134. package/src/orchestrator/prompt-templates.ts +49 -8
  135. package/src/orchestrator/scheduler.test.ts +129 -6
  136. package/src/orchestrator/scheduler.ts +46 -20
@@ -1,5 +1,7 @@
1
1
  import type { Database } from 'sql.js';
2
2
  import type { ClusterConfig } from '../config/schema.js';
3
+ import { type MembershipJoinRequest, type MembershipJoinResponse, type MembershipLeaveRequest, type MembershipLeaveResponse } from './cluster-http-server.js';
4
+ import { type ClusterEvent, type VersionVector } from './replication.js';
3
5
  type NodeRole = 'leader' | 'follower' | 'candidate';
4
6
  interface ClusterRuntimeOptions {
5
7
  hiveDir?: string;
@@ -17,6 +19,9 @@ export interface ClusterStatus {
17
19
  is_leader: boolean;
18
20
  leader_id: string | null;
19
21
  leader_url: string | null;
22
+ fencing_token: number;
23
+ leader_lease_valid: boolean;
24
+ leader_lease_duration_ms: number;
20
25
  raft_commit_index: number;
21
26
  raft_last_applied: number;
22
27
  raft_last_log_index: number;
@@ -24,12 +29,38 @@ export interface ClusterStatus {
24
29
  id: string;
25
30
  url: string;
26
31
  }>;
32
+ /** True while the node is performing snapshot-based catch-up and not yet election-eligible. */
33
+ is_catching_up: boolean;
27
34
  }
28
35
  export interface ClusterSyncResult {
29
36
  local_events_emitted: number;
30
37
  imported_events_applied: number;
31
38
  merged_duplicate_stories: number;
32
39
  durable_log_entries_appended: number;
40
+ log_entries_compacted: number;
41
+ cluster_events_pruned: number;
42
+ /** True when this sync triggered snapshot-based recovery rather than delta sync. */
43
+ used_snapshot_recovery: boolean;
44
+ /** Number of rows applied from the snapshot (0 when delta sync was used). */
45
+ catch_up_applied: number;
46
+ /** Total rows in the snapshot (0 when delta sync was used). */
47
+ catch_up_total: number;
48
+ }
49
+ export interface PeerReplicationLag {
50
+ peer_id: string;
51
+ peer_url: string;
52
+ reachable: boolean;
53
+ events_behind: number;
54
+ last_sync_at: string | null;
55
+ last_sync_duration_ms: number | null;
56
+ last_sync_events_applied: number;
57
+ }
58
+ export interface ReplicationLagSummary {
59
+ node_id: string;
60
+ total_local_events: number;
61
+ version_vector: VersionVector;
62
+ peers: PeerReplicationLag[];
63
+ last_sync_at: string | null;
33
64
  }
34
65
  export declare class ClusterRuntime {
35
66
  private readonly config;
@@ -38,6 +69,11 @@ export declare class ClusterRuntime {
38
69
  private stopping;
39
70
  private eventCache;
40
71
  private versionVectorCache;
72
+ private lastCompactionAt;
73
+ private peerLagMap;
74
+ private lastSyncAt;
75
+ /** Cached full snapshot refreshed on every sync, served to recovering nodes. */
76
+ private cachedSnapshot;
41
77
  private readonly raft;
42
78
  private readonly heartbeat;
43
79
  private readonly httpServer;
@@ -46,16 +82,58 @@ export declare class ClusterRuntime {
46
82
  stop(): Promise<void>;
47
83
  isEnabled(): boolean;
48
84
  isLeader(): boolean;
85
+ getReplicationLag(): ReplicationLagSummary;
49
86
  getStatus(): ClusterStatus;
50
87
  sync(db: Database): Promise<ClusterSyncResult>;
88
+ handleMembershipJoin(request: MembershipJoinRequest): MembershipJoinResponse;
89
+ handleMembershipLeave(request: MembershipLeaveRequest): MembershipLeaveResponse;
90
+ private maybeCompact;
51
91
  private refreshCache;
52
92
  private pullEventsFromPeers;
93
+ /**
94
+ * Returns true when the delta response is missing events the peer should have.
95
+ * This happens when the peer's event cache has been truncated (log compaction)
96
+ * and can no longer provide all events since our last known version.
97
+ */
98
+ private isDeltaInsufficient;
99
+ /**
100
+ * Requests a full snapshot from the given peer and applies it locally.
101
+ * Marks the node as no longer catching up once complete.
102
+ * Returns { applied, total } on success, null on failure.
103
+ */
104
+ private recoverFromSnapshot;
105
+ /**
106
+ * Applies a snapshot to the local database, upserting all rows from all tables.
107
+ * Stores the snapshot's version vector so future delta requests start from here.
108
+ */
109
+ private applySnapshot;
110
+ /**
111
+ * Builds a full snapshot of all replicated tables from the current db state.
112
+ * Called during sync to keep cachedSnapshot fresh for the HTTP endpoint.
113
+ */
114
+ private buildSnapshot;
53
115
  private requestDelta;
116
+ private requestSnapshot;
54
117
  private getDeltaFromCache;
55
118
  private postJson;
119
+ private getJson;
56
120
  private handleBackgroundError;
57
121
  private validateNetworkSecurity;
58
122
  }
123
+ export declare function fetchReplicationLag(config: ClusterConfig): Promise<ReplicationLagSummary | null>;
124
+ /**
125
+ * Fetches recent cluster events from the local runtime via the delta endpoint.
126
+ * Uses an empty version vector to request recent events up to the given limit.
127
+ */
128
+ export declare function fetchLocalClusterEvents(config: ClusterConfig, limit?: number): Promise<ClusterEvent[] | null>;
129
+ /**
130
+ * POSTs to the local cluster runtime at the given path.
131
+ */
132
+ export declare function postToLocalCluster<T>(config: ClusterConfig, path: string, body: unknown): Promise<T | null>;
133
+ /**
134
+ * POSTs to a peer cluster node at the given URL and path.
135
+ */
136
+ export declare function postToPeerCluster<T>(peerUrl: string, path: string, body: unknown, options: ClusterStatusFetchOptions): Promise<T | null>;
59
137
  export declare function fetchLocalClusterStatus(config: ClusterConfig): Promise<ClusterStatus | null>;
60
138
  export declare function fetchClusterStatusFromUrl(url: string, options: ClusterStatusFetchOptions): Promise<ClusterStatus | null>;
61
139
  export {};
@@ -1 +1 @@
1
- {"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../../src/cluster/runtime.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,QAAQ,CAAC;AACvC,OAAO,KAAK,EAAE,aAAa,EAAqB,MAAM,qBAAqB,CAAC;AAe5E,KAAK,QAAQ,GAAG,QAAQ,GAAG,UAAU,GAAG,WAAW,CAAC;AAEpD,UAAU,qBAAqB;IAC7B,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,UAAU,yBAAyB;IACjC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;CACnB;AAOD,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,QAAQ,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,SAAS,EAAE,OAAO,CAAC;IACnB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,iBAAiB,EAAE,MAAM,CAAC;IAC1B,iBAAiB,EAAE,MAAM,CAAC;IAC1B,mBAAmB,EAAE,MAAM,CAAC;IAC5B,KAAK,EAAE,KAAK,CAAC;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CAC3C;AAED,MAAM,WAAW,iBAAiB;IAChC,oBAAoB,EAAE,MAAM,CAAC;IAC7B,uBAAuB,EAAE,MAAM,CAAC;IAChC,wBAAwB,EAAE,MAAM,CAAC;IACjC,4BAA4B,EAAE,MAAM,CAAC;CACtC;AAED,qBAAa,cAAc;IAYvB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,OAAO;IAZ1B,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,QAAQ,CAAS;IAEzB,OAAO,CAAC,UAAU,CAAsB;IACxC,OAAO,CAAC,kBAAkB,CAAqB;IAE/C,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAmB;IACxC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAmB;IAC7C,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAoB;gBAG5B,MAAM,EAAE,aAAa,EACrB,OAAO,GAAE,qBAA0B;IAwBhD,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAoBtB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAiB3B,SAAS,IAAI,OAAO;IAIpB,QAAQ,IAAI,OAAO;IAKnB,SAAS,IAAI,aAAa;IAmBpB,IAAI,CAAC,EAAE,EAAE,QAAQ,GAAG,OAAO,CAAC,iBAAiB,CAAC;IAmCpD,OAAO,CAAC,YAAY;YAKN,mBAAmB;YAkBnB,YAAY;IAW1B,OAAO,CAAC,iBAAiB;YASX,QAAQ;IAmBtB,OAAO,CAAC,qBAAqB;IAO7B,OAAO,CAAC,uBAAuB;CAQhC;AAED,wBAAsB,uBAAuB,CAC3C,MAAM,EAAE,aAAa,GACpB,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,CAyB/B;AAED,wBAAsB,yBAAyB,CAC7C,GAAG,EAAE,MAAM,EACX,OAAO,EAAE,yBAAyB,GACjC,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,CAa/B"}
1
+ {"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../../src/cluster/runtime.ts"],"names":[],"mappings":"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"}
@@ -1,9 +1,11 @@
1
1
  // Licensed under the Hungry Ghost Hive License. See LICENSE.
2
2
  import { join } from 'path';
3
- import { ClusterHttpServer } from './cluster-http-server.js';
3
+ import { queryAll } from '../db/client.js';
4
+ import { REPLICATED_TABLES } from './adapters.js';
5
+ import { ClusterHttpServer, } from './cluster-http-server.js';
4
6
  import { HeartbeatManager } from './heartbeat-manager.js';
5
7
  import { RaftStateMachine } from './raft-state-machine.js';
6
- import { applyRemoteEvents, ensureClusterTables, getAllClusterEvents, getVersionVector, mergeSimilarStories, scanLocalChanges, } from './replication.js';
8
+ import { applyRemoteEvents, ensureClusterTables, getAllClusterEvents, getClusterEventCount, getEffectiveVersionVector, getVersionVector, mergeSimilarStories, pruneClusterEvents, scanLocalChanges, setSnapshotVersionVector, } from './replication.js';
7
9
  export class ClusterRuntime {
8
10
  config;
9
11
  options;
@@ -11,6 +13,11 @@ export class ClusterRuntime {
11
13
  stopping = false;
12
14
  eventCache = [];
13
15
  versionVectorCache = {};
16
+ lastCompactionAt = 0;
17
+ peerLagMap = new Map();
18
+ lastSyncAt = null;
19
+ /** Cached full snapshot refreshed on every sync, served to recovering nodes. */
20
+ cachedSnapshot = null;
14
21
  raft;
15
22
  heartbeat;
16
23
  httpServer;
@@ -27,6 +34,10 @@ export class ClusterRuntime {
27
34
  postJson: (peer, path, body) => this.postJson(peer, path, body),
28
35
  isActive: () => this.started && !this.stopping,
29
36
  handleBackgroundError: error => this.handleBackgroundError(error),
37
+ onPeersUpdated: peers => {
38
+ // Follower received updated peer list from leader via heartbeat
39
+ this.raft.setPeers(peers);
40
+ },
30
41
  });
31
42
  this.httpServer = new ClusterHttpServer(config, {
32
43
  getStatus: () => this.getStatus(),
@@ -34,6 +45,13 @@ export class ClusterRuntime {
34
45
  handleHeartbeat: body => this.heartbeat.handleHeartbeat(body),
35
46
  getDeltaFromCache: (vector, limit) => this.getDeltaFromCache(vector, limit),
36
47
  getVersionVectorCache: () => this.versionVectorCache,
48
+ getReplicationLag: () => this.getReplicationLag(),
49
+ getFencingToken: () => this.raft.getFencingToken(),
50
+ validateFencingToken: token => this.raft.validateFencingToken(token),
51
+ isLeaderLeaseValid: () => this.raft.isLeaderLeaseValid(),
52
+ handleMembershipJoin: body => this.handleMembershipJoin(body),
53
+ handleMembershipLeave: body => this.handleMembershipLeave(body),
54
+ getSnapshot: () => this.cachedSnapshot ?? { version_vector: {}, tables: {} },
37
55
  });
38
56
  }
39
57
  async start() {
@@ -72,6 +90,26 @@ export class ClusterRuntime {
72
90
  return true;
73
91
  return this.raft.role === 'leader';
74
92
  }
93
+ getReplicationLag() {
94
+ return {
95
+ node_id: this.config.node_id,
96
+ total_local_events: this.eventCache.length,
97
+ version_vector: { ...this.versionVectorCache },
98
+ peers: this.raft
99
+ .getPeers()
100
+ .filter(p => p.id !== this.config.node_id)
101
+ .map(p => this.peerLagMap.get(p.id) || {
102
+ peer_id: p.id,
103
+ peer_url: p.url,
104
+ reachable: false,
105
+ events_behind: 0,
106
+ last_sync_at: null,
107
+ last_sync_duration_ms: null,
108
+ last_sync_events_applied: 0,
109
+ }),
110
+ last_sync_at: this.lastSyncAt,
111
+ };
112
+ }
75
113
  getStatus() {
76
114
  const raftState = this.raft.getRaftStoreState();
77
115
  return {
@@ -83,10 +121,14 @@ export class ClusterRuntime {
83
121
  is_leader: this.isLeader(),
84
122
  leader_id: this.raft.leaderId,
85
123
  leader_url: this.raft.getLeaderUrl(),
124
+ fencing_token: this.raft.getFencingToken(),
125
+ leader_lease_valid: this.raft.isLeaderLeaseValid(),
126
+ leader_lease_duration_ms: this.raft.leaderLeaseDurationMs,
86
127
  raft_commit_index: raftState?.commit_index || 0,
87
128
  raft_last_applied: raftState?.last_applied || 0,
88
129
  raft_last_log_index: raftState?.last_log_index || 0,
89
- peers: this.config.peers.map(peer => ({ id: peer.id, url: peer.url })),
130
+ peers: this.raft.getPeers().map(peer => ({ id: peer.id, url: peer.url })),
131
+ is_catching_up: this.raft.isCatchingUp,
90
132
  };
91
133
  }
92
134
  async sync(db) {
@@ -96,49 +138,339 @@ export class ClusterRuntime {
96
138
  imported_events_applied: 0,
97
139
  merged_duplicate_stories: 0,
98
140
  durable_log_entries_appended: 0,
141
+ log_entries_compacted: 0,
142
+ cluster_events_pruned: 0,
143
+ used_snapshot_recovery: false,
144
+ catch_up_applied: 0,
145
+ catch_up_total: 0,
99
146
  };
100
147
  }
101
148
  const hiveDir = this.options.hiveDir || join(process.cwd(), '.hive');
102
149
  this.raft.initializeRaftStore(hiveDir);
103
150
  ensureClusterTables(db, this.config.node_id);
151
+ // Refresh snapshot cache so the HTTP endpoint always serves current data
152
+ this.cachedSnapshot = this.buildSnapshot(db);
104
153
  const localEventsBefore = scanLocalChanges(db, this.config.node_id);
105
- const imported = await this.pullEventsFromPeers(db);
154
+ const { imported, usedSnapshot, catchUpApplied, catchUpTotal } = await this.pullEventsFromPeers(db);
106
155
  const merged = mergeSimilarStories(db, this.config.story_similarity_threshold);
107
- const localEventsAfter = imported > 0 || merged > 0 ? scanLocalChanges(db, this.config.node_id) : 0;
156
+ const localEventsAfter = imported > 0 || merged > 0 || usedSnapshot ? scanLocalChanges(db, this.config.node_id) : 0;
108
157
  this.refreshCache(db);
109
158
  const durableLogEntriesAppended = this.raft.appendClusterEventsToDurableLog(getAllClusterEvents(db));
159
+ // Run compaction if thresholds are met and enough time has elapsed
160
+ const { logCompacted, eventsPruned } = this.maybeCompact(db);
110
161
  return {
111
162
  local_events_emitted: localEventsBefore + localEventsAfter,
112
163
  imported_events_applied: imported,
113
164
  merged_duplicate_stories: merged,
114
165
  durable_log_entries_appended: durableLogEntriesAppended,
166
+ log_entries_compacted: logCompacted,
167
+ cluster_events_pruned: eventsPruned,
168
+ used_snapshot_recovery: usedSnapshot,
169
+ catch_up_applied: catchUpApplied,
170
+ catch_up_total: catchUpTotal,
115
171
  };
116
172
  }
173
+ handleMembershipJoin(request) {
174
+ const peers = this.raft.getPeers();
175
+ const leaderUrl = this.raft.getLeaderUrl();
176
+ // If not the leader, redirect to leader
177
+ if (this.raft.role !== 'leader') {
178
+ return {
179
+ success: false,
180
+ leader_id: this.raft.leaderId,
181
+ leader_url: leaderUrl,
182
+ peers: peers.map(p => ({ id: p.id, url: p.url })),
183
+ term: this.raft.currentTerm,
184
+ };
185
+ }
186
+ // Check if peer already exists
187
+ const existing = peers.find(p => p.id === request.node_id);
188
+ if (existing) {
189
+ // Update URL if changed
190
+ if (existing.url !== request.url) {
191
+ const updated = peers.map(p => p.id === request.node_id ? { id: p.id, url: request.url } : p);
192
+ this.raft.setPeers(updated);
193
+ this.raft.appendDurableEntry('membership_change', {
194
+ action: 'update',
195
+ node_id: request.node_id,
196
+ url: request.url,
197
+ peer_count: updated.length,
198
+ });
199
+ }
200
+ return {
201
+ success: true,
202
+ leader_id: this.raft.leaderId,
203
+ leader_url: this.config.public_url,
204
+ peers: this.raft.getPeers().map(p => ({ id: p.id, url: p.url })),
205
+ term: this.raft.currentTerm,
206
+ };
207
+ }
208
+ // Add new peer
209
+ const newPeer = { id: request.node_id, url: request.url };
210
+ const updated = [...peers, newPeer];
211
+ this.raft.setPeers(updated);
212
+ this.raft.appendDurableEntry('membership_change', {
213
+ action: 'join',
214
+ node_id: request.node_id,
215
+ url: request.url,
216
+ peer_count: updated.length,
217
+ });
218
+ return {
219
+ success: true,
220
+ leader_id: this.raft.leaderId,
221
+ leader_url: this.config.public_url,
222
+ peers: updated.map(p => ({ id: p.id, url: p.url })),
223
+ term: this.raft.currentTerm,
224
+ };
225
+ }
226
+ handleMembershipLeave(request) {
227
+ const peers = this.raft.getPeers();
228
+ // If not the leader, cannot process leave
229
+ if (this.raft.role !== 'leader') {
230
+ return {
231
+ success: false,
232
+ peers: peers.map(p => ({ id: p.id, url: p.url })),
233
+ };
234
+ }
235
+ // Cannot remove self (leader) — leader must transfer leadership first
236
+ if (request.node_id === this.config.node_id) {
237
+ return {
238
+ success: false,
239
+ peers: peers.map(p => ({ id: p.id, url: p.url })),
240
+ };
241
+ }
242
+ const existing = peers.find(p => p.id === request.node_id);
243
+ if (!existing) {
244
+ // Already gone
245
+ return {
246
+ success: true,
247
+ peers: peers.map(p => ({ id: p.id, url: p.url })),
248
+ };
249
+ }
250
+ const updated = peers.filter(p => p.id !== request.node_id);
251
+ this.raft.setPeers(updated);
252
+ this.raft.appendDurableEntry('membership_change', {
253
+ action: 'leave',
254
+ node_id: request.node_id,
255
+ peer_count: updated.length,
256
+ });
257
+ return {
258
+ success: true,
259
+ peers: updated.map(p => ({ id: p.id, url: p.url })),
260
+ };
261
+ }
262
+ maybeCompact(db) {
263
+ const now = Date.now();
264
+ const interval = this.config.compaction_interval_ms ?? 300000;
265
+ // Respect minimum interval between compaction runs
266
+ if (interval > 0 && now - this.lastCompactionAt < interval) {
267
+ return { logCompacted: 0, eventsPruned: 0 };
268
+ }
269
+ let logCompacted = 0;
270
+ let eventsPruned = 0;
271
+ // Compact raft log if threshold exceeded
272
+ const maxLogEntries = this.config.max_log_entries ?? 10000;
273
+ if (maxLogEntries > 0) {
274
+ const logCount = this.raft.getLogEntryCount();
275
+ if (logCount > maxLogEntries) {
276
+ const versionVector = getVersionVector(db);
277
+ const result = this.raft.createSnapshotAndCompact(versionVector);
278
+ logCompacted = result.entries_removed;
279
+ }
280
+ }
281
+ // Prune cluster_events if threshold exceeded
282
+ const maxEvents = this.config.max_cluster_events ?? 50000;
283
+ if (maxEvents > 0) {
284
+ const eventCount = getClusterEventCount(db);
285
+ if (eventCount > maxEvents) {
286
+ eventsPruned = pruneClusterEvents(db, maxEvents);
287
+ if (eventsPruned > 0) {
288
+ this.refreshCache(db);
289
+ }
290
+ }
291
+ }
292
+ if (logCompacted > 0 || eventsPruned > 0) {
293
+ this.lastCompactionAt = now;
294
+ }
295
+ return { logCompacted, eventsPruned };
296
+ }
117
297
  refreshCache(db) {
118
298
  this.eventCache = getAllClusterEvents(db).slice(-20000);
119
299
  this.versionVectorCache = getVersionVector(db);
120
300
  }
121
301
  async pullEventsFromPeers(db) {
122
- if (this.config.peers.length === 0)
123
- return 0;
124
- let applied = 0;
125
- for (const peer of this.config.peers) {
302
+ const peers = this.raft.getPeers();
303
+ if (peers.length === 0) {
304
+ return { imported: 0, usedSnapshot: false, catchUpApplied: 0, catchUpTotal: 0 };
305
+ }
306
+ let imported = 0;
307
+ const syncTimestamp = new Date().toISOString();
308
+ this.lastSyncAt = syncTimestamp;
309
+ for (const peer of peers) {
126
310
  if (peer.id === this.config.node_id)
127
311
  continue;
128
- const localVector = getVersionVector(db);
312
+ const localVector = getEffectiveVersionVector(db);
313
+ const syncStart = Date.now();
129
314
  const response = await this.requestDelta(peer, localVector, 4000);
130
- if (!response || response.events.length === 0)
315
+ if (!response) {
316
+ this.peerLagMap.set(peer.id, {
317
+ peer_id: peer.id,
318
+ peer_url: peer.url,
319
+ reachable: false,
320
+ events_behind: 0,
321
+ last_sync_at: syncTimestamp,
322
+ last_sync_duration_ms: Date.now() - syncStart,
323
+ last_sync_events_applied: 0,
324
+ });
325
+ continue;
326
+ }
327
+ // If the peer advertises a higher fencing token, step down
328
+ if (typeof response.fencing_token === 'number' &&
329
+ response.fencing_token > this.raft.currentTerm) {
330
+ this.raft.stepDown(response.fencing_token, null);
331
+ }
332
+ // Detect if the delta is insufficient (peer's log was truncated past our position)
333
+ if (this.isDeltaInsufficient(localVector, response.version_vector, response.events)) {
334
+ const recovery = await this.recoverFromSnapshot(db, peer);
335
+ if (recovery !== null) {
336
+ this.peerLagMap.set(peer.id, {
337
+ peer_id: peer.id,
338
+ peer_url: peer.url,
339
+ reachable: true,
340
+ events_behind: 0,
341
+ last_sync_at: syncTimestamp,
342
+ last_sync_duration_ms: Date.now() - syncStart,
343
+ last_sync_events_applied: recovery.applied,
344
+ });
345
+ return {
346
+ imported: 0,
347
+ usedSnapshot: true,
348
+ catchUpApplied: recovery.applied,
349
+ catchUpTotal: recovery.total,
350
+ };
351
+ }
352
+ // Snapshot recovery failed — fall through and apply whatever delta we have
353
+ }
354
+ const eventsBehind = response.events.length;
355
+ const peerApplied = eventsBehind > 0 ? applyRemoteEvents(db, this.config.node_id, response.events) : 0;
356
+ imported += peerApplied;
357
+ this.peerLagMap.set(peer.id, {
358
+ peer_id: peer.id,
359
+ peer_url: peer.url,
360
+ reachable: true,
361
+ events_behind: eventsBehind,
362
+ last_sync_at: syncTimestamp,
363
+ last_sync_duration_ms: Date.now() - syncStart,
364
+ last_sync_events_applied: peerApplied,
365
+ });
366
+ }
367
+ // If we had been catching up and now the effective vector matches peers, mark done
368
+ if (this.raft.isCatchingUp) {
369
+ this.raft.isCatchingUp = false;
370
+ }
371
+ return { imported, usedSnapshot: false, catchUpApplied: 0, catchUpTotal: 0 };
372
+ }
373
+ /**
374
+ * Returns true when the delta response is missing events the peer should have.
375
+ * This happens when the peer's event cache has been truncated (log compaction)
376
+ * and can no longer provide all events since our last known version.
377
+ */
378
+ isDeltaInsufficient(localVector, peerVector, receivedEvents) {
379
+ // Count how many events we actually received per actor
380
+ const received = {};
381
+ for (const event of receivedEvents) {
382
+ received[event.version.actor_id] = (received[event.version.actor_id] ?? 0) + 1;
383
+ }
384
+ for (const [actorId, peerCounter] of Object.entries(peerVector)) {
385
+ const localCounter = localVector[actorId] ?? 0;
386
+ const needed = peerCounter - localCounter;
387
+ if (needed <= 0)
131
388
  continue;
132
- applied += applyRemoteEvents(db, this.config.node_id, response.events);
389
+ const receivedCount = received[actorId] ?? 0;
390
+ if (receivedCount < needed) {
391
+ // We're missing events for this actor that the peer should have
392
+ return true;
393
+ }
133
394
  }
134
- return applied;
395
+ return false;
396
+ }
397
+ /**
398
+ * Requests a full snapshot from the given peer and applies it locally.
399
+ * Marks the node as no longer catching up once complete.
400
+ * Returns { applied, total } on success, null on failure.
401
+ */
402
+ async recoverFromSnapshot(db, peer) {
403
+ this.raft.isCatchingUp = true;
404
+ this.raft.appendDurableEntry('runtime', {
405
+ event: 'snapshot_recovery_start',
406
+ node_id: this.config.node_id,
407
+ peer_id: peer.id,
408
+ });
409
+ const snapshot = await this.requestSnapshot(peer);
410
+ if (!snapshot) {
411
+ return null;
412
+ }
413
+ const { applied, total } = this.applySnapshot(db, snapshot);
414
+ this.raft.isCatchingUp = false;
415
+ this.raft.appendDurableEntry('runtime', {
416
+ event: 'snapshot_recovery_complete',
417
+ node_id: this.config.node_id,
418
+ peer_id: peer.id,
419
+ rows_applied: applied,
420
+ rows_total: total,
421
+ });
422
+ return { applied, total };
423
+ }
424
+ /**
425
+ * Applies a snapshot to the local database, upserting all rows from all tables.
426
+ * Stores the snapshot's version vector so future delta requests start from here.
427
+ */
428
+ applySnapshot(db, snapshot) {
429
+ let applied = 0;
430
+ let total = 0;
431
+ for (const adapter of REPLICATED_TABLES) {
432
+ const rows = snapshot.tables[adapter.table];
433
+ if (!rows)
434
+ continue;
435
+ total += rows.length;
436
+ for (const row of rows) {
437
+ adapter.upsert(db, row.payload);
438
+ applied++;
439
+ }
440
+ }
441
+ // Record the snapshot version vector so future delta requests
442
+ // only ask for events newer than this snapshot
443
+ setSnapshotVersionVector(db, snapshot.version_vector);
444
+ return { applied, total };
445
+ }
446
+ /**
447
+ * Builds a full snapshot of all replicated tables from the current db state.
448
+ * Called during sync to keep cachedSnapshot fresh for the HTTP endpoint.
449
+ */
450
+ buildSnapshot(db) {
451
+ const tables = {};
452
+ for (const adapter of REPLICATED_TABLES) {
453
+ const rows = queryAll(db, adapter.selectSql);
454
+ tables[adapter.table] = rows.map(row => ({
455
+ rowId: adapter.rowId(row),
456
+ payload: adapter.payload(row),
457
+ }));
458
+ }
459
+ return {
460
+ version_vector: getVersionVector(db),
461
+ tables,
462
+ };
135
463
  }
136
464
  async requestDelta(peer, versionVector, limit) {
137
465
  return this.postJson(peer, '/cluster/v1/events/delta', {
138
466
  version_vector: versionVector,
139
467
  limit,
468
+ fencing_token: this.raft.getFencingToken(),
140
469
  });
141
470
  }
471
+ async requestSnapshot(peer) {
472
+ return this.getJson(peer, '/cluster/v1/snapshot');
473
+ }
142
474
  getDeltaFromCache(remoteVersionVector, limit) {
143
475
  return this.eventCache
144
476
  .filter(event => {
@@ -155,6 +487,11 @@ export class ClusterRuntime {
155
487
  body,
156
488
  });
157
489
  }
490
+ async getJson(peer, path) {
491
+ const normalizedBase = peer.url.endsWith('/') ? peer.url : `${peer.url}/`;
492
+ const url = new URL(path.replace(/^\//, ''), normalizedBase).toString();
493
+ return fetchClusterStatusOrPostJson(url, this.config.request_timeout_ms, this.config.auth_token, { method: 'GET' });
494
+ }
158
495
  handleBackgroundError(error) {
159
496
  if (!this.started || this.stopping)
160
497
  return;
@@ -171,6 +508,48 @@ export class ClusterRuntime {
171
508
  throw new Error(`Cluster auth_token is required when listen_host is not loopback (received: ${this.config.listen_host})`);
172
509
  }
173
510
  }
511
+ export async function fetchReplicationLag(config) {
512
+ if (!config.enabled)
513
+ return null;
514
+ const host = config.listen_host === '0.0.0.0' ? '127.0.0.1' : config.listen_host;
515
+ const url = `http://${host}:${config.listen_port}/cluster/v1/replication-lag`;
516
+ return fetchClusterStatusOrPostJson(url, config.request_timeout_ms, config.auth_token, { method: 'GET' });
517
+ }
518
+ /**
519
+ * Fetches recent cluster events from the local runtime via the delta endpoint.
520
+ * Uses an empty version vector to request recent events up to the given limit.
521
+ */
522
+ export async function fetchLocalClusterEvents(config, limit = 50) {
523
+ if (!config.enabled)
524
+ return null;
525
+ const host = config.listen_host === '0.0.0.0' ? '127.0.0.1' : config.listen_host;
526
+ const url = `http://${host}:${config.listen_port}/cluster/v1/events/delta`;
527
+ const response = await fetchClusterStatusOrPostJson(url, config.request_timeout_ms, config.auth_token, { method: 'POST', body: { version_vector: {}, limit } });
528
+ return response?.events ?? null;
529
+ }
530
+ /**
531
+ * POSTs to the local cluster runtime at the given path.
532
+ */
533
+ export async function postToLocalCluster(config, path, body) {
534
+ if (!config.enabled)
535
+ return null;
536
+ const host = config.listen_host === '0.0.0.0' ? '127.0.0.1' : config.listen_host;
537
+ const url = `http://${host}:${config.listen_port}${path}`;
538
+ return fetchClusterStatusOrPostJson(url, config.request_timeout_ms, config.auth_token, {
539
+ method: 'POST',
540
+ body,
541
+ });
542
+ }
543
+ /**
544
+ * POSTs to a peer cluster node at the given URL and path.
545
+ */
546
+ export async function postToPeerCluster(peerUrl, path, body, options) {
547
+ const url = `${peerUrl.replace(/\/$/, '')}${path}`;
548
+ return fetchClusterStatusOrPostJson(url, options.timeoutMs, options.authToken, {
549
+ method: 'POST',
550
+ body,
551
+ });
552
+ }
174
553
  export async function fetchLocalClusterStatus(config) {
175
554
  if (!config.enabled) {
176
555
  return {
@@ -182,10 +561,14 @@ export async function fetchLocalClusterStatus(config) {
182
561
  is_leader: true,
183
562
  leader_id: config.node_id,
184
563
  leader_url: null,
564
+ fencing_token: 0,
565
+ leader_lease_valid: true,
566
+ leader_lease_duration_ms: config.leader_lease_ms ?? config.heartbeat_interval_ms * 3,
185
567
  raft_commit_index: 0,
186
568
  raft_last_applied: 0,
187
569
  raft_last_log_index: 0,
188
570
  peers: config.peers.map(peer => ({ id: peer.id, url: peer.url })),
571
+ is_catching_up: false,
189
572
  };
190
573
  }
191
574
  const host = config.listen_host === '0.0.0.0' ? '127.0.0.1' : config.listen_host;
@@ -228,10 +611,14 @@ function parseClusterStatus(input) {
228
611
  is_leader: input.is_leader === true,
229
612
  leader_id: typeof input.leader_id === 'string' ? input.leader_id : null,
230
613
  leader_url: typeof input.leader_url === 'string' ? input.leader_url : null,
614
+ fencing_token: toInt(input.fencing_token),
615
+ leader_lease_valid: input.leader_lease_valid === true,
616
+ leader_lease_duration_ms: toInt(input.leader_lease_duration_ms),
231
617
  raft_commit_index: toInt(input.raft_commit_index),
232
618
  raft_last_applied: toInt(input.raft_last_applied),
233
619
  raft_last_log_index: toInt(input.raft_last_log_index),
234
620
  peers,
621
+ is_catching_up: input.is_catching_up === true,
235
622
  };
236
623
  }
237
624
  async function fetchClusterStatusOrPostJson(url, timeoutMs, authToken, options) {