lex-mesh 0.4.1 → 0.4.3

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 30d95d61132596e1638e9855fb045f20a7b9b0285c63c35766a9e07c776c78d2
4
- data.tar.gz: 1e8c6704b51f98cc9c768bd5798b3634badb6491e542a022f75c612a5165aa6a
3
+ metadata.gz: b16d673e30a813f0fa38f61386eae0e9a72a35cc2b0eedc6b45ced88a4acadf7
4
+ data.tar.gz: dc7a12c1de2412117ac9ce56c7803626dba16604f9cbfba57ca41e72e55dc3b5
5
5
  SHA512:
6
- metadata.gz: 7a82cf01be6f79a4c81092f4d5b56fe902e6e6ac933b7dd14fca4b5169ea2aac20a310eb056ced5bc36f0240800ebcc9cfcb5f6d2e6d835606080f969cfb07ca
7
- data.tar.gz: a4d0cffdfc0177c59f7899caa8b65fca7c2359e9085903f3fb60b77eb2d7ee2a690aac20ac1c5a71a0ec072bfa10baf6265c3ea697b78fd6b9a454cd93c007f1
6
+ metadata.gz: 7abe441618ba1fca371412adf3a2a48f16f54adc4130d060369e1fcdc7222a777fcc48e165576e74e4cf1892fdd9a42f4562242523bf2d9e8239cdb8af771b4c
7
+ data.tar.gz: 61e2b0e8aba072966d10e820db3708ff2c76ecfcda9fc73e99bcd61eff051323c81ea5c1cef0e1c56ce126604369983205f32e732376eab53ad1fae4af1857f5
data/Gemfile CHANGED
@@ -8,3 +8,4 @@ gem 'base64'
8
8
  gem 'ed25519', '~> 1.3'
9
9
  gem 'rspec', '~> 3.13'
10
10
  gem 'rubocop', '~> 1.75', require: false
11
+ gem 'rubocop-legion', '~> 0.1'
@@ -6,7 +6,7 @@ module Legion
6
6
  module Extensions
7
7
  module Mesh
8
8
  module Actor
9
- class Gossip < Legion::Extensions::Actors::Every
9
+ class Gossip < Legion::Extensions::Actors::Every # rubocop:disable Legion/Extension/EveryActorRequiresTime
10
10
  def runner_class
11
11
  Legion::Extensions::Mesh::Runners::Mesh
12
12
  end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/actors/subscription'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module Mesh
8
+ module Actor
9
+ class GossipListener < Legion::Extensions::Actors::Subscription
10
+ def runner_class
11
+ Legion::Extensions::Mesh::Runners::Mesh
12
+ end
13
+
14
+ def runner_function
15
+ 'dispatch_gossip_message'
16
+ end
17
+
18
+ def check_subtask?
19
+ false
20
+ end
21
+
22
+ def generate_task?
23
+ false
24
+ end
25
+
26
+ def use_runner?
27
+ false
28
+ end
29
+
30
+ def queue
31
+ Legion::Extensions::Mesh::Transport::Queues::Gossip
32
+ end
33
+
34
+ def enabled? # rubocop:disable Legion/Extension/ActorEnabledSideEffects
35
+ defined?(Legion::Extensions::Mesh::Runners::Mesh) &&
36
+ Legion.const_defined?(:Transport, false)
37
+ rescue StandardError => _e
38
+ false
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -6,7 +6,7 @@ module Legion
6
6
  module Extensions
7
7
  module Mesh
8
8
  module Actor
9
- class Heartbeat < Legion::Extensions::Actors::Every
9
+ class Heartbeat < Legion::Extensions::Actors::Every # rubocop:disable Legion/Extension/EveryActorRequiresTime
10
10
  def runner_class
11
11
  Legion::Extensions::Mesh::Runners::Mesh
12
12
  end
@@ -6,7 +6,7 @@ module Legion
6
6
  module Extensions
7
7
  module Mesh
8
8
  module Actor
9
- class PendingExpiry < Legion::Extensions::Actors::Every
9
+ class PendingExpiry < Legion::Extensions::Actors::Every # rubocop:disable Legion/Extension/EveryActorRequiresTime
10
10
  def runner_class
11
11
  Legion::Extensions::Mesh::Runners::Preferences
12
12
  end
@@ -31,10 +31,10 @@ module Legion
31
31
  Legion::Extensions::Mesh::Transport::Queues::Preference
32
32
  end
33
33
 
34
- def enabled?
34
+ def enabled? # rubocop:disable Legion/Extension/ActorEnabledSideEffects
35
35
  defined?(Legion::Extensions::Mesh::Runners::Preferences) &&
36
- defined?(Legion::Transport)
37
- rescue StandardError
36
+ Legion.const_defined?(:Transport, false)
37
+ rescue StandardError => _e
38
38
  false
39
39
  end
40
40
  end
@@ -6,7 +6,7 @@ module Legion
6
6
  module Extensions
7
7
  module Mesh
8
8
  module Actor
9
- class SilenceWatchdog < Legion::Extensions::Actors::Every
9
+ class SilenceWatchdog < Legion::Extensions::Actors::Every # rubocop:disable Legion/Extension/EveryActorRequiresTime
10
10
  def runner_class
11
11
  Legion::Extensions::Mesh::Runners::Mesh
