lex-react 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 77b98f390c59e0768be5c629e704e3d5920297695a78fa114481a8fe5b89e9ac
4
+ data.tar.gz: b48f8e5959e929359661b2e4fe206ca65fa0d1e189a1c3628a403bfa142cfcf3
5
+ SHA512:
6
+ metadata.gz: 8608c662d7654352d1cd4222307ef6749f2ad916ca0a92a20ead94ad8cc28650e3f8f4a8e13a3c3f4f105b4d387afb4e087ef88cad8eecb5150bb2097d8a4e3c
7
+ data.tar.gz: c9b7db15123922391729970bd08d38d1208514115369b5f4ebc170e2f78d4576b5c3d52cf9985836804055c4ab858ef79d483395fac23ef344ccc0b50d14216f
data/CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+
3
+ ## [0.1.0] - 2026-03-24
4
+
5
+ ### Added
6
+ - Initial release: rule engine, event matcher, reaction dispatcher, loop breaker
7
+ - Wildcard event subscription via Legion::Events
8
+ - YAML-based configurable reaction rules via Legion::Settings
9
+ - Synapse autonomy level gating (OBSERVE/FILTER/TRANSFORM/AUTONOMOUS)
10
+ - Loop prevention with configurable depth limit and cooldown window
data/README.md ADDED
@@ -0,0 +1,29 @@
1
+ # lex-react
2
+
3
+ Reaction engine for LegionIO. Subscribes to `Legion::Events` and fires configurable reaction chains in response to events, with Synapse autonomy gating and loop prevention.
4
+
5
+ ## Configuration
6
+
7
+ ```yaml
8
+ react:
9
+ rules:
10
+ ci_failure:
11
+ enabled: true
12
+ source: "github.check_run.completed"
13
+ condition: "conclusion == 'failure'"
14
+ autonomy: FILTER
15
+ chain:
16
+ - lex-github.runners.fetch_check_logs
17
+ - lex-transformer.runners.analyze
18
+ max_depth: 3
19
+ cooldown_seconds: 60
20
+ max_reactions_per_hour: 100
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ lex-react auto-subscribes to `Legion::Events` on extension load. Rules are evaluated against every event. Matching rules dispatch task chains respecting Synapse autonomy levels.
26
+
27
+ ## License
28
+
29
+ MIT
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module React
6
+ module Actor
7
+ class EventSubscriber < Legion::Extensions::Actors::Once
8
+ def runner_class = 'Legion::Extensions::React'
9
+ def runner_function = 'subscribe!'
10
+ def check_subtask? = false
11
+ def generate_task? = false
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module React
6
+ module Helpers
7
+ module Constants
8
+ AUTONOMY_LEVELS = %i[observe filter transform autonomous].freeze
9
+
10
+ AUTONOMY_THRESHOLDS = {
11
+ observe: 0.0,
12
+ filter: 0.3,
13
+ transform: 0.6,
14
+ autonomous: 0.8
15
+ }.freeze
16
+
17
+ DEFAULT_MAX_DEPTH = 3
18
+ DEFAULT_COOLDOWN_SECONDS = 60
19
+ DEFAULT_MAX_REACTIONS_PER_HOUR = 100
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module React
6
+ module Helpers
7
+ module EventMatcher
8
+ module_function
9
+
10
+ def match?(pattern, event_name)
11
+ # Use placeholder so '.**' double-star replacement isn't re-processed by single '*' gsub
12
+ regex_str = pattern.gsub('.**', '__DS__')
13
+ .gsub('*', '[^.]*')
14
+ .gsub('__DS__', '\..*')
15
+ regex_str = "\\A#{regex_str}\\z"
16
+ Regexp.new(regex_str).match?(event_name)
17
+ rescue RegexpError
18
+ false
19
+ end
20
+
21
+ def evaluate_condition(condition, event)
22
+ return true if condition.nil? || condition.strip.empty?
23
+
24
+ # Parse simple conditions: "key == 'value'" or "key != 'value'"
25
+ if condition =~ /\A(\w+)\s*(==|!=)\s*'([^']*)'\z/
26
+ key = Regexp.last_match(1).to_sym
27
+ op = Regexp.last_match(2)
28
+ value = Regexp.last_match(3)
29
+ actual = event[key]&.to_s
30
+
31
+ case op
32
+ when '==' then actual == value
33
+ when '!=' then actual != value
34
+ else false
35
+ end
36
+ else
37
+ false
38
+ end
39
+ rescue StandardError
40
+ false
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module React
6
+ module Helpers
7
+ class LoopBreaker
8
+ def initialize(max_depth: Constants::DEFAULT_MAX_DEPTH,
9
+ cooldown_seconds: Constants::DEFAULT_COOLDOWN_SECONDS,
10
+ max_per_hour: Constants::DEFAULT_MAX_REACTIONS_PER_HOUR)
11
+ @max_depth = max_depth
12
+ @cooldown_seconds = cooldown_seconds
13
+ @max_per_hour = max_per_hour
14
+ @recent = [] # Array of { rule_id:, event_id:, at: }
15
+ @mutex = Mutex.new
16
+ end
17
+
18
+ def allow?(rule_id:, depth:, event_id: nil)
19
+ return false if depth > @max_depth
20
+
21
+ @mutex.synchronize do
22
+ prune_old_entries
23
+ return false if @recent.size >= @max_per_hour
24
+
25
+ if event_id && @cooldown_seconds.positive?
26
+ duplicate = @recent.any? do |r|
27
+ r[:rule_id] == rule_id && r[:event_id] == event_id
28
+ end
29
+ return false if duplicate
30
+ end
31
+
32
+ true
33
+ end
34
+ end
35
+
36
+ def record(rule_id:, event_id:)
37
+ @mutex.synchronize do
38
+ @recent << { rule_id: rule_id, event_id: event_id, at: Time.now }
39
+ end
40
+ end
41
+
42
+ def reactions_this_hour
43
+ @mutex.synchronize do
44
+ prune_old_entries
45
+ @recent.size
46
+ end
47
+ end
48
+
49
+ def stats
50
+ @mutex.synchronize do
51
+ prune_old_entries
52
+ { reactions_this_hour: @recent.size, max_per_hour: @max_per_hour }
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ def prune_old_entries
59
+ cutoff = Time.now - 3600
60
+ @recent.reject! { |r| r[:at] < cutoff }
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module React
6
+ class ReactionDispatcher
7
+ def initialize(loop_breaker:)
8
+ @loop_breaker = loop_breaker
9
+ end
10
+
11
+ def dispatch(rule:, event:, depth: 0)
12
+ event_id = event[:event].to_s
13
+
14
+ unless @loop_breaker.allow?(rule_id: rule[:id].to_s, depth: depth, event_id: event_id)
15
+ log_blocked(rule, event)
16
+ return { success: false, rule_id: rule[:id], reason: :loop_blocked }
17
+ end
18
+
19
+ if rule[:autonomy] == :observe
20
+ log_observed(rule, event)
21
+ return { success: true, rule_id: rule[:id], action: :observed, chain: rule[:chain] }
22
+ end
23
+
24
+ execute_chain(rule, event)
25
+ @loop_breaker.record(rule_id: rule[:id].to_s, event_id: event_id)
26
+
27
+ { success: true, rule_id: rule[:id], action: :dispatched, chain: rule[:chain] }
28
+ rescue StandardError => e
29
+ { success: false, rule_id: rule[:id], error: e.message }
30
+ end
31
+
32
+ private
33
+
34
+ def execute_chain(rule, event)
35
+ rule[:chain].each do |runner_ref|
36
+ dispatch_runner(runner_ref, event, rule)
37
+ end
38
+ end
39
+
40
+ def dispatch_runner(runner_ref, event, rule)
41
+ Legion::Events.emit('react.dispatched',
42
+ rule_id: rule[:id],
43
+ runner_ref: runner_ref,
44
+ source_event: event[:event],
45
+ autonomy: rule[:autonomy])
46
+ rescue StandardError => e
47
+ Legion::Logging.warn "[React] dispatch error: #{e.message}" if defined?(Legion::Logging)
48
+ end
49
+
50
+ def log_observed(rule, event)
51
+ return unless defined?(Legion::Logging)
52
+
53
+ Legion::Logging.info "[React] OBSERVE rule=#{rule[:id]} event=#{event[:event]} chain=#{rule[:chain]}"
54
+ end
55
+
56
+ def log_blocked(rule, event)
57
+ return unless defined?(Legion::Logging)
58
+
59
+ Legion::Logging.warn "[React] BLOCKED rule=#{rule[:id]} event=#{event[:event]} (loop prevention)"
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module React
6
+ class RuleEngine
7
+ attr_reader :rules
8
+
9
+ def initialize(rules_hash = {})
10
+ @rules = parse_rules(rules_hash)
11
+ end
12
+
13
+ def self.from_settings
14
+ rules_hash = if defined?(Legion::Settings) && !Legion::Settings[:react].nil?
15
+ Legion::Settings.dig(:react, :rules) || {}
16
+ else
17
+ {}
18
+ end
19
+ new(rules_hash)
20
+ rescue StandardError
21
+ new({})
22
+ end
23
+
24
+ def match(event)
25
+ event_name = event[:event].to_s
26
+ @rules.select do |rule|
27
+ Helpers::EventMatcher.match?(rule[:source], event_name) &&
28
+ Helpers::EventMatcher.evaluate_condition(rule[:condition], event)
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def parse_rules(rules_hash)
35
+ rules_hash.filter_map do |id, config|
36
+ next unless config.is_a?(Hash) && config[:enabled] != false
37
+
38
+ {
39
+ id: id.to_sym,
40
+ source: config[:source].to_s,
41
+ condition: config[:condition],
42
+ autonomy: (config[:autonomy] || 'observe').to_s.downcase.to_sym,
43
+ chain: Array(config[:chain])
44
+ }
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module React
6
+ module Runners
7
+ module React
8
+ module_function
9
+
10
+ def handle_event(event:)
11
+ engine = rule_engine
12
+ matches = engine.match(event)
13
+ dispatcher = reaction_dispatcher
14
+
15
+ results = matches.map do |rule|
16
+ dispatcher.dispatch(rule: rule, event: event, depth: event[:react_depth] || 0)
17
+ end
18
+
19
+ {
20
+ success: true,
21
+ matched_rules: matches.size,
22
+ results: results
23
+ }
24
+ rescue StandardError => e
25
+ { success: false, error: e.message }
26
+ end
27
+
28
+ def react_stats
29
+ engine = rule_engine
30
+ breaker_stats = loop_breaker.stats
31
+
32
+ {
33
+ success: true,
34
+ rule_count: engine.rules.size,
35
+ reactions_this_hour: breaker_stats[:reactions_this_hour],
36
+ max_per_hour: breaker_stats[:max_per_hour]
37
+ }
38
+ rescue StandardError => e
39
+ { success: false, error: e.message }
40
+ end
41
+
42
+ def reset!
43
+ @rule_engine = nil
44
+ @loop_breaker = nil
45
+ @reaction_dispatcher = nil
46
+ end
47
+
48
+ def rule_engine
49
+ @rule_engine ||= RuleEngine.from_settings
50
+ end
51
+
52
+ def loop_breaker
53
+ @loop_breaker ||= begin
54
+ settings = react_settings
55
+ Helpers::LoopBreaker.new(
56
+ max_depth: settings[:max_depth] || Helpers::Constants::DEFAULT_MAX_DEPTH,
57
+ cooldown_seconds: settings[:cooldown_seconds] || Helpers::Constants::DEFAULT_COOLDOWN_SECONDS,
58
+ max_per_hour: settings[:max_reactions_per_hour] || Helpers::Constants::DEFAULT_MAX_REACTIONS_PER_HOUR
59
+ )
60
+ end
61
+ end
62
+
63
+ def reaction_dispatcher
64
+ @reaction_dispatcher ||= ReactionDispatcher.new(loop_breaker: loop_breaker)
65
+ end
66
+
67
+ def react_settings
68
+ return {} unless defined?(Legion::Settings) && !Legion::Settings[:react].nil?
69
+
70
+ Legion::Settings[:react] || {}
71
+ rescue StandardError
72
+ {}
73
+ end
74
+
75
+ private_class_method :rule_engine, :loop_breaker, :reaction_dispatcher, :react_settings
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module React
6
+ VERSION = '0.1.0'
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/react/version'
4
+ require_relative 'react/helpers/constants'
5
+ require_relative 'react/helpers/event_matcher'
6
+ require_relative 'react/helpers/loop_breaker'
7
+ require_relative 'react/rule_engine'
8
+ require_relative 'react/reaction_dispatcher'
9
+ require_relative 'react/runners/react'
10
+
11
+ module Legion
12
+ module Extensions
13
+ module React
14
+ extend Legion::Extensions::Core if Legion::Extensions.const_defined?(:Core)
15
+
16
+ class << self
17
+ def data_required?
18
+ false
19
+ end
20
+
21
+ def subscribe!
22
+ return unless defined?(Legion::Events)
23
+
24
+ @subscription ||= Legion::Events.on('*') do |event|
25
+ next if event[:event].to_s.start_with?('react.')
26
+
27
+ Runners::React.handle_event(event: event)
28
+ rescue StandardError => e
29
+ Legion::Logging.warn "[React] event handler error: #{e.message}" if defined?(Legion::Logging)
30
+ end
31
+ end
32
+
33
+ def unsubscribe!
34
+ return unless @subscription && defined?(Legion::Events)
35
+
36
+ Legion::Events.off('*', @subscription)
37
+ @subscription = nil
38
+ end
39
+ end
40
+
41
+ require_relative 'react/actors/event_subscriber' if defined?(Legion::Extensions::Actors::Once)
42
+ end
43
+ end
44
+ end
metadata ADDED
@@ -0,0 +1,53 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: lex-react
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Esity
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Event-driven automation rules that subscribe to Legion::Events and fire
13
+ configurable reaction chains
14
+ email:
15
+ - legionio@esity.com
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - CHANGELOG.md
21
+ - README.md
22
+ - lib/legion/extensions/react.rb
23
+ - lib/legion/extensions/react/actors/event_subscriber.rb
24
+ - lib/legion/extensions/react/helpers/constants.rb
25
+ - lib/legion/extensions/react/helpers/event_matcher.rb
26
+ - lib/legion/extensions/react/helpers/loop_breaker.rb
27
+ - lib/legion/extensions/react/reaction_dispatcher.rb
28
+ - lib/legion/extensions/react/rule_engine.rb
29
+ - lib/legion/extensions/react/runners/react.rb
30
+ - lib/legion/extensions/react/version.rb
31
+ homepage: https://github.com/LegionIO/lex-react
32
+ licenses:
33
+ - MIT
34
+ metadata:
35
+ rubygems_mfa_required: 'true'
36
+ rdoc_options: []
37
+ require_paths:
38
+ - lib
39
+ required_ruby_version: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - ">="
42
+ - !ruby/object:Gem::Version
43
+ version: '3.4'
44
+ required_rubygems_version: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '0'
49
+ requirements: []
50
+ rubygems_version: 3.6.9
51
+ specification_version: 4
52
+ summary: Reaction engine for LegionIO
53
+ test_files: []