lex-mesh 0.4.2 → 0.4.4
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 +4 -4
- data/lib/legion/extensions/mesh/actors/gossip_listener.rb +44 -0
- data/lib/legion/extensions/mesh/helpers/peer_table.rb +55 -0
- data/lib/legion/extensions/mesh/helpers/preference_profile.rb +80 -0
- data/lib/legion/extensions/mesh/runners/mesh.rb +86 -21
- data/lib/legion/extensions/mesh/transport/messages/mesh_conflict.rb +35 -0
- data/lib/legion/extensions/mesh/version.rb +1 -1
- data/lib/legion/extensions/mesh.rb +4 -0
- data/spec/legion/extensions/mesh/actors/gossip_listener_spec.rb +90 -0
- data/spec/legion/extensions/mesh/helpers/peer_table_spec.rb +103 -0
- data/spec/legion/extensions/mesh/helpers/preference_profile_spec.rb +75 -0
- data/spec/legion/extensions/mesh/runners/mesh_gossip_spec.rb +43 -0
- data/spec/legion/extensions/mesh/transport/messages/mesh_conflict_spec.rb +88 -0
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 983bda71103db47ebf1fd1a9087afa36ac9073b108aed204f579204718f84672
|
|
4
|
+
data.tar.gz: 5a2a670f327317978f84f10684ba0b6f09031065e4402bece53309ccab96462e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 98a5c88d848ad732100824f8c6bcf4657413bb2fae5137a0a8d46cde3df54888596ddab738e4716a291e28cdfc6c81758d0cfdef73feaef70046e933983dd3e4
|
|
7
|
+
data.tar.gz: c3e8d3eec443be56de9b29817aff18ce83a7b98a5d84ceac5bf702206c33dd987990e77c31cf3c08359825b3916f44535747ad8419259201d6fcbbbff9c0309a
|
|
@@ -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
|
|
@@ -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
|
|
@@ -246,6 +246,86 @@ module Legion
|
|
|
246
246
|
def personality_available?
|
|
247
247
|
defined?(Legion::Extensions::Agentic::Self::Personality::Runners::Personality)
|
|
248
248
|
end
|
|
249
|
+
|
|
250
|
+
OBSERVATION_THRESHOLD = 20
|
|
251
|
+
|
|
252
|
+
def update_from_observation(owner_id:, signals:)
|
|
253
|
+
@observation_counts ||= {} # rubocop:disable ThreadSafety/ClassInstanceVariable
|
|
254
|
+
@observation_signals ||= {} # rubocop:disable ThreadSafety/ClassInstanceVariable
|
|
255
|
+
|
|
256
|
+
key = owner_id.to_s
|
|
257
|
+
@observation_counts[key] = (@observation_counts[key] || 0) + 1 # rubocop:disable ThreadSafety/ClassInstanceVariable
|
|
258
|
+
@observation_signals[key] ||= [] # rubocop:disable ThreadSafety/ClassInstanceVariable
|
|
259
|
+
@observation_signals[key] << signals # rubocop:disable ThreadSafety/ClassInstanceVariable
|
|
260
|
+
|
|
261
|
+
derive_and_store_observations(owner_id: key) if @observation_counts[key] >= OBSERVATION_THRESHOLD # rubocop:disable ThreadSafety/ClassInstanceVariable
|
|
262
|
+
|
|
263
|
+
{ updated: true }
|
|
264
|
+
rescue StandardError => _e
|
|
265
|
+
{ updated: false }
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def observation_counts
|
|
269
|
+
@observation_counts ||= {} # rubocop:disable ThreadSafety/ClassInstanceVariable
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def inferred_preferences(owner_id)
|
|
273
|
+
@inferred_preferences ||= {} # rubocop:disable ThreadSafety/ClassInstanceVariable
|
|
274
|
+
@inferred_preferences[owner_id.to_s] || [] # rubocop:disable ThreadSafety/ClassInstanceVariable
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def clear_observations
|
|
278
|
+
@observation_counts = {} # rubocop:disable ThreadSafety/ClassInstanceVariable
|
|
279
|
+
@observation_signals = {} # rubocop:disable ThreadSafety/ClassInstanceVariable
|
|
280
|
+
@inferred_preferences = {} # rubocop:disable ThreadSafety/ClassInstanceVariable
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def derive_and_store_observations(owner_id:)
|
|
284
|
+
@observation_signals ||= {} # rubocop:disable ThreadSafety/ClassInstanceVariable
|
|
285
|
+
signals = @observation_signals[owner_id] || [] # rubocop:disable ThreadSafety/ClassInstanceVariable
|
|
286
|
+
return if signals.empty?
|
|
287
|
+
|
|
288
|
+
inferred = []
|
|
289
|
+
inferred << derive_verbosity(signals)
|
|
290
|
+
inferred << derive_tone(signals)
|
|
291
|
+
inferred << derive_format(signals)
|
|
292
|
+
inferred = inferred.compact
|
|
293
|
+
|
|
294
|
+
@inferred_preferences ||= {} # rubocop:disable ThreadSafety/ClassInstanceVariable
|
|
295
|
+
@inferred_preferences[owner_id] = inferred # rubocop:disable ThreadSafety/ClassInstanceVariable
|
|
296
|
+
|
|
297
|
+
inferred.each do |pref|
|
|
298
|
+
store_preference(
|
|
299
|
+
owner_id: owner_id,
|
|
300
|
+
domain: pref[:domain],
|
|
301
|
+
value: pref[:value],
|
|
302
|
+
source: pref[:source]
|
|
303
|
+
)
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def derive_verbosity(signals)
|
|
308
|
+
cli_count = signals.count { |s| s[:channel] == :cli }
|
|
309
|
+
cli_ratio = cli_count.to_f / signals.size
|
|
310
|
+
return nil unless cli_ratio >= 0.5
|
|
311
|
+
|
|
312
|
+
{ domain: 'verbosity', value: 'concise', source: 'observation', confidence: 0.65 }
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def derive_tone(signals)
|
|
316
|
+
da_count = signals.count { |s| s[:direct_address] }
|
|
317
|
+
da_ratio = da_count.to_f / signals.size
|
|
318
|
+
return nil unless da_ratio >= 0.5
|
|
319
|
+
|
|
320
|
+
{ domain: 'tone', value: 'conversational', source: 'observation', confidence: 0.65 }
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def derive_format(signals)
|
|
324
|
+
unique_channels = signals.map { |s| s[:channel] }.uniq
|
|
325
|
+
return nil unless unique_channels.size >= 3
|
|
326
|
+
|
|
327
|
+
{ domain: 'format', value: 'adaptive', source: 'observation', confidence: 0.55 }
|
|
328
|
+
end
|
|
249
329
|
end
|
|
250
330
|
end
|
|
251
331
|
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'time'
|
|
4
|
+
|
|
3
5
|
module Legion
|
|
4
6
|
module Extensions
|
|
5
7
|
module Mesh
|
|
@@ -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, **)
|
|
93
|
+
def merge_gossip(incoming_peers:, sender: nil, **)
|
|
82
94
|
registry = mesh_registry
|
|
95
|
+
conflict_agents = []
|
|
83
96
|
merged = 0
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
peer
|
|
87
|
-
|
|
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,6 +145,53 @@ 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
|
|
@@ -147,6 +208,10 @@ module Legion
|
|
|
147
208
|
def mesh_registry
|
|
148
209
|
@mesh_registry ||= Helpers::Registry.new # rubocop:disable Legion/Singleton/UseInstance
|
|
149
210
|
end
|
|
211
|
+
|
|
212
|
+
def peer_table
|
|
213
|
+
@peer_table ||= Helpers::PeerTable.new
|
|
214
|
+
end
|
|
150
215
|
end
|
|
151
216
|
end
|
|
152
217
|
end
|
|
@@ -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
|
|
@@ -7,6 +7,7 @@ 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'
|
|
@@ -16,7 +17,10 @@ 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
|
|
@@ -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
|
|
@@ -191,6 +191,81 @@ RSpec.describe Legion::Extensions::Mesh::Helpers::PreferenceProfile do
|
|
|
191
191
|
end
|
|
192
192
|
end
|
|
193
193
|
|
|
194
|
+
describe '.update_from_observation' do
|
|
195
|
+
before { described_class.clear_observations }
|
|
196
|
+
|
|
197
|
+
let(:cli_signal) { { content_type: :text, channel: :cli, direct_address: false } }
|
|
198
|
+
let(:chat_signal) { { content_type: :text, channel: :chat, direct_address: true } }
|
|
199
|
+
let(:mixed_signal) { { content_type: :text, channel: :api, direct_address: false } }
|
|
200
|
+
|
|
201
|
+
it 'returns { updated: true } on each call' do
|
|
202
|
+
result = described_class.update_from_observation(owner_id: 'user-obs', signals: cli_signal)
|
|
203
|
+
expect(result).to eq({ updated: true })
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
it 'accumulates signals per owner_id' do
|
|
207
|
+
5.times { described_class.update_from_observation(owner_id: 'user-obs', signals: cli_signal) }
|
|
208
|
+
counts = described_class.observation_counts
|
|
209
|
+
expect(counts['user-obs']).to eq(5)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
it 'does not infer preferences before threshold (20)' do
|
|
213
|
+
19.times { described_class.update_from_observation(owner_id: 'user-obs', signals: cli_signal) }
|
|
214
|
+
inferred = described_class.inferred_preferences('user-obs')
|
|
215
|
+
expect(inferred).to be_empty
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
it 'derives :concise verbosity for CLI-heavy usage after threshold' do
|
|
219
|
+
20.times { described_class.update_from_observation(owner_id: 'user-cli', signals: cli_signal) }
|
|
220
|
+
inferred = described_class.inferred_preferences('user-cli')
|
|
221
|
+
verbosity = inferred.find { |p| p[:domain] == 'verbosity' }
|
|
222
|
+
expect(verbosity).not_to be_nil
|
|
223
|
+
expect(verbosity[:value]).to eq('concise')
|
|
224
|
+
expect(verbosity[:source]).to eq('observation')
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
it 'derives :conversational tone for high direct_address ratio after threshold' do
|
|
228
|
+
20.times { described_class.update_from_observation(owner_id: 'user-da', signals: chat_signal) }
|
|
229
|
+
inferred = described_class.inferred_preferences('user-da')
|
|
230
|
+
tone = inferred.find { |p| p[:domain] == 'tone' }
|
|
231
|
+
expect(tone).not_to be_nil
|
|
232
|
+
expect(tone[:value]).to eq('conversational')
|
|
233
|
+
expect(tone[:source]).to eq('observation')
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
it 'derives :adaptive format for mixed channel usage after threshold' do
|
|
237
|
+
channels = %i[cli api chat rest]
|
|
238
|
+
20.times.with_index do |i, _|
|
|
239
|
+
ch = channels[i % channels.length]
|
|
240
|
+
described_class.update_from_observation(
|
|
241
|
+
owner_id: 'user-mix',
|
|
242
|
+
signals: { content_type: :text, channel: ch, direct_address: false }
|
|
243
|
+
)
|
|
244
|
+
end
|
|
245
|
+
inferred = described_class.inferred_preferences('user-mix')
|
|
246
|
+
format = inferred.find { |p| p[:domain] == 'format' }
|
|
247
|
+
expect(format).not_to be_nil
|
|
248
|
+
expect(format[:value]).to eq('adaptive')
|
|
249
|
+
expect(format[:source]).to eq('observation')
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
it 'keeps separate observation counts per owner_id' do
|
|
253
|
+
3.times { described_class.update_from_observation(owner_id: 'user-a', signals: cli_signal) }
|
|
254
|
+
7.times { described_class.update_from_observation(owner_id: 'user-b', signals: cli_signal) }
|
|
255
|
+
counts = described_class.observation_counts
|
|
256
|
+
expect(counts['user-a']).to eq(3)
|
|
257
|
+
expect(counts['user-b']).to eq(7)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
it 'calls store_preference for each inferred preference after threshold' do
|
|
261
|
+
allow(described_class).to receive(:store_preference).and_call_original
|
|
262
|
+
20.times { described_class.update_from_observation(owner_id: 'user-store', signals: cli_signal) }
|
|
263
|
+
expect(described_class).to have_received(:store_preference).with(
|
|
264
|
+
hash_including(owner_id: 'user-store', source: 'observation')
|
|
265
|
+
).at_least(:once)
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
194
269
|
describe '.preference_instructions' do
|
|
195
270
|
it 'generates natural language prompt instructions from profile' do
|
|
196
271
|
profile = {
|
|
@@ -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.
|
|
4
|
+
version: 0.4.4
|
|
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
|