12
12
  end
@@ -20,7 +20,7 @@ module Legion
20
20
 
21
21
  def initialize(**opts)
22
22
  @opts = opts
23
- @mesh_registry = Helpers::Registry.new
23
+ @mesh_registry = Helpers::Registry.new # rubocop:disable Legion/Singleton/UseInstance
24
24
  end
25
25
 
26
26
  private
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Mesh
6
+ module Helpers
7
+ class PeerTable
8
+ DEFAULT_TTL = 60 # seconds
9
+
10
+ def initialize(ttl: DEFAULT_TTL)
11
+ @ttl = ttl
12
+ @peers = {}
13
+ @mutex = Mutex.new
14
+ end
15
+
16
+ def upsert(agent_id, data = {})
17
+ @mutex.synchronize do
18
+ @peers[agent_id] = data.merge(last_seen_at: Time.now.utc)
19
+ end
20
+ end
21
+
22
+ def get(agent_id)
23
+ @mutex.synchronize { @peers[agent_id] }
24
+ end
25
+
26
+ def all
27
+ @mutex.synchronize { @peers.dup }
28
+ end
29
+
30
+ def expire
31
+ cutoff = Time.now.utc - @ttl
32
+ expired = []
33
+ @mutex.synchronize do
34
+ @peers.each do |id, entry|
35
+ next unless entry[:last_seen_at] < cutoff
36
+
37
+ expired << id
38
+ end
39
+ expired.each { |id| @peers.delete(id) }
40
+ end
41
+ expired
42
+ end
43
+
44
+ def count
45
+ @mutex.synchronize { @peers.size }
46
+ end
47
+
48
+ def remove(agent_id)
49
+ @mutex.synchronize { @peers.delete(agent_id) }
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -21,21 +21,21 @@ module Legion
21
21
  return { valid: false, org_id: org_id, reason: :unknown_peer } unless peer
22
22
 
23
23
  require 'ed25519'
24
- pub_key_b64 = peer[:public_key].sub(/\Aed25519:/, '')
24
+ pub_key_b64 = peer[:public_key].delete_prefix('ed25519:')
25
25
  verify_key = Ed25519::VerifyKey.new(Base64.strict_decode64(pub_key_b64))
26
26
  signature = Base64.strict_decode64(signed_message[:signature])
27
27
  message_bytes = signed_message[:signed_bytes] || json_dump(signed_message[:payload])
28
28
  verify_key.verify(signature, message_bytes)
29
29
  { valid: true, org_id: org_id }
30
- rescue Ed25519::VerifyError
30
+ rescue Ed25519::VerifyError => _e
31
31
  { valid: false, org_id: org_id, reason: :invalid_signature }
32
32
  rescue StandardError => e
33
33
  { valid: false, org_id: org_id, reason: :error, message: e.message }
34
34
  end
35
35
 
36
36
  def check_rate_limit(org_id)
37
- @counters ||= Hash.new { |h, k| h[k] = { count: 0, window_start: Time.now.utc } }
38
- counter = @counters[org_id]
37
+ @counters ||= Hash.new { |h, k| h[k] = { count: 0, window_start: Time.now.utc } } # rubocop:disable ThreadSafety/ClassInstanceVariable
38
+ counter = @counters[org_id] # rubocop:disable ThreadSafety/ClassInstanceVariable
39
39
  peer = find_peer(org_id)
40
40
  limit = peer&.dig(:rate_limit) || 100
41
41
 
@@ -51,7 +51,7 @@ module Legion
51
51
  end
52
52
 
53
53
  def reset_counters!
54
- @counters = nil
54
+ @counters = nil # rubocop:disable ThreadSafety/ClassInstanceVariable
55
55
  end
56
56
 
57
57
  private
@@ -65,7 +65,7 @@ module Legion
65
65
 
66
66
  def json_dump(data)
67
67
  if defined?(Legion::JSON)
68
- Legion::JSON.dump({ data: data })
68
+ Legion::JSON.dump({ data: data }) # rubocop:disable Legion/HelperMigration/DirectJson
69
69
  else
70
70
  require 'json'
71
71
  ::JSON.dump({ data: data })
@@ -21,7 +21,7 @@ module Legion
21
21
  end
22
22
  end
23
23
 
24
- def resolve(correlation_id:, result:) # rubocop:disable Naming/PredicateMethod
24
+ def resolve(correlation_id:, result:)
25
25
  entry = @mutex.synchronize { @requests.delete(correlation_id) }
26
26
  return false unless entry
27
27
 
@@ -124,8 +124,8 @@ module Legion
124
124
  MESH_CACHE_TTL = 3600 # 1 hour default
125
125
 
126
126
  def store_mesh_profile(agent_id:, profile:, source_agent_id:)
