lex-mesh 0.2.4 → 0.3.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.
- checksums.yaml +4 -4
- data/lib/legion/extensions/mesh/actors/gossip.rb +41 -0
- data/lib/legion/extensions/mesh/client.rb +4 -0
- data/lib/legion/extensions/mesh/helpers/registry.rb +17 -1
- data/lib/legion/extensions/mesh/runners/mesh.rb +67 -0
- data/lib/legion/extensions/mesh/runners/task_request.rb +88 -0
- data/lib/legion/extensions/mesh/transport/messages/gossip.rb +38 -0
- data/lib/legion/extensions/mesh/transport/queues/gossip.rb +25 -0
- data/lib/legion/extensions/mesh/version.rb +1 -1
- data/lib/legion/extensions/mesh.rb +1 -0
- data/spec/legion/extensions/mesh/actors/gossip_spec.rb +28 -0
- data/spec/legion/extensions/mesh/client_spec.rb +22 -0
- data/spec/legion/extensions/mesh/helpers/registry_spec.rb +26 -0
- data/spec/legion/extensions/mesh/runners/mesh_gossip_spec.rb +73 -0
- data/spec/legion/extensions/mesh/runners/task_request_spec.rb +91 -0
- data/spec/legion/extensions/mesh/transport/messages/gossip_spec.rb +54 -0
- metadata +9 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d736de5644beff024c6acc40ededd1eface48a64dc919b4f1033e0971a7af71f
|
|
4
|
+
data.tar.gz: e58b0661aab721ebaf1c7f413691a41f2a5f0f1e455710b3d27f79ad687655fb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d1abfff1fe01a29d3b9be8ee75c5f949f753a9fdf33df340e1362ef758236fb411a826eb0ae3c448bc330b8bc06cff725f35d10b5767f39ca7a8ffcb06969782
|
|
7
|
+
data.tar.gz: e1266fc12dd23f183439f2ed5c1d2a21a1c11b824652e918b6fc398df40eea46cc5aa0dbabbb14e99b8f41aa4b0cd2e99d71a259aaba8de595088c51336c7a62
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/actors/every'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Extensions
|
|
7
|
+
module Mesh
|
|
8
|
+
module Actor
|
|
9
|
+
class Gossip < Legion::Extensions::Actors::Every
|
|
10
|
+
def runner_class
|
|
11
|
+
Legion::Extensions::Mesh::Runners::Mesh
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def runner_function
|
|
15
|
+
:publish_gossip
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def time
|
|
19
|
+
15
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def use_runner?
|
|
23
|
+
true
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def check_subtask?
|
|
27
|
+
false
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def generate_task?
|
|
31
|
+
false
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def args
|
|
35
|
+
{}
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require 'legion/extensions/mesh/runners/mesh'
|
|
4
4
|
require 'legion/extensions/mesh/runners/preferences'
|
|
5
|
+
require 'legion/extensions/mesh/runners/delegation'
|
|
6
|
+
require 'legion/extensions/mesh/runners/task_request'
|
|
5
7
|
require 'legion/extensions/mesh/helpers/preference_profile'
|
|
6
8
|
require 'legion/extensions/mesh/helpers/pending_requests'
|
|
7
9
|
require 'legion/extensions/mesh/helpers/topology'
|
|
@@ -13,6 +15,8 @@ module Legion
|
|
|
13
15
|
class Client
|
|
14
16
|
include Runners::Mesh
|
|
15
17
|
include Runners::Preferences
|
|
18
|
+
include Runners::Delegation
|
|
19
|
+
include Runners::TaskRequest
|
|
16
20
|
|
|
17
21
|
def initialize(**opts)
|
|
18
22
|
@opts = opts
|
|
@@ -13,11 +13,14 @@ module Legion
|
|
|
13
13
|
@messages = []
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
-
def register_agent(agent_id, capabilities: [], endpoint: nil)
|
|
16
|
+
def register_agent(agent_id, capabilities: [], endpoint: nil, source: :native, node: nil)
|
|
17
17
|
@agents[agent_id] = {
|
|
18
18
|
agent_id: agent_id,
|
|
19
19
|
capabilities: capabilities,
|
|
20
20
|
endpoint: endpoint,
|
|
21
|
+
source: source,
|
|
22
|
+
node: node || local_node_name,
|
|
23
|
+
generation: 1,
|
|
21
24
|
registered_at: Time.now.utc,
|
|
22
25
|
last_seen: Time.now.utc,
|
|
23
26
|
status: :online
|
|
@@ -39,6 +42,7 @@ module Legion
|
|
|
39
42
|
|
|
40
43
|
agent[:last_seen] = Time.now.utc
|
|
41
44
|
agent[:status] = :online
|
|
45
|
+
agent[:generation] = (agent[:generation] || 0) + 1
|
|
42
46
|
end
|
|
43
47
|
|
|
44
48
|
def find_by_capability(capability)
|
|
@@ -81,9 +85,21 @@ module Legion
|
|
|
81
85
|
@agents.values.select { |a| a[:status] == :online }
|
|
82
86
|
end
|
|
83
87
|
|
|
88
|
+
def all_agents
|
|
89
|
+
@agents.values
|
|
90
|
+
end
|
|
91
|
+
|
|
84
92
|
def count
|
|
85
93
|
@agents.size
|
|
86
94
|
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def local_node_name
|
|
99
|
+
Legion::Settings[:client][:name]
|
|
100
|
+
rescue StandardError
|
|
101
|
+
'unknown'
|
|
102
|
+
end
|
|
87
103
|
end
|
|
88
104
|
end
|
|
89
105
|
end
|
|
@@ -64,6 +64,50 @@ module Legion
|
|
|
64
64
|
{ success: true, expired: expired, count: expired.size }
|
|
65
65
|
end
|
|
66
66
|
|
|
67
|
+
def publish_gossip(**)
|
|
68
|
+
registry = mesh_registry
|
|
69
|
+
peers = registry.all_agents.first(gossip_max_peers).map do |agent|
|
|
70
|
+
agent.slice(:agent_id, :capabilities, :node, :source, :status, :generation,
|
|
71
|
+
:last_seen, :registered_at).transform_values { |v| v.is_a?(Time) ? v.to_s : v }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
@gossip_round = (@gossip_round || 0) + 1
|
|
75
|
+
publish_gossip_message(peers)
|
|
76
|
+
{ success: true, peers_broadcast: peers.size, gossip_round: @gossip_round }
|
|
77
|
+
rescue StandardError => e
|
|
78
|
+
{ success: false, reason: :error, message: e.message }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def merge_gossip(incoming_peers:, sender: nil, **) # rubocop:disable Lint/UnusedMethodArgument
|
|
82
|
+
registry = mesh_registry
|
|
83
|
+
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
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
{ success: true, merged: merged, total_peers: incoming_peers.size }
|
|
107
|
+
rescue StandardError => e
|
|
108
|
+
{ success: false, reason: :error, message: e.message }
|
|
109
|
+
end
|
|
110
|
+
|
|
67
111
|
private
|
|
68
112
|
|
|
69
113
|
def publish_mesh_departure(agent_id:, capabilities:)
|
|
@@ -77,6 +121,29 @@ module Legion
|
|
|
77
121
|
Legion::Logging.warn "[mesh] failed to publish departure signal: #{e.message}"
|
|
78
122
|
end
|
|
79
123
|
|
|
124
|
+
def publish_gossip_message(peers)
|
|
125
|
+
return unless defined?(Legion::Extensions::Mesh::Transport::Messages::Gossip)
|
|
126
|
+
|
|
127
|
+
Legion::Extensions::Mesh::Transport::Messages::Gossip.new(
|
|
128
|
+
sender: local_node_name,
|
|
129
|
+
gossip_round: @gossip_round,
|
|
130
|
+
peers: peers
|
|
131
|
+
).publish
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def gossip_max_peers
|
|
135
|
+
settings = Legion::Settings.dig(:mesh, :gossip)
|
|
136
|
+
(settings.is_a?(Hash) ? settings[:max_peers_per_message] : nil) || 100
|
|
137
|
+
rescue StandardError
|
|
138
|
+
100
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def local_node_name
|
|
142
|
+
Legion::Settings[:client][:name]
|
|
143
|
+
rescue StandardError
|
|
144
|
+
'unknown'
|
|
145
|
+
end
|
|
146
|
+
|
|
80
147
|
def mesh_registry
|
|
81
148
|
@mesh_registry ||= Helpers::Registry.new
|
|
82
149
|
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
require_relative '../helpers/pending_requests'
|
|
5
|
+
require_relative '../helpers/delegation'
|
|
6
|
+
require_relative '../helpers/registry'
|
|
7
|
+
require_relative 'mesh'
|
|
8
|
+
|
|
9
|
+
module Legion
|
|
10
|
+
module Extensions
|
|
11
|
+
module Mesh
|
|
12
|
+
module Runners
|
|
13
|
+
module TaskRequest
|
|
14
|
+
include Runners::Mesh
|
|
15
|
+
|
|
16
|
+
DEFAULT_TIMEOUT = 30
|
|
17
|
+
|
|
18
|
+
def request_task(from:, to:, task:, payload:, timeout: DEFAULT_TIMEOUT, consent_level: :execute, **) # rubocop:disable Metrics/ParameterLists
|
|
19
|
+
target_id = resolve_target(to)
|
|
20
|
+
return { success: false, reason: :no_agent_found, requested: to } unless target_id
|
|
21
|
+
|
|
22
|
+
correlation_id = "task-#{SecureRandom.uuid}"
|
|
23
|
+
|
|
24
|
+
delegation = delegation_tracker.create(
|
|
25
|
+
from: from, to: target_id,
|
|
26
|
+
task_context: task, consent_level: consent_level
|
|
27
|
+
)
|
|
28
|
+
return { success: false, reason: delegation[:error] } if delegation[:error]
|
|
29
|
+
|
|
30
|
+
task_pending.register(
|
|
31
|
+
correlation_id: correlation_id,
|
|
32
|
+
callback: nil,
|
|
33
|
+
ttl: timeout
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
send_message(from: from, to: target_id, pattern: :unicast,
|
|
37
|
+
payload: { type: :task_request, correlation_id: correlation_id,
|
|
38
|
+
task: task, payload: payload, reply_to: from })
|
|
39
|
+
|
|
40
|
+
Legion::Logging.info "[mesh-task] request: from=#{from} to=#{target_id} task=#{task} cid=#{correlation_id[0..11]}"
|
|
41
|
+
{ success: true, correlation_id: correlation_id, delegation_id: delegation[:delegation_id],
|
|
42
|
+
target_agent: target_id }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def handle_task_reply(correlation_id:, result:, **)
|
|
46
|
+
resolved = task_pending.resolve(correlation_id: correlation_id, result: result)
|
|
47
|
+
if resolved
|
|
48
|
+
Legion::Logging.debug "[mesh-task] reply resolved: cid=#{correlation_id[0..11]}"
|
|
49
|
+
{ success: true, resolved: true, correlation_id: correlation_id }
|
|
50
|
+
else
|
|
51
|
+
{ success: false, reason: :not_found, correlation_id: correlation_id }
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def pending_task_stats(**)
|
|
56
|
+
{ success: true, pending_count: task_pending.pending_count }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def expire_pending_tasks(**)
|
|
60
|
+
expired = task_pending.expire
|
|
61
|
+
Legion::Logging.debug "[mesh-task] expired #{expired.size} pending tasks" unless expired.empty?
|
|
62
|
+
{ success: true, expired_count: expired.size, expired_ids: expired }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def resolve_target(to)
|
|
68
|
+
return to if mesh_registry.agents.key?(to)
|
|
69
|
+
|
|
70
|
+
agents = mesh_registry.find_by_capability(to.to_sym)
|
|
71
|
+
return nil if agents.empty?
|
|
72
|
+
|
|
73
|
+
online = agents.select { |a| a[:status] == :online }
|
|
74
|
+
(online.empty? ? agents : online).sample[:agent_id]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def task_pending
|
|
78
|
+
@task_pending ||= Helpers::PendingRequests.new(default_ttl: DEFAULT_TIMEOUT)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def delegation_tracker
|
|
82
|
+
@delegation_tracker ||= Helpers::Delegation.new
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Mesh
|
|
6
|
+
module Transport
|
|
7
|
+
module Messages
|
|
8
|
+
class Gossip < Legion::Transport::Message
|
|
9
|
+
def exchange
|
|
10
|
+
Legion::Transport::Exchanges::Node
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def routing_key
|
|
14
|
+
'mesh.gossip'
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def message
|
|
18
|
+
{
|
|
19
|
+
type: 'mesh_gossip',
|
|
20
|
+
sender: @options[:sender],
|
|
21
|
+
gossip_round: @options[:gossip_round] || 0,
|
|
22
|
+
peers: @options[:peers] || []
|
|
23
|
+
}
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def type
|
|
27
|
+
'mesh_gossip'
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def encrypt?
|
|
31
|
+
false
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Mesh
|
|
6
|
+
module Transport
|
|
7
|
+
module Queues
|
|
8
|
+
class Gossip < Legion::Transport::Queue
|
|
9
|
+
def queue_name
|
|
10
|
+
'mesh.gossip'
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def exchange
|
|
14
|
+
Legion::Transport::Exchanges::Node
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def routing_key
|
|
18
|
+
'mesh.gossip'
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -10,6 +10,7 @@ require 'legion/extensions/mesh/helpers/peer_verify'
|
|
|
10
10
|
require 'legion/extensions/mesh/runners/mesh'
|
|
11
11
|
require 'legion/extensions/mesh/runners/preferences'
|
|
12
12
|
require 'legion/extensions/mesh/runners/delegation'
|
|
13
|
+
require 'legion/extensions/mesh/runners/task_request'
|
|
13
14
|
|
|
14
15
|
if defined?(Legion::Transport)
|
|
15
16
|
require 'legion/extensions/mesh/transport/messages/preference_query'
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Stub the framework actor base class since legionio gem is not available in test
|
|
4
|
+
unless defined?(Legion::Extensions::Actors::Every)
|
|
5
|
+
module Legion
|
|
6
|
+
module Extensions
|
|
7
|
+
module Actors
|
|
8
|
+
class Every # rubocop:disable Lint/EmptyClass
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
$LOADED_FEATURES << 'legion/extensions/actors/every' unless $LOADED_FEATURES.include?('legion/extensions/actors/every')
|
|
16
|
+
|
|
17
|
+
require 'legion/extensions/mesh/actors/gossip'
|
|
18
|
+
|
|
19
|
+
RSpec.describe Legion::Extensions::Mesh::Actor::Gossip do
|
|
20
|
+
it 'fires every 15 seconds' do
|
|
21
|
+
expect(described_class.new.time).to eq(15)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it 'calls publish_gossip runner function' do
|
|
25
|
+
instance = described_class.allocate
|
|
26
|
+
expect(instance.runner_function).to eq(:publish_gossip)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -51,4 +51,26 @@ RSpec.describe Legion::Extensions::Mesh::Client do
|
|
|
51
51
|
expect(result[:expired]).to eq(0)
|
|
52
52
|
end
|
|
53
53
|
end
|
|
54
|
+
|
|
55
|
+
it 'responds to delegation runner methods' do
|
|
56
|
+
expect(client).to respond_to(:delegate)
|
|
57
|
+
expect(client).to respond_to(:complete_delegation)
|
|
58
|
+
expect(client).to respond_to(:revoke_delegation)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
it 'responds to task request runner methods' do
|
|
62
|
+
expect(client).to respond_to(:request_task)
|
|
63
|
+
expect(client).to respond_to(:handle_task_reply)
|
|
64
|
+
expect(client).to respond_to(:pending_task_stats)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
describe '#request_task' do
|
|
68
|
+
it 'sends a task request via the client' do
|
|
69
|
+
client.register(agent_id: 'a', capabilities: [:orchestration])
|
|
70
|
+
client.register(agent_id: 'b', capabilities: [:review])
|
|
71
|
+
result = client.request_task(from: 'a', to: 'b', task: 'review', payload: {})
|
|
72
|
+
expect(result[:success]).to be true
|
|
73
|
+
expect(result[:target_agent]).to eq('b')
|
|
74
|
+
end
|
|
75
|
+
end
|
|
54
76
|
end
|
|
@@ -313,6 +313,32 @@ RSpec.describe Legion::Extensions::Mesh::Helpers::Registry do
|
|
|
313
313
|
end
|
|
314
314
|
end
|
|
315
315
|
|
|
316
|
+
describe 'gossip fields' do
|
|
317
|
+
it 'stores source, node, and generation on registration' do
|
|
318
|
+
registry.register_agent('agent-1', capabilities: [:test], source: :native, node: 'node-01')
|
|
319
|
+
agent = registry.agents['agent-1']
|
|
320
|
+
expect(agent[:source]).to eq(:native)
|
|
321
|
+
expect(agent[:node]).to eq('node-01')
|
|
322
|
+
expect(agent[:generation]).to eq(1)
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
it 'increments generation on heartbeat' do
|
|
326
|
+
registry.register_agent('agent-1', capabilities: [:test])
|
|
327
|
+
registry.heartbeat('agent-1')
|
|
328
|
+
expect(registry.agents['agent-1'][:generation]).to eq(2)
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
describe '#all_agents' do
|
|
333
|
+
it 'returns all agent records as an array' do
|
|
334
|
+
registry.register_agent('a1', capabilities: [:search])
|
|
335
|
+
registry.register_agent('a2', capabilities: [:compute])
|
|
336
|
+
all = registry.all_agents
|
|
337
|
+
expect(all).to be_an(Array)
|
|
338
|
+
expect(all.size).to eq(2)
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
316
342
|
describe '#count' do
|
|
317
343
|
it 'returns 0 for an empty registry' do
|
|
318
344
|
expect(registry.count).to eq(0)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe 'Gossip runner methods' do
|
|
6
|
+
subject { Object.new.extend(Legion::Extensions::Mesh::Runners::Mesh) }
|
|
7
|
+
|
|
8
|
+
before do
|
|
9
|
+
allow(subject).to receive(:mesh_registry).and_return(
|
|
10
|
+
Legion::Extensions::Mesh::Helpers::Registry.new
|
|
11
|
+
)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
describe '#merge_gossip' do
|
|
15
|
+
it 'adds unknown agents from incoming gossip' do
|
|
16
|
+
incoming = [
|
|
17
|
+
{ agent_id: 'remote-1', capabilities: [:test], node: 'node-02',
|
|
18
|
+
source: :native, status: :online, generation: 5,
|
|
19
|
+
last_seen: Time.now.utc.to_s, registered_at: Time.now.utc.to_s }
|
|
20
|
+
]
|
|
21
|
+
result = subject.merge_gossip(incoming_peers: incoming, sender: 'node-02')
|
|
22
|
+
expect(result[:success]).to be true
|
|
23
|
+
expect(result[:merged]).to eq(1)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
it 'updates agents with higher generation' do
|
|
27
|
+
registry = subject.send(:mesh_registry)
|
|
28
|
+
registry.register_agent('agent-1', capabilities: [:old], node: 'node-01')
|
|
29
|
+
|
|
30
|
+
incoming = [
|
|
31
|
+
{ agent_id: 'agent-1', capabilities: [:updated], node: 'node-01',
|
|
32
|
+
source: :native, status: :online, generation: 99,
|
|
33
|
+
last_seen: Time.now.utc.to_s, registered_at: Time.now.utc.to_s }
|
|
34
|
+
]
|
|
35
|
+
result = subject.merge_gossip(incoming_peers: incoming, sender: 'node-01')
|
|
36
|
+
expect(result[:merged]).to eq(1)
|
|
37
|
+
expect(registry.agents['agent-1'][:generation]).to eq(99)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it 'skips agents from local node' do
|
|
41
|
+
local_name = 'local-node'
|
|
42
|
+
allow(subject).to receive(:local_node_name).and_return(local_name)
|
|
43
|
+
|
|
44
|
+
incoming = [
|
|
45
|
+
{ agent_id: 'local-agent', node: local_name, generation: 1 }
|
|
46
|
+
]
|
|
47
|
+
result = subject.merge_gossip(incoming_peers: incoming, sender: 'other-node')
|
|
48
|
+
expect(result[:merged]).to eq(0)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
it 'ignores agents with lower or equal generation' do
|
|
52
|
+
registry = subject.send(:mesh_registry)
|
|
53
|
+
registry.register_agent('agent-1', capabilities: [:test], node: 'node-01')
|
|
54
|
+
# generation is 1 after register
|
|
55
|
+
|
|
56
|
+
incoming = [
|
|
57
|
+
{ agent_id: 'agent-1', capabilities: [:test], node: 'node-01',
|
|
58
|
+
generation: 1, source: :native, status: :online }
|
|
59
|
+
]
|
|
60
|
+
result = subject.merge_gossip(incoming_peers: incoming, sender: 'node-02')
|
|
61
|
+
expect(result[:merged]).to eq(0)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
describe '#publish_gossip' do
|
|
66
|
+
it 'returns success with peer count' do
|
|
67
|
+
allow(subject).to receive(:publish_gossip_message)
|
|
68
|
+
result = subject.publish_gossip
|
|
69
|
+
expect(result[:success]).to be true
|
|
70
|
+
expect(result[:peers_broadcast]).to be_a(Integer)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'legion/extensions/mesh/runners/task_request'
|
|
5
|
+
|
|
6
|
+
RSpec.describe Legion::Extensions::Mesh::Runners::TaskRequest do
|
|
7
|
+
subject(:runner) { Object.new.extend(described_class) }
|
|
8
|
+
|
|
9
|
+
before do
|
|
10
|
+
runner.instance_variable_set(:@mesh_registry, nil)
|
|
11
|
+
runner.instance_variable_set(:@task_pending, nil)
|
|
12
|
+
runner.instance_variable_set(:@delegation_tracker, nil)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
describe '#request_task' do
|
|
16
|
+
before do
|
|
17
|
+
runner.register(agent_id: 'requester', capabilities: [:orchestration])
|
|
18
|
+
runner.register(agent_id: 'worker-1', capabilities: [:code_review])
|
|
19
|
+
runner.register(agent_id: 'worker-2', capabilities: %i[code_review testing])
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it 'sends a task request to a specific agent by ID' do
|
|
23
|
+
result = runner.request_task(from: 'requester', to: 'worker-1',
|
|
24
|
+
task: 'review_code', payload: { file: 'app.rb' })
|
|
25
|
+
expect(result[:success]).to be true
|
|
26
|
+
expect(result[:correlation_id]).to be_a(String)
|
|
27
|
+
expect(result[:delegation_id]).to start_with('del-')
|
|
28
|
+
expect(result[:target_agent]).to eq('worker-1')
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
it 'routes to an agent by capability when to: is not a registered agent' do
|
|
32
|
+
result = runner.request_task(from: 'requester', to: 'code_review',
|
|
33
|
+
task: 'review_code', payload: {})
|
|
34
|
+
expect(result[:success]).to be true
|
|
35
|
+
expect(%w[worker-1 worker-2]).to include(result[:target_agent])
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it 'returns error when no agent found for capability' do
|
|
39
|
+
result = runner.request_task(from: 'requester', to: 'nonexistent_capability',
|
|
40
|
+
task: 'do_thing', payload: {})
|
|
41
|
+
expect(result[:success]).to be false
|
|
42
|
+
expect(result[:reason]).to eq(:no_agent_found)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
it 'registers a pending request' do
|
|
46
|
+
result = runner.request_task(from: 'requester', to: 'worker-1',
|
|
47
|
+
task: 'review', payload: {}, timeout: 60)
|
|
48
|
+
expect(runner.send(:task_pending).pending?(result[:correlation_id])).to be true
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
describe '#handle_task_reply' do
|
|
53
|
+
it 'resolves a pending request' do
|
|
54
|
+
runner.register(agent_id: 'req', capabilities: [])
|
|
55
|
+
runner.register(agent_id: 'wrk', capabilities: [:work])
|
|
56
|
+
|
|
57
|
+
req = runner.request_task(from: 'req', to: 'wrk', task: 'test', payload: {})
|
|
58
|
+
result = runner.handle_task_reply(
|
|
59
|
+
correlation_id: req[:correlation_id],
|
|
60
|
+
result: { status: :completed, output: 'done' }
|
|
61
|
+
)
|
|
62
|
+
expect(result[:success]).to be true
|
|
63
|
+
expect(result[:resolved]).to be true
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
it 'returns not_found for unknown correlation_id' do
|
|
67
|
+
result = runner.handle_task_reply(correlation_id: 'unknown', result: {})
|
|
68
|
+
expect(result[:success]).to be false
|
|
69
|
+
expect(result[:reason]).to eq(:not_found)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
describe '#pending_task_stats' do
|
|
74
|
+
it 'returns count of pending requests' do
|
|
75
|
+
runner.register(agent_id: 'a', capabilities: [])
|
|
76
|
+
runner.register(agent_id: 'b', capabilities: [:work])
|
|
77
|
+
runner.request_task(from: 'a', to: 'b', task: 't', payload: {})
|
|
78
|
+
stats = runner.pending_task_stats
|
|
79
|
+
expect(stats[:success]).to be true
|
|
80
|
+
expect(stats[:pending_count]).to be >= 1
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
describe '#expire_pending_tasks' do
|
|
85
|
+
it 'expires timed-out requests' do
|
|
86
|
+
result = runner.expire_pending_tasks
|
|
87
|
+
expect(result[:success]).to be true
|
|
88
|
+
expect(result[:expired_count]).to eq(0)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
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/gossip'
|
|
29
|
+
|
|
30
|
+
RSpec.describe Legion::Extensions::Mesh::Transport::Messages::Gossip do
|
|
31
|
+
subject do
|
|
32
|
+
described_class.new(
|
|
33
|
+
sender: 'node-01',
|
|
34
|
+
gossip_round: 5,
|
|
35
|
+
peers: [{ agent_id: 'w1', capabilities: [:test], node: 'node-01', generation: 3 }]
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
it 'uses the node exchange' do
|
|
40
|
+
expect(subject.exchange).to eq(Legion::Transport::Exchanges::Node)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
it 'routes to mesh.gossip' do
|
|
44
|
+
expect(subject.routing_key).to eq('mesh.gossip')
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
it 'includes sender and peers in the message body' do
|
|
48
|
+
msg = subject.message
|
|
49
|
+
expect(msg[:type]).to eq('mesh_gossip')
|
|
50
|
+
expect(msg[:sender]).to eq('node-01')
|
|
51
|
+
expect(msg[:peers]).to be_an(Array)
|
|
52
|
+
expect(msg[:gossip_round]).to eq(5)
|
|
53
|
+
end
|
|
54
|
+
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
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Esity
|
|
@@ -48,6 +48,7 @@ files:
|
|
|
48
48
|
- Gemfile
|
|
49
49
|
- lex-mesh.gemspec
|
|
50
50
|
- lib/legion/extensions/mesh.rb
|
|
51
|
+
- lib/legion/extensions/mesh/actors/gossip.rb
|
|
51
52
|
- lib/legion/extensions/mesh/actors/heartbeat.rb
|
|
52
53
|
- lib/legion/extensions/mesh/actors/pending_expiry.rb
|
|
53
54
|
- lib/legion/extensions/mesh/actors/preference_listener.rb
|
|
@@ -62,11 +63,15 @@ files:
|
|
|
62
63
|
- lib/legion/extensions/mesh/runners/delegation.rb
|
|
63
64
|
- lib/legion/extensions/mesh/runners/mesh.rb
|
|
64
65
|
- lib/legion/extensions/mesh/runners/preferences.rb
|
|
66
|
+
- lib/legion/extensions/mesh/runners/task_request.rb
|
|
67
|
+
- lib/legion/extensions/mesh/transport/messages/gossip.rb
|
|
65
68
|
- lib/legion/extensions/mesh/transport/messages/mesh_departure.rb
|
|
66
69
|
- lib/legion/extensions/mesh/transport/messages/preference_query.rb
|
|
67
70
|
- lib/legion/extensions/mesh/transport/messages/preference_response.rb
|
|
71
|
+
- lib/legion/extensions/mesh/transport/queues/gossip.rb
|
|
68
72
|
- lib/legion/extensions/mesh/transport/queues/preference.rb
|
|
69
73
|
- lib/legion/extensions/mesh/version.rb
|
|
74
|
+
- spec/legion/extensions/mesh/actors/gossip_spec.rb
|
|
70
75
|
- spec/legion/extensions/mesh/actors/heartbeat_spec.rb
|
|
71
76
|
- spec/legion/extensions/mesh/actors/pending_expiry_spec.rb
|
|
72
77
|
- spec/legion/extensions/mesh/actors/preference_listener_spec.rb
|
|
@@ -79,8 +84,11 @@ files:
|
|
|
79
84
|
- spec/legion/extensions/mesh/helpers/registry_spec.rb
|
|
80
85
|
- spec/legion/extensions/mesh/helpers/topology_spec.rb
|
|
81
86
|
- spec/legion/extensions/mesh/runners/delegation_spec.rb
|
|
87
|
+
- spec/legion/extensions/mesh/runners/mesh_gossip_spec.rb
|
|
82
88
|
- spec/legion/extensions/mesh/runners/mesh_spec.rb
|
|
83
89
|
- spec/legion/extensions/mesh/runners/preferences_spec.rb
|
|
90
|
+
- spec/legion/extensions/mesh/runners/task_request_spec.rb
|
|
91
|
+
- spec/legion/extensions/mesh/transport/messages/gossip_spec.rb
|
|
84
92
|
- spec/legion/extensions/mesh/transport/messages/mesh_departure_spec.rb
|
|
85
93
|
- spec/legion/extensions/mesh/transport/messages/preference_query_spec.rb
|
|
86
94
|
- spec/legion/extensions/mesh/transport/messages/preference_response_spec.rb
|