lex-swarm 0.1.1 → 0.2.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/Gemfile +1 -1
- data/lib/legion/extensions/swarm/actors/orphan_sweeper.rb +38 -0
- data/lib/legion/extensions/swarm/actors/workspace_sync.rb +54 -0
- data/lib/legion/extensions/swarm/client.rb +2 -0
- data/lib/legion/extensions/swarm/helpers/sub_agent.rb +82 -0
- data/lib/legion/extensions/swarm/helpers/workspace.rb +91 -0
- data/lib/legion/extensions/swarm/runners/spawn_child.rb +17 -0
- data/lib/legion/extensions/swarm/runners/workspace.rb +62 -0
- data/lib/legion/extensions/swarm/version.rb +1 -1
- data/lib/legion/extensions/swarm.rb +4 -0
- data/spec/legion/extensions/swarm/actors/orphan_sweeper_spec.rb +41 -0
- data/spec/legion/extensions/swarm/actors/workspace_sync_spec.rb +48 -0
- data/spec/legion/extensions/swarm/client_spec.rb +20 -0
- data/spec/legion/extensions/swarm/helpers/sub_agent_spec.rb +67 -0
- data/spec/legion/extensions/swarm/helpers/workspace_spec.rb +108 -0
- data/spec/legion/extensions/swarm/runners/spawn_child_spec.rb +22 -0
- data/spec/legion/extensions/swarm/runners/workspace_spec.rb +79 -0
- metadata +13 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 578c4f78e11c1cf390624da98867d76b55274db01186715405a0e423645b99ae
|
|
4
|
+
data.tar.gz: 4584ad7f4ebe1c53eef32337404d21f930e23703f182f5f2ea835bb2c6982778
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: be53ef935c5ae6c6196ed41ffa15b0cbfc8ce1c708d37fccf2ecc8d75c91a511f6e2132952a7449fa58e9d56d762a8a72876a0cb9c81a0b93ce214399b168f9b
|
|
7
|
+
data.tar.gz: 5707c99712feea161e00e88a70767e74bbdd3daddb9509d99cd4b94d5236a9f246cedf109cba9a64d1077311f540163418f1f81b47f0603583334661285ad4dc
|
data/Gemfile
CHANGED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Swarm
|
|
6
|
+
module Actor
|
|
7
|
+
class OrphanSweeper < Legion::Extensions::Actors::Every
|
|
8
|
+
def time = 300
|
|
9
|
+
def run_now? = false
|
|
10
|
+
def use_runner? = false
|
|
11
|
+
def check_subtask? = false
|
|
12
|
+
def generate_task? = false
|
|
13
|
+
|
|
14
|
+
def action(**_opts)
|
|
15
|
+
return { swept: 0 } unless defined?(Legion::Data)
|
|
16
|
+
|
|
17
|
+
tasks = Legion::Data.connection[:tasks]
|
|
18
|
+
orphans = tasks.exclude(parent_id: nil)
|
|
19
|
+
.exclude(status: %w[complete failed])
|
|
20
|
+
.all
|
|
21
|
+
.select do |t|
|
|
22
|
+
parent = tasks.where(id: t[:parent_id]).first
|
|
23
|
+
parent.nil? || %w[complete failed].include?(parent[:status])
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
orphans.each do |orphan|
|
|
27
|
+
tasks.where(id: orphan[:id]).update(status: 'failed')
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
{ swept: orphans.size }
|
|
31
|
+
rescue StandardError => e
|
|
32
|
+
{ swept: 0, error: e.message }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'time'
|
|
4
|
+
require_relative '../helpers/workspace'
|
|
5
|
+
|
|
6
|
+
module Legion
|
|
7
|
+
module Extensions
|
|
8
|
+
module Swarm
|
|
9
|
+
module Actors
|
|
10
|
+
class WorkspaceSync
|
|
11
|
+
ROUTING_PREFIX = 'swarm.workspace'
|
|
12
|
+
|
|
13
|
+
def publish_change(charter_id:, key:, operation:, value: nil, author: nil, version: nil, **) # rubocop:disable Metrics/ParameterLists
|
|
14
|
+
return { success: true, skipped: :no_transport } unless defined?(Legion::Transport)
|
|
15
|
+
|
|
16
|
+
routing_key = "#{ROUTING_PREFIX}.#{charter_id}"
|
|
17
|
+
payload = { charter_id: charter_id, key: key, value: value,
|
|
18
|
+
author: author, version: version, operation: operation,
|
|
19
|
+
timestamp: Time.now.utc.to_s }
|
|
20
|
+
|
|
21
|
+
Legion::Transport.publish(routing_key: routing_key, payload: payload)
|
|
22
|
+
Legion::Logging.debug "[swarm-workspace-sync] published #{operation} #{key} to #{routing_key}"
|
|
23
|
+
{ success: true, routing_key: routing_key }
|
|
24
|
+
rescue StandardError => e
|
|
25
|
+
Legion::Logging.warn "[swarm-workspace-sync] publish failed: #{e.message}"
|
|
26
|
+
{ success: true, skipped: :publish_error, message: e.message }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def apply_incoming(charter_id:, key:, operation:, value: nil, author: nil, version: nil, timestamp: nil, **) # rubocop:disable Metrics/ParameterLists
|
|
30
|
+
op = operation.to_s
|
|
31
|
+
case op
|
|
32
|
+
when 'put'
|
|
33
|
+
ts = timestamp.is_a?(String) ? Time.parse(timestamp) : (timestamp || Time.now.utc)
|
|
34
|
+
applied = workspace.apply_remote(charter_id, key: key, value: value,
|
|
35
|
+
author: author, version: version || 1, timestamp: ts)
|
|
36
|
+
{ success: true, applied: applied }
|
|
37
|
+
when 'delete'
|
|
38
|
+
workspace.delete(charter_id, key: key)
|
|
39
|
+
{ success: true, applied: true }
|
|
40
|
+
else
|
|
41
|
+
{ success: false, reason: :unknown_operation, operation: op }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def workspace
|
|
48
|
+
@workspace ||= Helpers::Workspace.new
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -3,12 +3,14 @@
|
|
|
3
3
|
require 'legion/extensions/swarm/helpers/charter'
|
|
4
4
|
require 'legion/extensions/swarm/helpers/swarm_store'
|
|
5
5
|
require 'legion/extensions/swarm/runners/swarm'
|
|
6
|
+
require 'legion/extensions/swarm/runners/workspace'
|
|
6
7
|
|
|
7
8
|
module Legion
|
|
8
9
|
module Extensions
|
|
9
10
|
module Swarm
|
|
10
11
|
class Client
|
|
11
12
|
include Runners::Swarm
|
|
13
|
+
include Runners::Workspace
|
|
12
14
|
|
|
13
15
|
def initialize(**)
|
|
14
16
|
@swarm_store = Helpers::SwarmStore.new
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Swarm
|
|
6
|
+
module Helpers
|
|
7
|
+
module SubAgent
|
|
8
|
+
def spawn(runner:, function:, payload:, parent_task_id:, **)
|
|
9
|
+
return { success: false, reason: :data_unavailable } unless defined?(Legion::Data::Model::Task)
|
|
10
|
+
|
|
11
|
+
parent = Legion::Data::Model::Task[parent_task_id]
|
|
12
|
+
return { success: false, reason: :parent_not_found } unless parent
|
|
13
|
+
|
|
14
|
+
new_depth = (parent.respond_to?(:depth) ? parent.depth.to_i : 0) + 1
|
|
15
|
+
|
|
16
|
+
return { success: false, reason: :depth_exceeded } if new_depth > max_depth
|
|
17
|
+
return { success: false, reason: :concurrent_exceeded } if concurrent_children_count > max_concurrent
|
|
18
|
+
return { success: false, reason: :per_parent_exceeded } if children_of(parent_task_id) > max_per_parent
|
|
19
|
+
|
|
20
|
+
if defined?(Legion::Ingress)
|
|
21
|
+
result = Legion::Ingress.run(
|
|
22
|
+
payload: payload,
|
|
23
|
+
runner_class: runner,
|
|
24
|
+
function: function,
|
|
25
|
+
source: 'sub_agent',
|
|
26
|
+
generate_task: true,
|
|
27
|
+
check_subtask: false
|
|
28
|
+
)
|
|
29
|
+
{ success: true, task_id: result[:task_id], depth: new_depth }
|
|
30
|
+
else
|
|
31
|
+
{ success: false, reason: :ingress_unavailable }
|
|
32
|
+
end
|
|
33
|
+
rescue StandardError => e
|
|
34
|
+
{ success: false, reason: :error, message: e.message }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def max_depth
|
|
40
|
+
Legion::Settings.dig(:swarm, :max_depth) || 3
|
|
41
|
+
rescue StandardError
|
|
42
|
+
3
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def max_concurrent
|
|
46
|
+
Legion::Settings.dig(:swarm, :max_concurrent) || 20
|
|
47
|
+
rescue StandardError
|
|
48
|
+
20
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def max_per_parent
|
|
52
|
+
Legion::Settings.dig(:swarm, :max_per_parent) || 10
|
|
53
|
+
rescue StandardError
|
|
54
|
+
10
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def concurrent_children_count
|
|
58
|
+
return 0 unless defined?(Legion::Data)
|
|
59
|
+
|
|
60
|
+
Legion::Data.connection[:tasks]
|
|
61
|
+
.exclude(parent_id: nil)
|
|
62
|
+
.exclude(status: %w[complete failed])
|
|
63
|
+
.count
|
|
64
|
+
rescue StandardError
|
|
65
|
+
0
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def children_of(parent_task_id)
|
|
69
|
+
return 0 unless defined?(Legion::Data)
|
|
70
|
+
|
|
71
|
+
Legion::Data.connection[:tasks]
|
|
72
|
+
.where(parent_id: parent_task_id)
|
|
73
|
+
.exclude(status: %w[complete failed])
|
|
74
|
+
.count
|
|
75
|
+
rescue StandardError
|
|
76
|
+
0
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Swarm
|
|
6
|
+
module Helpers
|
|
7
|
+
class Workspace
|
|
8
|
+
def initialize
|
|
9
|
+
@store = {}
|
|
10
|
+
@mutex = Mutex.new
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def put(charter_id, key:, value:, author:)
|
|
14
|
+
@mutex.synchronize do
|
|
15
|
+
charter_store = (@store[charter_id] ||= {})
|
|
16
|
+
existing = charter_store[key]
|
|
17
|
+
version = existing ? existing[:version] + 1 : 1
|
|
18
|
+
charter_store[key] = {
|
|
19
|
+
key: key,
|
|
20
|
+
value: value,
|
|
21
|
+
author: author,
|
|
22
|
+
version: version,
|
|
23
|
+
timestamp: Time.now.utc
|
|
24
|
+
}
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def get(charter_id, key:)
|
|
29
|
+
@mutex.synchronize do
|
|
30
|
+
@store.dig(charter_id, key)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def list(charter_id)
|
|
35
|
+
@mutex.synchronize do
|
|
36
|
+
(@store[charter_id] || {}).dup
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def delete(charter_id, key:)
|
|
41
|
+
@mutex.synchronize do
|
|
42
|
+
charter_store = @store[charter_id]
|
|
43
|
+
return nil unless charter_store
|
|
44
|
+
|
|
45
|
+
charter_store.delete(key)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def clear_charter(charter_id)
|
|
50
|
+
@mutex.synchronize do
|
|
51
|
+
@store.delete(charter_id)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def apply_remote(charter_id, **entry)
|
|
56
|
+
key = entry[:key]
|
|
57
|
+
value = entry[:value]
|
|
58
|
+
author = entry[:author]
|
|
59
|
+
version = entry[:version]
|
|
60
|
+
timestamp = entry[:timestamp]
|
|
61
|
+
|
|
62
|
+
@mutex.synchronize do
|
|
63
|
+
charter_store = (@store[charter_id] ||= {})
|
|
64
|
+
existing = charter_store[key]
|
|
65
|
+
|
|
66
|
+
if existing.nil? || version > existing[:version] ||
|
|
67
|
+
(version == existing[:version] && timestamp > existing[:timestamp])
|
|
68
|
+
charter_store[key] = {
|
|
69
|
+
key: key,
|
|
70
|
+
value: value,
|
|
71
|
+
author: author,
|
|
72
|
+
version: version,
|
|
73
|
+
timestamp: timestamp
|
|
74
|
+
}
|
|
75
|
+
return true
|
|
76
|
+
end
|
|
77
|
+
false
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def stats
|
|
82
|
+
@mutex.synchronize do
|
|
83
|
+
total = @store.values.sum(&:size)
|
|
84
|
+
{ charter_count: @store.size, total_entries: total }
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Swarm
|
|
6
|
+
module Runners
|
|
7
|
+
module SpawnChild
|
|
8
|
+
include Helpers::SubAgent if defined?(Helpers::SubAgent)
|
|
9
|
+
|
|
10
|
+
def spawn_child(runner:, function:, payload:, parent_task_id:, **)
|
|
11
|
+
spawn(runner: runner, function: function, payload: payload, parent_task_id: parent_task_id)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../helpers/workspace'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Extensions
|
|
7
|
+
module Swarm
|
|
8
|
+
module Runners
|
|
9
|
+
module Workspace
|
|
10
|
+
include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
|
|
11
|
+
Legion::Extensions::Helpers.const_defined?(:Lex)
|
|
12
|
+
|
|
13
|
+
def workspace_put(charter_id:, key:, value:, author:, **)
|
|
14
|
+
entry = workspace.put(charter_id, key: key, value: value, author: author)
|
|
15
|
+
Legion::Logging.debug "[swarm-workspace] put: charter=#{charter_id[0..7]} key=#{key} v=#{entry[:version]}"
|
|
16
|
+
{ success: true, key: key, version: entry[:version], charter_id: charter_id }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def workspace_get(charter_id:, key:, **)
|
|
20
|
+
entry = workspace.get(charter_id, key: key)
|
|
21
|
+
if entry
|
|
22
|
+
{ success: true, entry: entry }
|
|
23
|
+
else
|
|
24
|
+
{ success: false, reason: :not_found, key: key, charter_id: charter_id }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def workspace_list(charter_id:, **)
|
|
29
|
+
entries = workspace.list(charter_id)
|
|
30
|
+
{ success: true, entries: entries, count: entries.size }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def workspace_delete(charter_id:, key:, **)
|
|
34
|
+
removed = workspace.delete(charter_id, key: key)
|
|
35
|
+
if removed
|
|
36
|
+
Legion::Logging.debug "[swarm-workspace] delete: charter=#{charter_id[0..7]} key=#{key}"
|
|
37
|
+
{ success: true, key: key, charter_id: charter_id }
|
|
38
|
+
else
|
|
39
|
+
{ success: false, reason: :not_found, key: key, charter_id: charter_id }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def workspace_clear(charter_id:, **)
|
|
44
|
+
workspace.clear_charter(charter_id)
|
|
45
|
+
Legion::Logging.debug "[swarm-workspace] clear: charter=#{charter_id[0..7]}"
|
|
46
|
+
{ success: true, charter_id: charter_id }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def workspace_stats(**)
|
|
50
|
+
workspace.stats.merge(success: true)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def workspace
|
|
56
|
+
@workspace ||= Helpers::Workspace.new
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
require 'legion/extensions/swarm/version'
|
|
4
4
|
require 'legion/extensions/swarm/helpers/charter'
|
|
5
5
|
require 'legion/extensions/swarm/helpers/swarm_store'
|
|
6
|
+
require 'legion/extensions/swarm/helpers/sub_agent'
|
|
7
|
+
require 'legion/extensions/swarm/helpers/workspace'
|
|
6
8
|
require 'legion/extensions/swarm/runners/swarm'
|
|
9
|
+
require 'legion/extensions/swarm/runners/spawn_child'
|
|
10
|
+
require 'legion/extensions/swarm/runners/workspace'
|
|
7
11
|
|
|
8
12
|
module Legion
|
|
9
13
|
module Extensions
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Actors
|
|
6
|
+
unless defined?(Every)
|
|
7
|
+
class Every # rubocop:disable Lint/EmptyClass
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
$LOADED_FEATURES << 'legion/extensions/actors/every'
|
|
15
|
+
|
|
16
|
+
require_relative '../../../../../lib/legion/extensions/swarm/actors/orphan_sweeper'
|
|
17
|
+
|
|
18
|
+
RSpec.describe Legion::Extensions::Swarm::Actor::OrphanSweeper do
|
|
19
|
+
subject(:actor) { described_class.new }
|
|
20
|
+
|
|
21
|
+
describe '#time' do
|
|
22
|
+
it 'returns 300 seconds' do
|
|
23
|
+
expect(actor.time).to eq(300)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
describe '#run_now?' do
|
|
28
|
+
it 'returns false' do
|
|
29
|
+
expect(actor.run_now?).to be false
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
describe '#action' do
|
|
34
|
+
context 'when Legion::Data is not available' do
|
|
35
|
+
it 'returns zero swept' do
|
|
36
|
+
result = actor.action
|
|
37
|
+
expect(result[:swept]).to eq(0)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require_relative '../../../../../lib/legion/extensions/swarm/actors/workspace_sync'
|
|
5
|
+
|
|
6
|
+
RSpec.describe Legion::Extensions::Swarm::Actors::WorkspaceSync do
|
|
7
|
+
subject(:actor) { described_class.allocate }
|
|
8
|
+
|
|
9
|
+
describe '#publish_change' do
|
|
10
|
+
it 'returns skipped when transport is not available' do
|
|
11
|
+
result = actor.publish_change(charter_id: 'c1', key: 'k', value: 'v',
|
|
12
|
+
author: 'a', version: 1, operation: :put)
|
|
13
|
+
expect(result[:success]).to be true
|
|
14
|
+
expect(result[:skipped]).to eq(:no_transport)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
describe '#apply_incoming' do
|
|
19
|
+
let(:workspace) { Legion::Extensions::Swarm::Helpers::Workspace.new }
|
|
20
|
+
|
|
21
|
+
before { actor.instance_variable_set(:@workspace, workspace) }
|
|
22
|
+
|
|
23
|
+
it 'applies a remote put operation' do
|
|
24
|
+
result = actor.apply_incoming(
|
|
25
|
+
charter_id: 'c1', key: 'shared', value: 'remote-data',
|
|
26
|
+
author: 'remote-agent', version: 1, timestamp: Time.now.utc.to_s, operation: 'put'
|
|
27
|
+
)
|
|
28
|
+
expect(result[:success]).to be true
|
|
29
|
+
expect(result[:applied]).to be true
|
|
30
|
+
expect(workspace.get('c1', key: 'shared')[:value]).to eq('remote-data')
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
it 'applies a remote delete operation' do
|
|
34
|
+
workspace.put('c1', key: 'doomed', value: 'bye', author: 'local')
|
|
35
|
+
result = actor.apply_incoming(
|
|
36
|
+
charter_id: 'c1', key: 'doomed', operation: 'delete'
|
|
37
|
+
)
|
|
38
|
+
expect(result[:success]).to be true
|
|
39
|
+
expect(workspace.get('c1', key: 'doomed')).to be_nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
it 'rejects unknown operations' do
|
|
43
|
+
result = actor.apply_incoming(charter_id: 'c1', key: 'x', operation: 'explode')
|
|
44
|
+
expect(result[:success]).to be false
|
|
45
|
+
expect(result[:reason]).to eq(:unknown_operation)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -13,4 +13,24 @@ RSpec.describe Legion::Extensions::Swarm::Client do
|
|
|
13
13
|
expect(client).to respond_to(:active_swarms)
|
|
14
14
|
expect(client).to respond_to(:swarm_status)
|
|
15
15
|
end
|
|
16
|
+
|
|
17
|
+
it 'responds to workspace runner methods' do
|
|
18
|
+
client = described_class.new
|
|
19
|
+
expect(client).to respond_to(:workspace_put)
|
|
20
|
+
expect(client).to respond_to(:workspace_get)
|
|
21
|
+
expect(client).to respond_to(:workspace_list)
|
|
22
|
+
expect(client).to respond_to(:workspace_delete)
|
|
23
|
+
expect(client).to respond_to(:workspace_clear)
|
|
24
|
+
expect(client).to respond_to(:workspace_stats)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
describe '#workspace_put and #workspace_get' do
|
|
28
|
+
it 'stores and retrieves workspace entries via client' do
|
|
29
|
+
client = described_class.new
|
|
30
|
+
client.workspace_put(charter_id: 'c1', key: 'data', value: 'hello', author: 'a')
|
|
31
|
+
result = client.workspace_get(charter_id: 'c1', key: 'data')
|
|
32
|
+
expect(result[:success]).to be true
|
|
33
|
+
expect(result[:entry][:value]).to eq('hello')
|
|
34
|
+
end
|
|
35
|
+
end
|
|
16
36
|
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe Legion::Extensions::Swarm::Helpers::SubAgent do
|
|
6
|
+
let(:helper_instance) { Class.new { include Legion::Extensions::Swarm::Helpers::SubAgent }.new }
|
|
7
|
+
|
|
8
|
+
before do
|
|
9
|
+
stub_const('Legion::Settings', Module.new do
|
|
10
|
+
def self.dig(*keys)
|
|
11
|
+
case keys
|
|
12
|
+
when %i[swarm max_depth] then 3
|
|
13
|
+
when %i[swarm max_concurrent] then 20
|
|
14
|
+
when %i[swarm max_per_parent] then 10
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
describe '#max_depth' do
|
|
21
|
+
it 'returns configured max depth' do
|
|
22
|
+
expect(helper_instance.send(:max_depth)).to eq(3)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
describe '#max_concurrent' do
|
|
27
|
+
it 'returns configured max concurrent' do
|
|
28
|
+
expect(helper_instance.send(:max_concurrent)).to eq(20)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
describe '#max_per_parent' do
|
|
33
|
+
it 'returns configured max per parent' do
|
|
34
|
+
expect(helper_instance.send(:max_per_parent)).to eq(10)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
describe '#spawn' do
|
|
39
|
+
context 'when legion-data is not available' do
|
|
40
|
+
it 'returns failure' do
|
|
41
|
+
result = helper_instance.spawn(runner: 'SomeRunner', function: 'do_thing',
|
|
42
|
+
payload: {}, parent_task_id: 1)
|
|
43
|
+
expect(result[:success]).to be false
|
|
44
|
+
expect(result[:reason]).to eq(:data_unavailable)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
context 'when depth would be exceeded' do
|
|
49
|
+
let(:parent_task) { double('Task', depth: 3, id: 1) }
|
|
50
|
+
|
|
51
|
+
before do
|
|
52
|
+
pt = parent_task
|
|
53
|
+
task_model = Class.new do
|
|
54
|
+
define_method(:[]) { |_id| pt }
|
|
55
|
+
end.new
|
|
56
|
+
stub_const('Legion::Data::Model::Task', task_model)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
it 'returns depth_exceeded' do
|
|
60
|
+
result = helper_instance.spawn(runner: 'SomeRunner', function: 'do_thing',
|
|
61
|
+
payload: {}, parent_task_id: 1)
|
|
62
|
+
expect(result[:success]).to be false
|
|
63
|
+
expect(result[:reason]).to eq(:depth_exceeded)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe Legion::Extensions::Swarm::Helpers::Workspace do
|
|
6
|
+
subject(:workspace) { described_class.new }
|
|
7
|
+
|
|
8
|
+
let(:charter_id) { 'charter-abc' }
|
|
9
|
+
|
|
10
|
+
describe '#put and #get' do
|
|
11
|
+
it 'stores and retrieves an entry' do
|
|
12
|
+
workspace.put(charter_id, key: 'findings', value: { data: [1, 2, 3] }, author: 'agent-a')
|
|
13
|
+
entry = workspace.get(charter_id, key: 'findings')
|
|
14
|
+
expect(entry[:value]).to eq({ data: [1, 2, 3] })
|
|
15
|
+
expect(entry[:author]).to eq('agent-a')
|
|
16
|
+
expect(entry[:version]).to eq(1)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
it 'increments version on overwrite' do
|
|
20
|
+
workspace.put(charter_id, key: 'result', value: 'v1', author: 'agent-a')
|
|
21
|
+
workspace.put(charter_id, key: 'result', value: 'v2', author: 'agent-b')
|
|
22
|
+
entry = workspace.get(charter_id, key: 'result')
|
|
23
|
+
expect(entry[:value]).to eq('v2')
|
|
24
|
+
expect(entry[:author]).to eq('agent-b')
|
|
25
|
+
expect(entry[:version]).to eq(2)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it 'returns nil for missing key' do
|
|
29
|
+
expect(workspace.get(charter_id, key: 'nope')).to be_nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
it 'returns nil for missing charter' do
|
|
33
|
+
expect(workspace.get('nonexistent', key: 'x')).to be_nil
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
describe '#list' do
|
|
38
|
+
it 'returns all entries for a charter' do
|
|
39
|
+
workspace.put(charter_id, key: 'a', value: 1, author: 'x')
|
|
40
|
+
workspace.put(charter_id, key: 'b', value: 2, author: 'y')
|
|
41
|
+
entries = workspace.list(charter_id)
|
|
42
|
+
expect(entries.keys).to contain_exactly('a', 'b')
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
it 'returns empty hash for unknown charter' do
|
|
46
|
+
expect(workspace.list('unknown')).to eq({})
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
describe '#delete' do
|
|
51
|
+
it 'removes an entry and returns it' do
|
|
52
|
+
workspace.put(charter_id, key: 'temp', value: 'data', author: 'a')
|
|
53
|
+
removed = workspace.delete(charter_id, key: 'temp')
|
|
54
|
+
expect(removed[:value]).to eq('data')
|
|
55
|
+
expect(workspace.get(charter_id, key: 'temp')).to be_nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
it 'returns nil when key does not exist' do
|
|
59
|
+
expect(workspace.delete(charter_id, key: 'nope')).to be_nil
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
describe '#clear_charter' do
|
|
64
|
+
it 'removes all entries for a charter' do
|
|
65
|
+
workspace.put(charter_id, key: 'a', value: 1, author: 'x')
|
|
66
|
+
workspace.put(charter_id, key: 'b', value: 2, author: 'x')
|
|
67
|
+
workspace.clear_charter(charter_id)
|
|
68
|
+
expect(workspace.list(charter_id)).to eq({})
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
describe '#apply_remote' do
|
|
73
|
+
it 'applies a remote put when version is higher' do
|
|
74
|
+
workspace.put(charter_id, key: 'shared', value: 'local', author: 'a')
|
|
75
|
+
workspace.apply_remote(charter_id, key: 'shared', value: 'remote', author: 'b',
|
|
76
|
+
version: 5, timestamp: Time.now.utc)
|
|
77
|
+
expect(workspace.get(charter_id, key: 'shared')[:value]).to eq('remote')
|
|
78
|
+
expect(workspace.get(charter_id, key: 'shared')[:version]).to eq(5)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
it 'ignores remote put when version is lower' do
|
|
82
|
+
workspace.put(charter_id, key: 'shared', value: 'local', author: 'a')
|
|
83
|
+
workspace.put(charter_id, key: 'shared', value: 'local2', author: 'a')
|
|
84
|
+
workspace.apply_remote(charter_id, key: 'shared', value: 'stale', author: 'b',
|
|
85
|
+
version: 1, timestamp: Time.now.utc)
|
|
86
|
+
expect(workspace.get(charter_id, key: 'shared')[:value]).to eq('local2')
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
it 'uses timestamp to break version ties' do
|
|
90
|
+
workspace.put(charter_id, key: 'tie', value: 'old', author: 'a')
|
|
91
|
+
later = Time.now.utc + 1
|
|
92
|
+
workspace.apply_remote(charter_id, key: 'tie', value: 'newer', author: 'b',
|
|
93
|
+
version: 1, timestamp: later)
|
|
94
|
+
expect(workspace.get(charter_id, key: 'tie')[:value]).to eq('newer')
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
describe '#stats' do
|
|
99
|
+
it 'returns charter count and total entries' do
|
|
100
|
+
workspace.put('c1', key: 'a', value: 1, author: 'x')
|
|
101
|
+
workspace.put('c1', key: 'b', value: 2, author: 'x')
|
|
102
|
+
workspace.put('c2', key: 'c', value: 3, author: 'y')
|
|
103
|
+
stats = workspace.stats
|
|
104
|
+
expect(stats[:charter_count]).to eq(2)
|
|
105
|
+
expect(stats[:total_entries]).to eq(3)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe Legion::Extensions::Swarm::Runners::SpawnChild do
|
|
6
|
+
let(:runner_instance) { Class.new { include Legion::Extensions::Swarm::Runners::SpawnChild }.new }
|
|
7
|
+
|
|
8
|
+
before do
|
|
9
|
+
stub_const('Legion::Settings', Module.new do
|
|
10
|
+
def self.dig(*_keys) = nil
|
|
11
|
+
end)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
describe '#spawn_child' do
|
|
15
|
+
it 'delegates to SubAgent.spawn and returns result' do
|
|
16
|
+
result = runner_instance.spawn_child(runner: 'R', function: 'f',
|
|
17
|
+
payload: {}, parent_task_id: 1)
|
|
18
|
+
expect(result).to be_a(Hash)
|
|
19
|
+
expect(result).to have_key(:success)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe Legion::Extensions::Swarm::Runners::Workspace do
|
|
6
|
+
subject(:runner) { Object.new.extend(described_class) }
|
|
7
|
+
|
|
8
|
+
let(:charter_id) { 'charter-123' }
|
|
9
|
+
|
|
10
|
+
before { runner.instance_variable_set(:@workspace, nil) }
|
|
11
|
+
|
|
12
|
+
describe '#workspace_put' do
|
|
13
|
+
it 'stores a value and returns success' do
|
|
14
|
+
result = runner.workspace_put(charter_id: charter_id, key: 'notes', value: 'hello', author: 'agent-a')
|
|
15
|
+
expect(result[:success]).to be true
|
|
16
|
+
expect(result[:key]).to eq('notes')
|
|
17
|
+
expect(result[:version]).to eq(1)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
describe '#workspace_get' do
|
|
22
|
+
it 'retrieves a stored value' do
|
|
23
|
+
runner.workspace_put(charter_id: charter_id, key: 'data', value: [1, 2], author: 'a')
|
|
24
|
+
result = runner.workspace_get(charter_id: charter_id, key: 'data')
|
|
25
|
+
expect(result[:success]).to be true
|
|
26
|
+
expect(result[:entry][:value]).to eq([1, 2])
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
it 'returns not_found for missing key' do
|
|
30
|
+
result = runner.workspace_get(charter_id: charter_id, key: 'nope')
|
|
31
|
+
expect(result[:success]).to be false
|
|
32
|
+
expect(result[:reason]).to eq(:not_found)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
describe '#workspace_list' do
|
|
37
|
+
it 'lists all entries for a charter' do
|
|
38
|
+
runner.workspace_put(charter_id: charter_id, key: 'a', value: 1, author: 'x')
|
|
39
|
+
runner.workspace_put(charter_id: charter_id, key: 'b', value: 2, author: 'x')
|
|
40
|
+
result = runner.workspace_list(charter_id: charter_id)
|
|
41
|
+
expect(result[:success]).to be true
|
|
42
|
+
expect(result[:count]).to eq(2)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
describe '#workspace_delete' do
|
|
47
|
+
it 'deletes an entry and returns success' do
|
|
48
|
+
runner.workspace_put(charter_id: charter_id, key: 'temp', value: 'x', author: 'a')
|
|
49
|
+
result = runner.workspace_delete(charter_id: charter_id, key: 'temp')
|
|
50
|
+
expect(result[:success]).to be true
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
it 'returns not_found for missing key' do
|
|
54
|
+
result = runner.workspace_delete(charter_id: charter_id, key: 'nope')
|
|
55
|
+
expect(result[:success]).to be false
|
|
56
|
+
expect(result[:reason]).to eq(:not_found)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
describe '#workspace_clear' do
|
|
61
|
+
it 'clears all entries for a charter' do
|
|
62
|
+
runner.workspace_put(charter_id: charter_id, key: 'a', value: 1, author: 'x')
|
|
63
|
+
result = runner.workspace_clear(charter_id: charter_id)
|
|
64
|
+
expect(result[:success]).to be true
|
|
65
|
+
expect(runner.workspace_list(charter_id: charter_id)[:count]).to eq(0)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
describe '#workspace_stats' do
|
|
70
|
+
it 'returns workspace statistics' do
|
|
71
|
+
runner.workspace_put(charter_id: 'c1', key: 'a', value: 1, author: 'x')
|
|
72
|
+
runner.workspace_put(charter_id: 'c2', key: 'b', value: 2, author: 'y')
|
|
73
|
+
result = runner.workspace_stats
|
|
74
|
+
expect(result[:success]).to be true
|
|
75
|
+
expect(result[:charter_count]).to eq(2)
|
|
76
|
+
expect(result[:total_entries]).to eq(2)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: lex-swarm
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Esity
|
|
@@ -33,17 +33,29 @@ files:
|
|
|
33
33
|
- Gemfile
|
|
34
34
|
- lex-swarm.gemspec
|
|
35
35
|
- lib/legion/extensions/swarm.rb
|
|
36
|
+
- lib/legion/extensions/swarm/actors/orphan_sweeper.rb
|
|
36
37
|
- lib/legion/extensions/swarm/actors/stale_check.rb
|
|
38
|
+
- lib/legion/extensions/swarm/actors/workspace_sync.rb
|
|
37
39
|
- lib/legion/extensions/swarm/client.rb
|
|
38
40
|
- lib/legion/extensions/swarm/helpers/charter.rb
|
|
41
|
+
- lib/legion/extensions/swarm/helpers/sub_agent.rb
|
|
39
42
|
- lib/legion/extensions/swarm/helpers/swarm_store.rb
|
|
43
|
+
- lib/legion/extensions/swarm/helpers/workspace.rb
|
|
44
|
+
- lib/legion/extensions/swarm/runners/spawn_child.rb
|
|
40
45
|
- lib/legion/extensions/swarm/runners/swarm.rb
|
|
46
|
+
- lib/legion/extensions/swarm/runners/workspace.rb
|
|
41
47
|
- lib/legion/extensions/swarm/version.rb
|
|
48
|
+
- spec/legion/extensions/swarm/actors/orphan_sweeper_spec.rb
|
|
42
49
|
- spec/legion/extensions/swarm/actors/stale_check_spec.rb
|
|
50
|
+
- spec/legion/extensions/swarm/actors/workspace_sync_spec.rb
|
|
43
51
|
- spec/legion/extensions/swarm/client_spec.rb
|
|
44
52
|
- spec/legion/extensions/swarm/helpers/charter_spec.rb
|
|
53
|
+
- spec/legion/extensions/swarm/helpers/sub_agent_spec.rb
|
|
45
54
|
- spec/legion/extensions/swarm/helpers/swarm_store_spec.rb
|
|
55
|
+
- spec/legion/extensions/swarm/helpers/workspace_spec.rb
|
|
56
|
+
- spec/legion/extensions/swarm/runners/spawn_child_spec.rb
|
|
46
57
|
- spec/legion/extensions/swarm/runners/swarm_spec.rb
|
|
58
|
+
- spec/legion/extensions/swarm/runners/workspace_spec.rb
|
|
47
59
|
- spec/spec_helper.rb
|
|
48
60
|
homepage: https://github.com/LegionIO/lex-swarm
|
|
49
61
|
licenses:
|