127
- @mesh_cache ||= {}
128
- @mesh_cache[agent_id.to_s] = {
127
+ @mesh_cache ||= {} # rubocop:disable ThreadSafety/ClassInstanceVariable
128
+ @mesh_cache[agent_id.to_s] = { # rubocop:disable ThreadSafety/ClassInstanceVariable
129
129
  profile: profile,
130
130
  source_agent_id: source_agent_id,
131
131
  origin: :mesh_transfer,
@@ -147,26 +147,26 @@ module Legion
147
147
  end
148
148
 
149
149
  def cached_mesh_profile(agent_id:, ttl: MESH_CACHE_TTL)
150
- @mesh_cache ||= {}
151
- entry = @mesh_cache[agent_id.to_s]
150
+ @mesh_cache ||= {} # rubocop:disable ThreadSafety/ClassInstanceVariable
151
+ entry = @mesh_cache[agent_id.to_s] # rubocop:disable ThreadSafety/ClassInstanceVariable
152
152
  return nil unless entry
153
153
 
154
154
  if Time.now - entry[:cached_at] > ttl
155
- @mesh_cache.delete(agent_id.to_s)
155
+ @mesh_cache.delete(agent_id.to_s) # rubocop:disable ThreadSafety/ClassInstanceVariable
156
156
  return nil
157
157
  end
158
158
 
159
159
  entry[:profile]
160
- rescue StandardError
160
+ rescue StandardError => _e
161
161
  nil
162
162
  end
163
163
 
164
164
  def clear_mesh_cache(agent_id: nil)
165
- @mesh_cache ||= {}
165
+ @mesh_cache ||= {} # rubocop:disable ThreadSafety/ClassInstanceVariable
166
166
  if agent_id
167
- @mesh_cache.delete(agent_id.to_s)
167
+ @mesh_cache.delete(agent_id.to_s) # rubocop:disable ThreadSafety/ClassInstanceVariable
168
168
  else
169
- @mesh_cache.clear
169
+ @mesh_cache.clear # rubocop:disable ThreadSafety/ClassInstanceVariable
170
170
  end
171
171
  end
172
172
 
@@ -214,7 +214,7 @@ module Legion
214
214
  traces.select { |t| t[:domain_tags]&.include?('preference') }.filter_map do |trace|
215
215
  parse_preference_trace(trace)
216
216
  end
217
- rescue StandardError
217
+ rescue StandardError => _e
218
218
  []
219
219
  end
220
220
 
@@ -226,7 +226,7 @@ module Legion
226
226
  return nil unless match
227
227
 
228
228
  { domain: match[1], value: match[2], source: match[3], confidence: trace[:confidence] }
229
- rescue StandardError
229
+ rescue StandardError => _e
230
230
  nil
231
231
  end
232
232
 
@@ -239,7 +239,7 @@ module Legion
239
239
  )
240
240
  result = personality_runner.personality_compatibility(other_profile: profile[:personality])
241
241
  { score: result[:compatibility], interpretation: result[:interpretation] }
242
- rescue StandardError
242
+ rescue StandardError => _e
243
243
  nil
244
244
  end
245
245
 
@@ -103,7 +103,7 @@ module Legion
103
103
 
104
104
  def local_node_name
105
105
  Legion::Settings[:client][:name]
106
- rescue StandardError
106
+ rescue StandardError => _e
107
107
  'unknown'
108
108
  end
109
109
  end
@@ -1,12 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'time'
4
+
3
5
  module Legion
4
6
  module Extensions
5
7
  module Mesh
6
8
  module Runners
7
9
  module Mesh
8
- include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
9
- Legion::Extensions::Helpers.const_defined?(:Lex)
10
+ include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers, false) &&
11
+ Legion::Extensions::Helpers.const_defined?(:Lex, false)
10
12
 
11
13
  def register(agent_id:, capabilities: [], endpoint: nil, **)
12
14
  mesh_registry.register_agent(agent_id, capabilities: capabilities, endpoint: endpoint)
@@ -65,7 +67,17 @@ module Legion
65
67
  end
66
68
 
67
69
  def publish_gossip(**)
70
+ expired = peer_table.expire
68
71
  registry = mesh_registry
72
+
73
+ Array(expired).each do |agent_id|
74
+ result = registry.unregister_agent(agent_id)
75
+ if result
76
+ log.info "[mesh] ttl-expired agent unregistered from registry: agent=#{agent_id}"
77
+ else
78
+ log.debug "[mesh] ttl-expired agent not found in registry: agent=#{agent_id}"
79
+ end
80
+ end
69
81
  peers = registry.all_agents.first(gossip_max_peers).map do |agent|
70
82
  agent.slice(:agent_id, :capabilities, :node, :source, :status, :generation,
71
83
  :last_seen, :registered_at).transform_values { |v| v.is_a?(Time) ? v.to_s : v }
@@ -78,36 +90,38 @@ module Legion
78
90
  { success: false, reason: :error, message: e.message }
79
91
  end
80
92
 
81
- def merge_gossip(incoming_peers:, sender: nil, **) # rubocop:disable Lint/UnusedMethodArgument
93
+ def merge_gossip(incoming_peers:, sender: nil, **)
82
94
  registry = mesh_registry
95
+ conflict_agents = []
83
96
  merged = 0
