lex-mesh 0.2.3 → 0.2.5
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/Gemfile +2 -0
- data/lex-mesh.gemspec +3 -0
- data/lib/legion/extensions/mesh/actors/gossip.rb +41 -0
- data/lib/legion/extensions/mesh/helpers/delegation.rb +126 -0
- data/lib/legion/extensions/mesh/helpers/peer_verify.rb +79 -0
- data/lib/legion/extensions/mesh/helpers/registry.rb +17 -1
- data/lib/legion/extensions/mesh/runners/delegation.rb +87 -0
- data/lib/legion/extensions/mesh/runners/mesh.rb +67 -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 +3 -0
- data/spec/legion/extensions/mesh/actors/gossip_spec.rb +28 -0
- data/spec/legion/extensions/mesh/helpers/delegation_spec.rb +125 -0
- data/spec/legion/extensions/mesh/helpers/peer_verify_spec.rb +76 -0
- data/spec/legion/extensions/mesh/helpers/registry_spec.rb +26 -0
- data/spec/legion/extensions/mesh/runners/delegation_spec.rb +84 -0
- data/spec/legion/extensions/mesh/runners/mesh_gossip_spec.rb +73 -0
- data/spec/legion/extensions/mesh/transport/messages/gossip_spec.rb +54 -0
- metadata +42 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 650958df48e1ad4fa390b3fecb3ab2817dc535c279449dc4ce114e92f59cfff2
|
|
4
|
+
data.tar.gz: ff13ef5a0e18147daa15b72579de24f3691bec50f5f124627d2b503208cfba82
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fc982147fb2a22e4a5bb40fa8f7a767e87aed754f59474d4937059a831adcfeae6b4f4e4c9add47359c3b6a69200a0cdea21c1bafd1b00dfd74501c93415eb40
|
|
7
|
+
data.tar.gz: e34a1a0d9ae8481bb40949bda0e47d6e1647856777214d133acf10c121b8e70962bb85c10fda16120b9dd7bc958828fc20a73851ab259c89fa813d7df2236f26
|
data/Gemfile
CHANGED
data/lex-mesh.gemspec
CHANGED
|
@@ -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
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Extensions
|
|
7
|
+
module Mesh
|
|
8
|
+
module Helpers
|
|
9
|
+
class Delegation
|
|
10
|
+
CONSENT_LEVELS = %i[read execute admin].freeze
|
|
11
|
+
|
|
12
|
+
attr_reader :delegations
|
|
13
|
+
|
|
14
|
+
def initialize(max_depth: 3, max_active_per_agent: 10)
|
|
15
|
+
@delegations = {}
|
|
16
|
+
@agent_delegations = Hash.new { |h, k| h[k] = [] }
|
|
17
|
+
@max_depth = max_depth
|
|
18
|
+
@max_active_per_agent = max_active_per_agent
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def create(from:, to:, task_context:, consent_level:, parent_delegation_id: nil)
|
|
22
|
+
depth = compute_depth(parent_delegation_id)
|
|
23
|
+
return { error: :max_depth_exceeded } if depth >= @max_depth
|
|
24
|
+
|
|
25
|
+
if parent_delegation_id
|
|
26
|
+
parent = find(parent_delegation_id)
|
|
27
|
+
return { error: :consent_escalation } if parent && CONSENT_LEVELS.index(consent_level) > CONSENT_LEVELS.index(parent[:consent_level])
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
active_count = (@agent_delegations[from] || []).count do |id|
|
|
31
|
+
@delegations[id]&.fetch(:status, nil) == :active
|
|
32
|
+
end
|
|
33
|
+
return { error: :max_active_exceeded } if active_count >= @max_active_per_agent
|
|
34
|
+
|
|
35
|
+
id = "del-#{SecureRandom.uuid}"
|
|
36
|
+
record = {
|
|
37
|
+
delegation_id: id,
|
|
38
|
+
from_agent_id: from,
|
|
39
|
+
to_agent_id: to,
|
|
40
|
+
task_context: task_context,
|
|
41
|
+
consent_level: consent_level,
|
|
42
|
+
parent_delegation_id: parent_delegation_id,
|
|
43
|
+
depth: depth,
|
|
44
|
+
status: :active,
|
|
45
|
+
created_at: Time.now.utc,
|
|
46
|
+
completed_at: nil
|
|
47
|
+
}
|
|
48
|
+
@delegations[id] = record
|
|
49
|
+
@agent_delegations[from] << id
|
|
50
|
+
@agent_delegations[to] << id
|
|
51
|
+
record
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def complete(delegation_id)
|
|
55
|
+
record = @delegations[delegation_id]
|
|
56
|
+
return nil unless record && record[:status] == :active
|
|
57
|
+
|
|
58
|
+
record[:status] = :completed
|
|
59
|
+
record[:completed_at] = Time.now.utc
|
|
60
|
+
record
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def revoke(delegation_id)
|
|
64
|
+
record = @delegations[delegation_id]
|
|
65
|
+
return nil unless record && record[:status] == :active
|
|
66
|
+
|
|
67
|
+
record[:status] = :revoked
|
|
68
|
+
record[:completed_at] = Time.now.utc
|
|
69
|
+
cascade_revoke(delegation_id)
|
|
70
|
+
record
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def chain(delegation_id)
|
|
74
|
+
result = []
|
|
75
|
+
current = @delegations[delegation_id]
|
|
76
|
+
while current
|
|
77
|
+
result.unshift(current)
|
|
78
|
+
current = current[:parent_delegation_id] ? @delegations[current[:parent_delegation_id]] : nil
|
|
79
|
+
end
|
|
80
|
+
result
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def for_agent(agent_id, status: nil)
|
|
84
|
+
ids = @agent_delegations[agent_id] || []
|
|
85
|
+
results = ids.filter_map { |id| @delegations[id] }
|
|
86
|
+
results = results.select { |d| d[:status] == status } if status
|
|
87
|
+
results
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def find(delegation_id)
|
|
91
|
+
@delegations[delegation_id]
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def stats
|
|
95
|
+
active = @delegations.values.count { |d| d[:status] == :active }
|
|
96
|
+
depths = @delegations.values.map { |d| d[:depth] }
|
|
97
|
+
{
|
|
98
|
+
total: @delegations.size,
|
|
99
|
+
active: active,
|
|
100
|
+
avg_depth: depths.empty? ? 0 : depths.sum.to_f / depths.size,
|
|
101
|
+
max_depth: depths.max || 0
|
|
102
|
+
}
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def compute_depth(parent_delegation_id)
|
|
108
|
+
return 0 unless parent_delegation_id
|
|
109
|
+
|
|
110
|
+
parent = find(parent_delegation_id)
|
|
111
|
+
(parent&.fetch(:depth, 0) || 0) + 1
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def cascade_revoke(parent_id)
|
|
115
|
+
@delegations.each_value do |d|
|
|
116
|
+
next unless d[:parent_delegation_id] == parent_id && d[:status] == :active
|
|
117
|
+
|
|
118
|
+
d[:status] = :revoked
|
|
119
|
+
d[:completed_at] = Time.now.utc
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'base64'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Extensions
|
|
7
|
+
module Mesh
|
|
8
|
+
module Helpers
|
|
9
|
+
module PeerVerify
|
|
10
|
+
class << self
|
|
11
|
+
def sign_message(payload, private_key_b64)
|
|
12
|
+
require 'ed25519'
|
|
13
|
+
key = Ed25519::SigningKey.new(Base64.strict_decode64(private_key_b64))
|
|
14
|
+
message_bytes = json_dump(payload)
|
|
15
|
+
signature = key.sign(message_bytes)
|
|
16
|
+
{ payload: payload, signature: Base64.strict_encode64(signature), signed_bytes: message_bytes }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def verify_message(signed_message, org_id:)
|
|
20
|
+
peer = find_peer(org_id)
|
|
21
|
+
return { valid: false, org_id: org_id, reason: :unknown_peer } unless peer
|
|
22
|
+
|
|
23
|
+
require 'ed25519'
|
|
24
|
+
pub_key_b64 = peer[:public_key].sub(/\Aed25519:/, '')
|
|
25
|
+
verify_key = Ed25519::VerifyKey.new(Base64.strict_decode64(pub_key_b64))
|
|
26
|
+
signature = Base64.strict_decode64(signed_message[:signature])
|
|
27
|
+
message_bytes = signed_message[:signed_bytes] || json_dump(signed_message[:payload])
|
|
28
|
+
verify_key.verify(signature, message_bytes)
|
|
29
|
+
{ valid: true, org_id: org_id }
|
|
30
|
+
rescue Ed25519::VerifyError
|
|
31
|
+
{ valid: false, org_id: org_id, reason: :invalid_signature }
|
|
32
|
+
rescue StandardError => e
|
|
33
|
+
{ valid: false, org_id: org_id, reason: :error, message: e.message }
|
|
34
|
+
end
|
|
35
|
+
|
|
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]
|
|
39
|
+
peer = find_peer(org_id)
|
|
40
|
+
limit = peer&.dig(:rate_limit) || 100
|
|
41
|
+
|
|
42
|
+
if Time.now.utc - counter[:window_start] > 60
|
|
43
|
+
counter[:count] = 0
|
|
44
|
+
counter[:window_start] = Time.now.utc
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
counter[:count] += 1
|
|
48
|
+
return { allowed: true, remaining: limit - counter[:count] } if counter[:count] <= limit
|
|
49
|
+
|
|
50
|
+
{ allowed: false, error: :rate_limited, org_id: org_id }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def reset_counters!
|
|
54
|
+
@counters = nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def find_peer(org_id)
|
|
60
|
+
return nil unless defined?(Legion::Settings)
|
|
61
|
+
|
|
62
|
+
peers = Legion::Settings.dig(:mesh, :trusted_peers) || []
|
|
63
|
+
peers.find { |p| p[:org_id] == org_id }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def json_dump(data)
|
|
67
|
+
if defined?(Legion::JSON)
|
|
68
|
+
Legion::JSON.dump({ data: data })
|
|
69
|
+
else
|
|
70
|
+
require 'json'
|
|
71
|
+
::JSON.dump({ data: data })
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -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
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../helpers/delegation'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Extensions
|
|
7
|
+
module Mesh
|
|
8
|
+
module Runners
|
|
9
|
+
module Delegation
|
|
10
|
+
include Legion::Extensions::Helpers::Lex if defined?(Legion::Extensions::Helpers::Lex)
|
|
11
|
+
|
|
12
|
+
def delegate(from:, to:, task_context:, consent_level: :execute, parent_delegation_id: nil, **) # rubocop:disable Metrics/ParameterLists
|
|
13
|
+
result = delegation_tracker.create(
|
|
14
|
+
from: from,
|
|
15
|
+
to: to,
|
|
16
|
+
task_context: task_context,
|
|
17
|
+
consent_level: consent_level.to_sym,
|
|
18
|
+
parent_delegation_id: parent_delegation_id
|
|
19
|
+
)
|
|
20
|
+
return { success: false, **result } if result[:error]
|
|
21
|
+
|
|
22
|
+
publish_delegation_event('delegation.created', result)
|
|
23
|
+
{ success: true, delegation_id: result[:delegation_id], depth: result[:depth] }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def complete_delegation(delegation_id:, **)
|
|
27
|
+
result = delegation_tracker.complete(delegation_id)
|
|
28
|
+
return { success: false, reason: :not_found_or_inactive } unless result
|
|
29
|
+
|
|
30
|
+
publish_delegation_event('delegation.completed', result)
|
|
31
|
+
{ success: true, delegation_id: delegation_id }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def revoke_delegation(delegation_id:, **)
|
|
35
|
+
result = delegation_tracker.revoke(delegation_id)
|
|
36
|
+
return { success: false, reason: :not_found_or_inactive } unless result
|
|
37
|
+
|
|
38
|
+
publish_delegation_event('delegation.revoked', result)
|
|
39
|
+
{ success: true, delegation_id: delegation_id }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def delegation_chain(delegation_id:, **)
|
|
43
|
+
chain = delegation_tracker.chain(delegation_id)
|
|
44
|
+
{ success: true, chain: chain, depth: [chain.size - 1, 0].max }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def agent_delegations(agent_id:, status: nil, **)
|
|
48
|
+
results = delegation_tracker.for_agent(agent_id, status: status&.to_sym)
|
|
49
|
+
{ success: true, delegations: results, count: results.size }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def delegation_stats(**)
|
|
53
|
+
delegation_tracker.stats.merge(success: true)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def publish_delegation_event(event_type, record)
|
|
59
|
+
return unless defined?(Legion::Extensions::Audit::Transport::Messages::Audit)
|
|
60
|
+
|
|
61
|
+
Legion::Extensions::Audit::Transport::Messages::Audit.new(
|
|
62
|
+
event_type: event_type,
|
|
63
|
+
principal_id: record[:from_agent_id],
|
|
64
|
+
action: event_type.split('.').last,
|
|
65
|
+
resource: "delegation:#{record[:delegation_id]}",
|
|
66
|
+
detail: { to: record[:to_agent_id], depth: record[:depth], consent: record[:consent_level] }
|
|
67
|
+
).publish
|
|
68
|
+
rescue StandardError => e
|
|
69
|
+
Legion::Logging.warn "[mesh] failed to publish #{event_type}: #{e.message}" if defined?(Legion::Logging)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def delegation_tracker
|
|
73
|
+
@delegation_tracker ||= begin
|
|
74
|
+
max_depth = nil
|
|
75
|
+
max_active = nil
|
|
76
|
+
if defined?(Legion::Settings)
|
|
77
|
+
max_depth = Legion::Settings.dig(:mesh, :delegation, :max_depth)
|
|
78
|
+
max_active = Legion::Settings.dig(:mesh, :delegation, :max_active_per_agent)
|
|
79
|
+
end
|
|
80
|
+
Helpers::Delegation.new(max_depth: max_depth || 3, max_active_per_agent: max_active || 10)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
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,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
|
|
@@ -5,8 +5,11 @@ require 'legion/extensions/mesh/helpers/topology'
|
|
|
5
5
|
require 'legion/extensions/mesh/helpers/registry'
|
|
6
6
|
require 'legion/extensions/mesh/helpers/preference_profile'
|
|
7
7
|
require 'legion/extensions/mesh/helpers/pending_requests'
|
|
8
|
+
require 'legion/extensions/mesh/helpers/delegation'
|
|
9
|
+
require 'legion/extensions/mesh/helpers/peer_verify'
|
|
8
10
|
require 'legion/extensions/mesh/runners/mesh'
|
|
9
11
|
require 'legion/extensions/mesh/runners/preferences'
|
|
12
|
+
require 'legion/extensions/mesh/runners/delegation'
|
|
10
13
|
|
|
11
14
|
if defined?(Legion::Transport)
|
|
12
15
|
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
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe Legion::Extensions::Mesh::Helpers::Delegation do
|
|
6
|
+
subject(:tracker) { described_class.new(max_depth: 3, max_active_per_agent: 5) }
|
|
7
|
+
|
|
8
|
+
describe '#create' do
|
|
9
|
+
it 'creates a delegation record' do
|
|
10
|
+
result = tracker.create(from: 'agent-a', to: 'agent-b', task_context: 'task-1', consent_level: :execute)
|
|
11
|
+
expect(result[:delegation_id]).to start_with('del-')
|
|
12
|
+
expect(result[:from_agent_id]).to eq('agent-a')
|
|
13
|
+
expect(result[:to_agent_id]).to eq('agent-b')
|
|
14
|
+
expect(result[:status]).to eq(:active)
|
|
15
|
+
expect(result[:depth]).to eq(0)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
it 'increments depth for chained delegations' do
|
|
19
|
+
d1 = tracker.create(from: 'a', to: 'b', task_context: 't', consent_level: :execute)
|
|
20
|
+
d2 = tracker.create(from: 'b', to: 'c', task_context: 't', consent_level: :execute,
|
|
21
|
+
parent_delegation_id: d1[:delegation_id])
|
|
22
|
+
expect(d2[:depth]).to eq(1)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
it 'refuses delegation beyond max depth' do
|
|
26
|
+
d1 = tracker.create(from: 'a', to: 'b', task_context: 't', consent_level: :execute)
|
|
27
|
+
d2 = tracker.create(from: 'b', to: 'c', task_context: 't', consent_level: :execute,
|
|
28
|
+
parent_delegation_id: d1[:delegation_id])
|
|
29
|
+
d3 = tracker.create(from: 'c', to: 'd', task_context: 't', consent_level: :execute,
|
|
30
|
+
parent_delegation_id: d2[:delegation_id])
|
|
31
|
+
d4 = tracker.create(from: 'd', to: 'e', task_context: 't', consent_level: :execute,
|
|
32
|
+
parent_delegation_id: d3[:delegation_id])
|
|
33
|
+
expect(d4[:error]).to eq(:max_depth_exceeded)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it 'refuses consent escalation' do
|
|
37
|
+
d1 = tracker.create(from: 'a', to: 'b', task_context: 't', consent_level: :execute)
|
|
38
|
+
d2 = tracker.create(from: 'b', to: 'c', task_context: 't', consent_level: :admin,
|
|
39
|
+
parent_delegation_id: d1[:delegation_id])
|
|
40
|
+
expect(d2[:error]).to eq(:consent_escalation)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
it 'allows consent de-escalation' do
|
|
44
|
+
d1 = tracker.create(from: 'a', to: 'b', task_context: 't', consent_level: :admin)
|
|
45
|
+
d2 = tracker.create(from: 'b', to: 'c', task_context: 't', consent_level: :read,
|
|
46
|
+
parent_delegation_id: d1[:delegation_id])
|
|
47
|
+
expect(d2[:delegation_id]).to start_with('del-')
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
it 'refuses when max active per agent exceeded' do
|
|
51
|
+
5.times { |i| tracker.create(from: 'a', to: "b#{i}", task_context: 't', consent_level: :execute) }
|
|
52
|
+
result = tracker.create(from: 'a', to: 'b6', task_context: 't', consent_level: :execute)
|
|
53
|
+
expect(result[:error]).to eq(:max_active_exceeded)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
describe '#complete' do
|
|
58
|
+
it 'marks a delegation as completed' do
|
|
59
|
+
d = tracker.create(from: 'a', to: 'b', task_context: 't', consent_level: :execute)
|
|
60
|
+
result = tracker.complete(d[:delegation_id])
|
|
61
|
+
expect(result[:status]).to eq(:completed)
|
|
62
|
+
expect(result[:completed_at]).to be_a(Time)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
it 'returns nil for non-existent delegation' do
|
|
66
|
+
expect(tracker.complete('del-nonexistent')).to be_nil
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
describe '#revoke' do
|
|
71
|
+
it 'marks delegation and children as revoked' do
|
|
72
|
+
d1 = tracker.create(from: 'a', to: 'b', task_context: 't', consent_level: :execute)
|
|
73
|
+
d2 = tracker.create(from: 'b', to: 'c', task_context: 't', consent_level: :execute,
|
|
74
|
+
parent_delegation_id: d1[:delegation_id])
|
|
75
|
+
tracker.revoke(d1[:delegation_id])
|
|
76
|
+
|
|
77
|
+
expect(tracker.find(d1[:delegation_id])[:status]).to eq(:revoked)
|
|
78
|
+
expect(tracker.find(d2[:delegation_id])[:status]).to eq(:revoked)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
describe '#chain' do
|
|
83
|
+
it 'returns the full delegation chain' do
|
|
84
|
+
d1 = tracker.create(from: 'a', to: 'b', task_context: 't', consent_level: :execute)
|
|
85
|
+
d2 = tracker.create(from: 'b', to: 'c', task_context: 't', consent_level: :execute,
|
|
86
|
+
parent_delegation_id: d1[:delegation_id])
|
|
87
|
+
chain = tracker.chain(d2[:delegation_id])
|
|
88
|
+
expect(chain.size).to eq(2)
|
|
89
|
+
expect(chain.first[:from_agent_id]).to eq('a')
|
|
90
|
+
expect(chain.last[:from_agent_id]).to eq('b')
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
describe '#for_agent' do
|
|
95
|
+
it 'returns delegations involving an agent' do
|
|
96
|
+
tracker.create(from: 'a', to: 'b', task_context: 't1', consent_level: :execute)
|
|
97
|
+
tracker.create(from: 'a', to: 'c', task_context: 't2', consent_level: :execute)
|
|
98
|
+
tracker.create(from: 'x', to: 'y', task_context: 't3', consent_level: :execute)
|
|
99
|
+
|
|
100
|
+
results = tracker.for_agent('a')
|
|
101
|
+
expect(results.size).to eq(2)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
it 'filters by status' do
|
|
105
|
+
d = tracker.create(from: 'a', to: 'b', task_context: 't', consent_level: :execute)
|
|
106
|
+
tracker.create(from: 'a', to: 'c', task_context: 't', consent_level: :execute)
|
|
107
|
+
tracker.complete(d[:delegation_id])
|
|
108
|
+
|
|
109
|
+
active = tracker.for_agent('a', status: :active)
|
|
110
|
+
expect(active.size).to eq(1)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
describe '#stats' do
|
|
115
|
+
it 'returns summary statistics' do
|
|
116
|
+
tracker.create(from: 'a', to: 'b', task_context: 't', consent_level: :execute)
|
|
117
|
+
d = tracker.create(from: 'a', to: 'c', task_context: 't', consent_level: :execute)
|
|
118
|
+
tracker.complete(d[:delegation_id])
|
|
119
|
+
|
|
120
|
+
stats = tracker.stats
|
|
121
|
+
expect(stats[:total]).to eq(2)
|
|
122
|
+
expect(stats[:active]).to eq(1)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'ed25519'
|
|
5
|
+
|
|
6
|
+
RSpec.describe Legion::Extensions::Mesh::Helpers::PeerVerify do
|
|
7
|
+
let(:signing_key) { Ed25519::SigningKey.generate }
|
|
8
|
+
let(:verify_key) { signing_key.verify_key }
|
|
9
|
+
let(:private_key_b64) { Base64.strict_encode64(signing_key.to_bytes) }
|
|
10
|
+
let(:public_key_b64) { Base64.strict_encode64(verify_key.to_bytes) }
|
|
11
|
+
|
|
12
|
+
let(:peer_config) do
|
|
13
|
+
[{ org_id: 'acme', public_key: "ed25519:#{public_key_b64}",
|
|
14
|
+
capabilities: %w[query task], rate_limit: 5 }]
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
before do
|
|
18
|
+
described_class.reset_counters!
|
|
19
|
+
stub_const('Legion::Settings', Module.new) unless defined?(Legion::Settings)
|
|
20
|
+
allow(Legion::Settings).to receive(:dig).with(:mesh, :trusted_peers).and_return(peer_config)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
describe '.sign_message' do
|
|
24
|
+
it 'signs a payload with Ed25519' do
|
|
25
|
+
result = described_class.sign_message({ hello: 'world' }, private_key_b64)
|
|
26
|
+
expect(result[:signature]).to be_a(String)
|
|
27
|
+
expect(result[:payload]).to eq({ hello: 'world' })
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
describe '.verify_message' do
|
|
32
|
+
it 'verifies a validly signed message' do
|
|
33
|
+
signed = described_class.sign_message({ hello: 'world' }, private_key_b64)
|
|
34
|
+
result = described_class.verify_message(signed, org_id: 'acme')
|
|
35
|
+
expect(result[:valid]).to be true
|
|
36
|
+
expect(result[:org_id]).to eq('acme')
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
it 'rejects a tampered message' do
|
|
40
|
+
signed = described_class.sign_message({ hello: 'world' }, private_key_b64)
|
|
41
|
+
signed[:signed_bytes] = 'tampered data'
|
|
42
|
+
result = described_class.verify_message(signed, org_id: 'acme')
|
|
43
|
+
expect(result[:valid]).to be false
|
|
44
|
+
expect(result[:reason]).to eq(:invalid_signature)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
it 'rejects unknown peers' do
|
|
48
|
+
signed = described_class.sign_message({ hello: 'world' }, private_key_b64)
|
|
49
|
+
result = described_class.verify_message(signed, org_id: 'unknown-org')
|
|
50
|
+
expect(result[:valid]).to be false
|
|
51
|
+
expect(result[:reason]).to eq(:unknown_peer)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
describe '.check_rate_limit' do
|
|
56
|
+
it 'allows messages within limit' do
|
|
57
|
+
result = described_class.check_rate_limit('acme')
|
|
58
|
+
expect(result[:allowed]).to be true
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
it 'blocks messages over limit' do
|
|
62
|
+
5.times { described_class.check_rate_limit('acme') }
|
|
63
|
+
result = described_class.check_rate_limit('acme')
|
|
64
|
+
expect(result[:allowed]).to be false
|
|
65
|
+
expect(result[:error]).to eq(:rate_limited)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
it 'resets after window expires' do
|
|
69
|
+
5.times { described_class.check_rate_limit('acme') }
|
|
70
|
+
counters = described_class.instance_variable_get(:@counters)
|
|
71
|
+
counters['acme'][:window_start] = Time.now.utc - 61
|
|
72
|
+
result = described_class.check_rate_limit('acme')
|
|
73
|
+
expect(result[:allowed]).to be true
|
|
74
|
+
end
|
|
75
|
+
end
|
|
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,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe Legion::Extensions::Mesh::Runners::Delegation do
|
|
6
|
+
subject { Object.new.extend(described_class) }
|
|
7
|
+
|
|
8
|
+
before do
|
|
9
|
+
subject.instance_variable_set(:@delegation_tracker, nil)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
describe '#delegate' do
|
|
13
|
+
it 'creates a delegation' do
|
|
14
|
+
result = subject.delegate(from: 'a', to: 'b', task_context: 'task-1', consent_level: :execute)
|
|
15
|
+
expect(result[:success]).to be true
|
|
16
|
+
expect(result[:delegation_id]).to start_with('del-')
|
|
17
|
+
expect(result[:depth]).to eq(0)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
it 'returns failure for depth exceeded' do
|
|
21
|
+
d1 = subject.delegate(from: 'a', to: 'b', task_context: 't', consent_level: :execute)
|
|
22
|
+
d2 = subject.delegate(from: 'b', to: 'c', task_context: 't', consent_level: :execute,
|
|
23
|
+
parent_delegation_id: d1[:delegation_id])
|
|
24
|
+
d3 = subject.delegate(from: 'c', to: 'd', task_context: 't', consent_level: :execute,
|
|
25
|
+
parent_delegation_id: d2[:delegation_id])
|
|
26
|
+
d4 = subject.delegate(from: 'd', to: 'e', task_context: 't', consent_level: :execute,
|
|
27
|
+
parent_delegation_id: d3[:delegation_id])
|
|
28
|
+
expect(d4[:success]).to be false
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
describe '#complete_delegation' do
|
|
33
|
+
it 'completes an active delegation' do
|
|
34
|
+
d = subject.delegate(from: 'a', to: 'b', task_context: 't', consent_level: :execute)
|
|
35
|
+
result = subject.complete_delegation(delegation_id: d[:delegation_id])
|
|
36
|
+
expect(result[:success]).to be true
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
it 'fails for non-existent delegation' do
|
|
40
|
+
result = subject.complete_delegation(delegation_id: 'del-fake')
|
|
41
|
+
expect(result[:success]).to be false
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
describe '#revoke_delegation' do
|
|
46
|
+
it 'revokes an active delegation' do
|
|
47
|
+
d = subject.delegate(from: 'a', to: 'b', task_context: 't', consent_level: :execute)
|
|
48
|
+
result = subject.revoke_delegation(delegation_id: d[:delegation_id])
|
|
49
|
+
expect(result[:success]).to be true
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
describe '#delegation_chain' do
|
|
54
|
+
it 'returns the full chain' do
|
|
55
|
+
d1 = subject.delegate(from: 'a', to: 'b', task_context: 't', consent_level: :execute)
|
|
56
|
+
d2 = subject.delegate(from: 'b', to: 'c', task_context: 't', consent_level: :execute,
|
|
57
|
+
parent_delegation_id: d1[:delegation_id])
|
|
58
|
+
result = subject.delegation_chain(delegation_id: d2[:delegation_id])
|
|
59
|
+
expect(result[:success]).to be true
|
|
60
|
+
expect(result[:chain].size).to eq(2)
|
|
61
|
+
expect(result[:depth]).to eq(1)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
describe '#agent_delegations' do
|
|
66
|
+
it 'lists delegations for an agent' do
|
|
67
|
+
subject.delegate(from: 'a', to: 'b', task_context: 't1', consent_level: :execute)
|
|
68
|
+
subject.delegate(from: 'a', to: 'c', task_context: 't2', consent_level: :execute)
|
|
69
|
+
result = subject.agent_delegations(agent_id: 'a')
|
|
70
|
+
expect(result[:success]).to be true
|
|
71
|
+
expect(result[:count]).to eq(2)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
describe '#delegation_stats' do
|
|
76
|
+
it 'returns statistics' do
|
|
77
|
+
subject.delegate(from: 'a', to: 'b', task_context: 't', consent_level: :execute)
|
|
78
|
+
result = subject.delegation_stats
|
|
79
|
+
expect(result[:success]).to be true
|
|
80
|
+
expect(result[:total]).to eq(1)
|
|
81
|
+
expect(result[:active]).to eq(1)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -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,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,14 +1,42 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: lex-mesh
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.5
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Esity
|
|
8
8
|
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
-
dependencies:
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: base64
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: ed25519
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '1.3'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '1.3'
|
|
12
40
|
description: Agent-to-agent mesh communication protocol for brain-modeled agentic
|
|
13
41
|
AI
|
|
14
42
|
email:
|
|
@@ -20,33 +48,45 @@ files:
|
|
|
20
48
|
- Gemfile
|
|
21
49
|
- lex-mesh.gemspec
|
|
22
50
|
- lib/legion/extensions/mesh.rb
|
|
51
|
+
- lib/legion/extensions/mesh/actors/gossip.rb
|
|
23
52
|
- lib/legion/extensions/mesh/actors/heartbeat.rb
|
|
24
53
|
- lib/legion/extensions/mesh/actors/pending_expiry.rb
|
|
25
54
|
- lib/legion/extensions/mesh/actors/preference_listener.rb
|
|
26
55
|
- lib/legion/extensions/mesh/actors/silence_watchdog.rb
|
|
27
56
|
- lib/legion/extensions/mesh/client.rb
|
|
57
|
+
- lib/legion/extensions/mesh/helpers/delegation.rb
|
|
58
|
+
- lib/legion/extensions/mesh/helpers/peer_verify.rb
|
|
28
59
|
- lib/legion/extensions/mesh/helpers/pending_requests.rb
|
|
29
60
|
- lib/legion/extensions/mesh/helpers/preference_profile.rb
|
|
30
61
|
- lib/legion/extensions/mesh/helpers/registry.rb
|
|
31
62
|
- lib/legion/extensions/mesh/helpers/topology.rb
|
|
63
|
+
- lib/legion/extensions/mesh/runners/delegation.rb
|
|
32
64
|
- lib/legion/extensions/mesh/runners/mesh.rb
|
|
33
65
|
- lib/legion/extensions/mesh/runners/preferences.rb
|
|
66
|
+
- lib/legion/extensions/mesh/transport/messages/gossip.rb
|
|
34
67
|
- lib/legion/extensions/mesh/transport/messages/mesh_departure.rb
|
|
35
68
|
- lib/legion/extensions/mesh/transport/messages/preference_query.rb
|
|
36
69
|
- lib/legion/extensions/mesh/transport/messages/preference_response.rb
|
|
70
|
+
- lib/legion/extensions/mesh/transport/queues/gossip.rb
|
|
37
71
|
- lib/legion/extensions/mesh/transport/queues/preference.rb
|
|
38
72
|
- lib/legion/extensions/mesh/version.rb
|
|
73
|
+
- spec/legion/extensions/mesh/actors/gossip_spec.rb
|
|
39
74
|
- spec/legion/extensions/mesh/actors/heartbeat_spec.rb
|
|
40
75
|
- spec/legion/extensions/mesh/actors/pending_expiry_spec.rb
|
|
41
76
|
- spec/legion/extensions/mesh/actors/preference_listener_spec.rb
|
|
42
77
|
- spec/legion/extensions/mesh/actors/silence_watchdog_spec.rb
|
|
43
78
|
- spec/legion/extensions/mesh/client_spec.rb
|
|
79
|
+
- spec/legion/extensions/mesh/helpers/delegation_spec.rb
|
|
80
|
+
- spec/legion/extensions/mesh/helpers/peer_verify_spec.rb
|
|
44
81
|
- spec/legion/extensions/mesh/helpers/pending_requests_spec.rb
|
|
45
82
|
- spec/legion/extensions/mesh/helpers/preference_profile_spec.rb
|
|
46
83
|
- spec/legion/extensions/mesh/helpers/registry_spec.rb
|
|
47
84
|
- spec/legion/extensions/mesh/helpers/topology_spec.rb
|
|
85
|
+
- spec/legion/extensions/mesh/runners/delegation_spec.rb
|
|
86
|
+
- spec/legion/extensions/mesh/runners/mesh_gossip_spec.rb
|
|
48
87
|
- spec/legion/extensions/mesh/runners/mesh_spec.rb
|
|
49
88
|
- spec/legion/extensions/mesh/runners/preferences_spec.rb
|
|
89
|
+
- spec/legion/extensions/mesh/transport/messages/gossip_spec.rb
|
|
50
90
|
- spec/legion/extensions/mesh/transport/messages/mesh_departure_spec.rb
|
|
51
91
|
- spec/legion/extensions/mesh/transport/messages/preference_query_spec.rb
|
|
52
92
|
- spec/legion/extensions/mesh/transport/messages/preference_response_spec.rb
|