84
-
85
- incoming_peers.each do |peer|
86
- peer = peer.transform_keys(&:to_sym)
87
- next if peer[:node] == local_node_name
88
-
89
- local = registry.agents[peer[:agent_id]]
90
- if local.nil?
91
- registry.register_agent(
92
- peer[:agent_id],
93
- capabilities: (peer[:capabilities] || []).map(&:to_sym),
94
- source: (peer[:source] || :native).to_sym,
95
- node: peer[:node]
96
- )
97
- registry.agents[peer[:agent_id]][:generation] = peer[:generation] || 1
98
- merged += 1
99
- elsif (peer[:generation] || 0) > (local[:generation] || 0)
100
- local.merge!(peer.slice(:capabilities, :status, :generation, :last_seen))
101
- local[:capabilities] = (local[:capabilities] || []).map(&:to_sym)
102
- merged += 1
97
+ incoming_peers.each do |raw_peer|
98
+ peer = raw_peer.transform_keys(&:to_sym)
99
+ if peer[:node] == local_node_name
100
+ conflict_agents << peer[:agent_id] if sender && sender != local_node_name
101
+ next
103
102
  end
103
+ merged += 1 if upsert_remote_peer(peer, registry)
104
+ end
105
+
106
+ if conflict_agents.any?
107
+ publish_mesh_conflict(local_node: local_node_name, conflicting_node: sender,
108
+ conflict_agents: conflict_agents)
104
109
  end
105
110
 
106
- { success: true, merged: merged, total_peers: incoming_peers.size }
111
+ { success: true, merged: merged, total_peers: incoming_peers.size, conflicts: conflict_agents.size }
107
112
  rescue StandardError => e
108
113
  { success: false, reason: :error, message: e.message }
109
114
  end
110
115
 
116
+ def dispatch_gossip_message(type: nil, sender: nil, peers: [], **)
117
+ case type
118
+ when 'mesh_gossip'
119
+ merge_gossip(incoming_peers: peers, sender: sender)
120
+ else
121
+ { success: false, error: "unknown gossip message type: #{type}" }
122
+ end
123
+ end
124
+
111
125
  private
112
126
 
113
127
  def publish_mesh_departure(agent_id:, capabilities:)
@@ -131,21 +145,72 @@ module Legion
131
145
  ).publish
132
146
  end
133
147
 
148
+ def publish_mesh_conflict(local_node:, conflicting_node:, conflict_agents:)
149
+ return unless defined?(Legion::Extensions::Mesh::Transport::Messages::MeshConflict)
150
+
151
+ Legion::Extensions::Mesh::Transport::Messages::MeshConflict.new(
152
+ local_node: local_node,
153
+ conflicting_node: conflicting_node,
154
+ conflict_agents: conflict_agents
155
+ ).publish
156
+ log.warn "[mesh] split-brain detected: node=#{conflicting_node} claims agents on #{local_node}: #{conflict_agents.join(',')}"
157
+ rescue StandardError => e
158
+ log.warn "[mesh] failed to publish conflict signal: #{e.message}"
159
+ end
160
+
161
+ def upsert_remote_peer(peer, registry)
162
+ peer_table.upsert(peer[:agent_id], peer)
163
+ local = registry.agents[peer[:agent_id]]
164
+ if local.nil?
165
+ registry.register_agent(
166
+ peer[:agent_id],
167
+ capabilities: (peer[:capabilities] || []).map(&:to_sym),
168
+ source: (peer[:source] || :native).to_sym,
169
+ node: peer[:node]
170
+ )
171
+ entry = registry.agents[peer[:agent_id]]
172
+ entry[:generation] = peer[:generation] || 1
173
+ entry[:last_seen] = parse_last_seen(peer[:last_seen])
174
+ true
175
+ elsif (peer[:generation] || 0) > (local[:generation] || 0)
176
+ local.merge!(peer.slice(:capabilities, :status, :generation))
177
+ local[:capabilities] = (local[:capabilities] || []).map(&:to_sym)
178
+ local[:last_seen] = parse_last_seen(peer[:last_seen])
179
+ true
180
+ end
181
+ end
182
+
183
+ def parse_last_seen(value)
184
+ if value.nil?
185
+ Time.now.utc
186
+ elsif value.is_a?(Time)
187
+ value
188
+ else
189
+ Time.parse(value.to_s)
190
+ end
191
+ rescue ArgumentError => _e
192
+ Time.now.utc
193
+ end
194
+
134
195
  def gossip_max_peers
135
196
  settings = Legion::Settings.dig(:mesh, :gossip)
136
197
  (settings.is_a?(Hash) ? settings[:max_peers_per_message] : nil) || 100
137
- rescue StandardError
198
+ rescue StandardError => _e
138
199
  100
139
200
  end
140
201
 
141
202
  def local_node_name
142
203
  Legion::Settings[:client][:name]
143
- rescue StandardError
204
+ rescue StandardError => _e
144
205
  'unknown'
145
206
  end
146
207
 
147
208
  def mesh_registry
148
- @mesh_registry ||= Helpers::Registry.new
209
+ @mesh_registry ||= Helpers::Registry.new # rubocop:disable Legion/Singleton/UseInstance
210
+ end
211
+
212
+ def peer_table
213
+ @peer_table ||= Helpers::PeerTable.new
149
214
  end
150
215
  end
151
216
  end
@@ -7,8 +7,8 @@ module Legion
7
7
  module Mesh
8
8
  module Runners
9
9
  module Preferences
10
- include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
11
- Legion::Extensions::Helpers.const_defined?(:Lex)
10
+ include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers, false) &&
11
+ Legion::Extensions::Helpers.const_defined?(:Lex, false)
12
12
 
13
13
  def query_preferences(target_agent_id:, domains: nil, callback: nil, ttl: 5, **)
14
14
  default_profile = Helpers::PreferenceProfile.resolve(owner_id: target_agent_id)
@@ -96,7 +96,7 @@ module Legion
96
96
  else
97
97
  'unknown'
98
98
  end
99
- rescue StandardError
99
+ rescue StandardError => _e
100
100
  'unknown'
101
101
  end
102
102
 
@@ -110,7 +110,7 @@ module Legion
110
110
  correlation_id: msg[:correlation_id]
111
111
  ).publish
112
112
  rescue StandardError => e
113
- log_debug("[mesh] failed to publish preference response: #{e.message}")
113
+ log.debug("[mesh] failed to publish preference response: #{e.message}")
114
114
  end
115
115
 
116
116
  def publish_preference_query(target_agent_id:, correlation_id:, domains:)
@@ -125,7 +125,7 @@ module Legion
125
125
 
126
126
  def default_preference_callback(target_agent_id:)
127
127
  lambda do |_profile|
128
- log_debug("[mesh] received and cached preferences for #{target_agent_id}")
128
+ log.debug("[mesh] received and cached preferences for #{target_agent_id}")
129
129
  end
130
130
  end
131
131
 
@@ -138,11 +138,11 @@ module Legion
138
138
  evaluator = Object.new.extend(trust_mod)
139
139
  result = evaluator.get_trust(agent_id: agent_id, domain: :general)
140
140
 
141
- return :allowed unless result[:found]
141
+ return :allowed unless result[:found] # rubocop:disable Legion/Extension/RunnerReturnHash
142
142
 
143
143
  composite = result.dig(:trust, :composite) || 0.0
144
144
  composite >= Helpers::Topology::TRUST_CONSIDER_THRESHOLD ? :allowed : :denied
145
- rescue StandardError
145
+ rescue StandardError => _e
146
146
  :allowed
147
147
  end
148
148
 
@@ -10,7 +10,7 @@ module Legion
10
10
  module Extensions
11
11
  module Mesh
12
12
  module Runners
13
- module TaskRequest
13
+ module TaskRequest # rubocop:disable Legion/Extension/RunnerIncludeHelpers
14
14
  include Runners::Mesh
15
15
 
16
16
  DEFAULT_TIMEOUT = 30
@@ -65,10 +65,10 @@ module Legion
65
65
  private
66
66
 
67
67
  def resolve_target(to)
68
- return to if mesh_registry.agents.key?(to)
68
+ return to if mesh_registry.agents.key?(to) # rubocop:disable Legion/Extension/RunnerReturnHash
69
69
 
70
70
  agents = mesh_registry.find_by_capability(to.to_sym)
71
- return nil if agents.empty?
71
+ return nil if agents.empty? # rubocop:disable Legion/Extension/RunnerReturnHash
72
72
 
73
73
  online = agents.select { |a| a[:status] == :online }
74
74
  (online.empty? ? agents : online).sample[:agent_id]
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Mesh
6
+ module Transport
7
+ module Messages
8
+ class MeshConflict < Legion::Transport::Message
9
+ def exchange
10
+ Legion::Transport::Exchanges::Node
11
+ end
12
+
13
+ def routing_key
14
+ 'mesh.conflict'
15
+ end
16
+
17
+ def message
18
+ {
19
+ type: 'mesh_conflict',
20
+ local_node: @options[:local_node],
21
+ conflicting_node: @options[:conflicting_node],
22
+ conflict_agents: @options[:conflict_agents] || [],
23
+ detected_at: Time.now.to_s
24
+ }
25
+ end
26
+
27
+ def type
28
+ 'mesh_conflict'
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -3,7 +3,7 @@
3
3
  module Legion
4
4
  module Extensions
5
5
  module Mesh
6
- VERSION = '0.4.1'
6
+ VERSION = '0.4.3'
7
7
  end
8
8
  end
9
9
  end
@@ -7,22 +7,26 @@ require 'legion/extensions/mesh/helpers/preference_profile'
7
7
  require 'legion/extensions/mesh/helpers/pending_requests'
8
8
  require 'legion/extensions/mesh/helpers/delegation'
9
9
  require 'legion/extensions/mesh/helpers/peer_verify'
10
+ require 'legion/extensions/mesh/helpers/peer_table'
10
11
  require 'legion/extensions/mesh/runners/mesh'
11
12
  require 'legion/extensions/mesh/runners/preferences'
12
13
  require 'legion/extensions/mesh/runners/delegation'
13
14
  require 'legion/extensions/mesh/runners/task_request'
14
15
 
15
- if defined?(Legion::Transport)
16
+ if Legion.const_defined?(:Transport, false)
16
17
  require 'legion/extensions/mesh/transport/messages/preference_query'
17
18
  require 'legion/extensions/mesh/transport/messages/preference_response'
18
19
  require 'legion/extensions/mesh/transport/messages/mesh_departure'
20
+ require 'legion/extensions/mesh/transport/messages/gossip'
21
+ require 'legion/extensions/mesh/transport/messages/mesh_conflict'
19
22
  require 'legion/extensions/mesh/transport/queues/preference'
23
+ require 'legion/extensions/mesh/transport/queues/gossip'
20
24
  end
21
25
 
22
26
  module Legion
23
27
  module Extensions
24
28
  module Mesh
25
- extend Legion::Extensions::Core if Legion::Extensions.const_defined? :Core
29
+ extend Legion::Extensions::Core if Legion::Extensions.const_defined? :Core, false
26
30
  end
27
31
  end
28
32
  end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Stub the framework subscription base class
4
+ unless defined?(Legion::Extensions::Actors::Subscription)
5
+ module Legion
6
+ module Extensions
7
+ module Actors
8
+ class Subscription # rubocop:disable Lint/EmptyClass
9
+ end
10
+ end
11
+ end
12
+ end
13
+
14
+ $LOADED_FEATURES << 'legion/extensions/actors/subscription'
15
+ end
16
+
17
+ # Stub transport queue class if not loaded
18
+ unless defined?(Legion::Extensions::Mesh::Transport::Queues::Gossip)
19
+ module Legion
20
+ module Extensions
21
+ module Mesh
22
+ module Transport
23
+ module Queues
24
+ class Gossip # rubocop:disable Lint/EmptyClass
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+
33
+ require 'legion/extensions/mesh/runners/mesh'
34
+ require 'legion/extensions/mesh/actors/gossip_listener'
35
+
36
+ RSpec.describe Legion::Extensions::Mesh::Actor::GossipListener do
37
+ subject(:actor) { described_class.new }
38
+
39
+ describe '#runner_class' do
40
+ it 'returns the Mesh runner module' do
41
+ expect(actor.runner_class).to eq(Legion::Extensions::Mesh::Runners::Mesh)
42
+ end
43
+ end
44
+
45
+ describe '#runner_function' do
46
+ it 'returns dispatch_gossip_message' do
47
+ expect(actor.runner_function).to eq('dispatch_gossip_message')
48
+ end
49
+ end
50
+
51
+ describe '#use_runner?' do
52
+ it 'returns false (actor handles dispatch directly without runner pipeline)' do
53
+ expect(actor.use_runner?).to be false
54
+ end
55
+ end
56
+
57
+ describe '#check_subtask?' do
58
+ it 'returns false' do
59
+ expect(actor.check_subtask?).to be false
60
+ end
61
+ end
62
+
63
+ describe '#generate_task?' do
64
+ it 'returns false' do
65
+ expect(actor.generate_task?).to be false
66
+ end
67
+ end
68
+
69
+ describe '#queue' do
70
+ it 'returns the Gossip queue class' do
71
+ expect(actor.queue).to eq(Legion::Extensions::Mesh::Transport::Queues::Gossip)
72
+ end
73
+ end
74
+
75
+ describe '#enabled?' do
76
+ context 'when Mesh runner and Transport are both available' do
77
+ it 'returns truthy' do
78
+ stub_const('Legion::Transport', Module.new)
79
+ expect(actor.enabled?).to be_truthy
80
+ end
81
+ end
82
+
83
+ context 'when Transport is not available' do
84
+ it 'returns falsey' do
85
+ hide_const('Legion::Transport')
86
+ expect(actor.enabled?).to be_falsey
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'legion/extensions/mesh/helpers/peer_table'
5
+
6
+ RSpec.describe Legion::Extensions::Mesh::Helpers::PeerTable do
7
+ subject(:table) { described_class.new(ttl: 60) }
8
+
9
+ describe '#upsert / #get' do
10
+ it 'stores and retrieves a peer entry' do
11
+ table.upsert('agent-1', { node: 'node-01' })
12
+ entry = table.get('agent-1')
13
+ expect(entry[:node]).to eq('node-01')
14
+ end
15
+
16
+ it 'sets last_seen_at on upsert' do
17
+ table.upsert('agent-1', {})
18
+ expect(table.get('agent-1')[:last_seen_at]).to be_a(Time)
19
+ end
20
+
21
+ it 'overwrites an existing entry on re-upsert' do
22
+ table.upsert('agent-1', { node: 'node-01' })
23
+ table.upsert('agent-1', { node: 'node-02' })
24
+ expect(table.get('agent-1')[:node]).to eq('node-02')
25
+ end
26
+
27
+ it 'returns nil for unknown agent' do
28
+ expect(table.get('missing')).to be_nil
29
+ end
30
+ end
31
+
32
+ describe '#expire' do
33
+ let(:short_ttl_table) { described_class.new(ttl: 1) }
34
+
35
+ it 'removes peers whose last_seen_at is older than TTL' do
36
+ short_ttl_table.upsert('old-agent', {})
37
+ entry = short_ttl_table.instance_variable_get(:@peers)['old-agent']
38
+ entry[:last_seen_at] = Time.now.utc - 120
39
+ expired = short_ttl_table.expire
40
+ expect(expired).to include('old-agent')
41
+ expect(short_ttl_table.get('old-agent')).to be_nil
42
+ end
43
+
44
+ it 'keeps peers within TTL' do
45
+ short_ttl_table.upsert('fresh-agent', {})
46
+ short_ttl_table.expire
47
+ expect(short_ttl_table.get('fresh-agent')).not_to be_nil
48
+ end
49
+
50
+ it 'returns an array of expired agent IDs' do
51
+ short_ttl_table.upsert('e1', {})
52
+ short_ttl_table.upsert('e2', {})
53
+ short_ttl_table.instance_variable_get(:@peers).each_value { |e| e[:last_seen_at] = Time.now.utc - 120 }
54
+ expired = short_ttl_table.expire
55
+ expect(expired).to contain_exactly('e1', 'e2')
56
+ end
57
+
58
+ it 'returns empty array when nothing is expired' do
59
+ table.upsert('fresh', {})
60
+ expect(table.expire).to be_empty
61
+ end
62
+ end
63
+
64
+ describe 'thread safety' do
65
+ it 'handles concurrent upserts without data corruption' do
66
+ threads = Array.new(10) do |i|
67
+ Thread.new { table.upsert("agent-#{i}", { node: "node-#{i}" }) }
68
+ end
69
+ threads.each(&:join)
70
+ expect(table.count).to eq(10)
71
+ end
72
+ end
73
+
74
+ describe '#count' do
75
+ it 'reflects the number of stored peers' do
76
+ table.upsert('a1', {})
77
+ table.upsert('a2', {})
78
+ expect(table.count).to eq(2)
79
+ end
80
+ end
81
+
82
+ describe '#remove' do
83
+ it 'removes a specific peer' do
84
+ table.upsert('agent-1', {})
85
+ table.remove('agent-1')
86
+ expect(table.get('agent-1')).to be_nil
87
+ end
88
+
89
+ it 'does nothing for unknown agent' do
90
+ expect { table.remove('missing') }.not_to raise_error
91
+ end
92
+ end
93
+
94
+ describe '#all' do
95
+ it 'returns a copy of all peers' do
96
+ table.upsert('a1', { node: 'n1' })
97
+ all = table.all
98
+ expect(all.keys).to include('a1')
99
+ all.delete('a1')
100
+ expect(table.get('a1')).not_to be_nil
101
+ end
102
+ end
103
+ end
@@ -7,7 +7,7 @@ RSpec.describe 'Gossip runner methods' do
7
7
 
8
8
  before do
9
9
  allow(subject).to receive(:mesh_registry).and_return(
10
- Legion::Extensions::Mesh::Helpers::Registry.new
10
+ Legion::Extensions::Mesh::Helpers::Registry.new # rubocop:disable Legion/Singleton/UseInstance
11
11
  )
12
12
  end
13
13
 
@@ -60,6 +60,49 @@ RSpec.describe 'Gossip runner methods' do
60
60
  result = subject.merge_gossip(incoming_peers: incoming, sender: 'node-02')
61
61
  expect(result[:merged]).to eq(0)
62
62
  end
63
+
64
+ context 'split-brain detection' do
65
+ before do
66
+ allow(subject).to receive(:local_node_name).and_return('local-node')
67
+ allow(subject).to receive(:publish_mesh_conflict)
68
+ end
69
+
70
+ it 'detects a conflict when incoming peers claim the local node from a different sender' do
71
+ incoming = [
72
+ { agent_id: 'local-agent', node: 'local-node', generation: 1 }
73
+ ]
74
+ result = subject.merge_gossip(incoming_peers: incoming, sender: 'other-node')
75
+ expect(result[:conflicts]).to eq(1)
76
+ end
77
+
78
+ it 'publishes MeshConflict when split-brain is detected' do
79
+ incoming = [
80
+ { agent_id: 'local-agent', node: 'local-node', generation: 1 }
81
+ ]
82
+ subject.merge_gossip(incoming_peers: incoming, sender: 'other-node')
83
+ expect(subject).to have_received(:publish_mesh_conflict).with(
84
+ local_node: 'local-node',
85
+ conflicting_node: 'other-node',
86
+ conflict_agents: ['local-agent']
87
+ )
88
+ end
89
+
90
+ it 'does not publish MeshConflict when sender is the local node' do
91
+ incoming = [
92
+ { agent_id: 'local-agent', node: 'local-node', generation: 1 }
93
+ ]
94
+ subject.merge_gossip(incoming_peers: incoming, sender: 'local-node')
95
+ expect(subject).not_to have_received(:publish_mesh_conflict)
96
+ end
97
+
98
+ it 'reports zero conflicts when no peers claim the local node' do
99
+ incoming = [
100
+ { agent_id: 'remote-agent', node: 'remote-node', generation: 1 }
101
+ ]
102
+ result = subject.merge_gossip(incoming_peers: incoming, sender: 'remote-node')
103
+ expect(result[:conflicts]).to eq(0)
104
+ end
105
+ end
63
106
  end
64
107
 
65
108
  describe '#publish_gossip' do
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ unless defined?(Legion::Transport::Message)
6
+ module Legion
7
+ module Transport
8
+ class Message
9
+ def initialize(**options)
10
+ @options = options
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
16
+
17
+ unless defined?(Legion::Transport::Exchanges::Node)
18
+ module Legion
19
+ module Transport
20
+ module Exchanges
21
+ class Node # rubocop:disable Lint/EmptyClass
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+
28
+ require 'legion/extensions/mesh/transport/messages/mesh_conflict'
29
+
30
+ RSpec.describe Legion::Extensions::Mesh::Transport::Messages::MeshConflict do
31
+ subject(:msg) do
32
+ described_class.new(
33
+ local_node: 'node-01',
34
+ conflicting_node: 'node-02',
35
+ conflict_agents: %w[agent-a agent-b]
36
+ )
37
+ end
38
+
39
+ describe '#exchange' do
40
+ it 'returns the Node exchange' do
41
+ expect(msg.exchange).to eq(Legion::Transport::Exchanges::Node)
42
+ end
43
+ end
44
+
45
+ describe '#routing_key' do
46
+ it 'uses mesh.conflict' do
47
+ expect(msg.routing_key).to eq('mesh.conflict')
48
+ end
49
+ end
50
+
51
+ describe '#message' do
52
+ it 'includes type mesh_conflict' do
53
+ expect(msg.message[:type]).to eq('mesh_conflict')
54
+ end
55
+
56
+ it 'includes local_node' do
57
+ expect(msg.message[:local_node]).to eq('node-01')
58
+ end
59
+
60
+ it 'includes conflicting_node' do
61
+ expect(msg.message[:conflicting_node]).to eq('node-02')
62
+ end
63
+
64
+ it 'includes conflict_agents list' do
65
+ expect(msg.message[:conflict_agents]).to eq(%w[agent-a agent-b])
66
+ end
67
+
68
+ it 'includes detected_at timestamp string' do
69
+ expect(msg.message[:detected_at]).to be_a(String)
70
+ end
71
+ end
72
+
73
+ describe '#type' do
74
+ it 'returns mesh_conflict' do
75
+ expect(msg.type).to eq('mesh_conflict')
76
+ end
77
+ end
78
+
79
+ describe 'conflict_agents default' do
80
+ subject(:msg_no_agents) do
81
+ described_class.new(local_node: 'node-01', conflicting_node: 'node-02')
82
+ end
83
+
84
+ it 'defaults conflict_agents to empty array' do
85
+ expect(msg_no_agents.message[:conflict_agents]).to eq([])
86
+ end
87
+ end
88
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lex-mesh
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.1
4
+ version: 0.4.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -147,12 +147,14 @@ files:
147
147
  - lex-mesh.gemspec
148
148
  - lib/legion/extensions/mesh.rb
149
149
  - lib/legion/extensions/mesh/actors/gossip.rb
150
+ - lib/legion/extensions/mesh/actors/gossip_listener.rb
150
151
  - lib/legion/extensions/mesh/actors/heartbeat.rb
151
152
  - lib/legion/extensions/mesh/actors/pending_expiry.rb
152
153
  - lib/legion/extensions/mesh/actors/preference_listener.rb
153
154
  - lib/legion/extensions/mesh/actors/silence_watchdog.rb
154
155
  - lib/legion/extensions/mesh/client.rb
155
156
  - lib/legion/extensions/mesh/helpers/delegation.rb
157
+ - lib/legion/extensions/mesh/helpers/peer_table.rb
156
158
  - lib/legion/extensions/mesh/helpers/peer_verify.rb
157
159
  - lib/legion/extensions/mesh/helpers/pending_requests.rb
158
160
  - lib/legion/extensions/mesh/helpers/preference_profile.rb
@@ -163,12 +165,14 @@ files:
163
165
  - lib/legion/extensions/mesh/runners/preferences.rb
164
166
  - lib/legion/extensions/mesh/runners/task_request.rb
165
167
  - lib/legion/extensions/mesh/transport/messages/gossip.rb
168
+ - lib/legion/extensions/mesh/transport/messages/mesh_conflict.rb
166
169
  - lib/legion/extensions/mesh/transport/messages/mesh_departure.rb
167
170
  - lib/legion/extensions/mesh/transport/messages/preference_query.rb
168
171
  - lib/legion/extensions/mesh/transport/messages/preference_response.rb
169
172
  - lib/legion/extensions/mesh/transport/queues/gossip.rb
170
173
  - lib/legion/extensions/mesh/transport/queues/preference.rb
171
174
  - lib/legion/extensions/mesh/version.rb
175
+ - spec/legion/extensions/mesh/actors/gossip_listener_spec.rb
172
176
  - spec/legion/extensions/mesh/actors/gossip_spec.rb
173
177
  - spec/legion/extensions/mesh/actors/heartbeat_spec.rb
174
178
  - spec/legion/extensions/mesh/actors/pending_expiry_spec.rb
@@ -176,6 +180,7 @@ files:
176
180
  - spec/legion/extensions/mesh/actors/silence_watchdog_spec.rb
177
181
  - spec/legion/extensions/mesh/client_spec.rb
178
182
  - spec/legion/extensions/mesh/helpers/delegation_spec.rb
183
+ - spec/legion/extensions/mesh/helpers/peer_table_spec.rb
179
184
  - spec/legion/extensions/mesh/helpers/peer_verify_spec.rb
180
185
  - spec/legion/extensions/mesh/helpers/pending_requests_spec.rb
181
186
  - spec/legion/extensions/mesh/helpers/preference_profile_spec.rb
@@ -187,6 +192,7 @@ files:
187
192
  - spec/legion/extensions/mesh/runners/preferences_spec.rb
188
193
  - spec/legion/extensions/mesh/runners/task_request_spec.rb
189
194
  - spec/legion/extensions/mesh/transport/messages/gossip_spec.rb
195
+ - spec/legion/extensions/mesh/transport/messages/mesh_conflict_spec.rb
190
196
  - spec/legion/extensions/mesh/transport/messages/mesh_departure_spec.rb
191
197
  - spec/legion/extensions/mesh/transport/messages/preference_query_spec.rb
192
198
  - spec/legion/extensions/mesh/transport/messages/preference_response_spec.rb