lex-detect 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ac8b336bd2c34b58a559c0f4b664c728b2c61991d9f88f4a4bc83014ff5fe45f
4
- data.tar.gz: 8d6b78909cf1e3c405f6251099f686882bab688496a66588681ad6646e7b2455
3
+ metadata.gz: 4be5aba6e8a22792e9f9fcbc2db27f18fe2d51f176044e7a18bdcc7db534becf
4
+ data.tar.gz: de631309f69e478a59d46d7ea9d8f0ff90f4ceff01e198424813e5bece7c00a1
5
5
  SHA512:
6
- metadata.gz: e0f94a3e7c7f3ab15a9f27c5646423d98a9c275c203911a8c6d3a44c1c17830c3de2cd1cad4e5d769e5d1662ab54e03daedfb3f885cd579d47ab62ef67c83031
7
- data.tar.gz: ac6498c5166dc7cbf6b077be7476b8911c979fe3eb911703840bb87f7944f35a5262581d6918ba4a142c98d37f4373816ac0eb68e7dae9b114f82979e4dd6afe
6
+ metadata.gz: 18ef38241cf694a8244900735878fe5e5f49a70e3cedf50db94eb2e902aec8c84ecd3074e79a98320accea445e10b9145eca6590fc5fda4b2efce07e5338f737
7
+ data.tar.gz: af5e47f0566ca56b6b1eee75afd6620a5ec6201363e2c1d68ca89d47681221a0634979a52d9e941764204b3cdeb6745a4d9e33305537f548044c148425b137b0
data/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.4] - 2026-03-20
4
+
5
+ ### Changed
6
+ - Version bump for deployment (0.1.3 was released before task observer and cancel task changes landed)
7
+
8
+ ## [0.1.3] - 2026-03-20
9
+
10
+ ### Added
11
+ - `Runners::TaskObserver` for monitoring task failure patterns; supports DB-based `observe(since:)` and array-based `observe(tasks:)` calling styles
12
+ - `check_timeout_risk` detects tasks running beyond 2x expected duration and emits `timeout_risk` alert
13
+ - `check_repeated_failure` detects >=3 failures in 10 minutes per runner class
14
+ - `publish_alerts` forwards structured alerts to AMQP when `Legion::Transport` is available
15
+ - `record_observations` persists per-task observation records to `Legion::Data::Local` observer_events table
16
+ - `check_and_publish_failure_patterns` publishes failure pattern events to AMQP for self-healing pipeline integration
17
+ - `extract_gem_name` helper derives gem name from runner class string
18
+ - `build_failure_pattern` creates structured failure pattern hash
19
+ - `Runners::CancelTask` sets `cancelled_at` timestamp on tasks, guarded by `Legion::Data` availability
20
+ - `Actors::ObserverTick` runs `TaskObserver#observe` every 60 seconds, passing `since:` for incremental DB scans
21
+ - Local migration `20260320000001_create_observer_events` creates observer_events table for cognitive observation history
22
+
3
23
  ## [0.1.2] - 2026-03-19
4
24
 
5
25
  ### Added
data/CLAUDE.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # lex-detect: Environment-Aware Extension Discovery
2
2
 
3
3
  **Repository Level 3 Documentation**
4
- - **Parent (Level 2)**: `/Users/miverso2/rubymine/legion/extensions-core/CLAUDE.md`
5
- - **Parent (Level 1)**: `/Users/miverso2/rubymine/legion/CLAUDE.md`
4
+ - **Parent**: `/Users/miverso2/rubymine/legion/extensions-core/CLAUDE.md`
5
+ - **Grandparent**: `/Users/miverso2/rubymine/legion/CLAUDE.md`
6
6
 
7
7
  ## Purpose
8
8
 
@@ -10,7 +10,7 @@ Legion Extension that scans the local environment and recommends which `lex-*` e
10
10
 
11
11
  **GitHub**: https://github.com/LegionIO/lex-detect
12
12
  **License**: MIT
13
- **Version**: 0.1.1
13
+ **Version**: 0.1.2
14
14
 
15
15
  ## Architecture
16
16
 
@@ -19,10 +19,13 @@ Legion::Extensions::Detect
19
19
  ├── CATALOG (constant) # Frozen array of 20 detection rules
20
20
  ├── Scanner # Probes environment, matches against catalog
21
21
  ├── Installer # Gem.install wrapper with dry_run support
22
+ ├── Actors/
23
+ │ ├── FullScan (Once) # Full environment scan at startup
24
+ │ └── DeltaScan (Every) # Periodic delta scan every 6 hours
22
25
  └── Entry Point # Public API: scan, missing, install_missing!, catalog
23
26
  ```
24
27
 
25
- This is NOT a typical LEX no runners, no actors, no AMQP transport, no Faraday client. It is a local-only utility gem with zero runtime dependencies.
28
+ Local-only utility gem. Actors run scans but do not create AMQP queues.
26
29
 
27
30
  - `data_required? false` — no database needed
28
31
  - `remote_invocable? false` — no AMQP queue creation
@@ -49,12 +52,12 @@ Legion::Extensions::Detect.catalog # raw CATALOG constant
49
52
 
50
53
  ## Design Docs
51
54
 
52
- - Design: `docs/plans/2026-03-18-lex-detect-design.md`
53
- - Implementation: `docs/plans/2026-03-18-lex-detect-implementation.md`
55
+ - Design: `docs/work/completed/2026-03-18-lex-detect-design.md`
56
+ - Implementation: `docs/work/completed/2026-03-18-lex-detect-implementation.md`
54
57
 
55
58
  ## Testing
56
59
 
57
- 37 specs across 4 spec files.
60
+ 40 specs across 6 spec files.
58
61
 
59
62
  ```bash
60
63
  bundle install
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Detect
6
+ module Actors
7
+ class ObserverTick < Legion::Extensions::Actors::Every
8
+ def time = 60
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
+ observer = Object.new.extend(Runners::TaskObserver)
16
+ observer.observe(since: @last_tick || (Time.now - 60))
17
+ ensure
18
+ @last_tick = Time.now
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ Sequel.migration do
4
+ up do
5
+ create_table?(:observer_events) do
6
+ primary_key :id
7
+ String :task_id, size: 64
8
+ String :runner, size: 255
9
+ String :rule, size: 64, null: true
10
+ String :severity, size: 16, null: true
11
+ Float :duration, null: true
12
+ Float :token_cost, null: true
13
+ DateTime :observed_at, null: false, default: Sequel::CURRENT_TIMESTAMP
14
+ index :task_id
15
+ index :rule
16
+ index :observed_at
17
+ end
18
+ end
19
+
20
+ down do
21
+ drop_table?(:observer_events)
22
+ end
23
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Detect
6
+ module Runners
7
+ module CancelTask
8
+ def cancel_task(task_id:, **)
9
+ return { success: false, reason: :data_unavailable } unless defined?(Legion::Data)
10
+
11
+ task = Legion::Data::Model::Task[task_id]
12
+ return { success: false, reason: :not_found } unless task
13
+ return { success: false, reason: :already_cancelled } if task.respond_to?(:cancelled?) && task.cancelled?
14
+
15
+ task.update(cancelled_at: Time.now.utc)
16
+ { success: true, task_id: task_id, cancelled_at: task.cancelled_at }
17
+ rescue StandardError => e
18
+ { success: false, reason: :error, message: e.message }
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Detect
6
+ module Runners
7
+ module TaskObserver
8
+ extend self
9
+
10
+ def observe(tasks: nil, since: nil, **)
11
+ return observe_db(since: since) if tasks.nil?
12
+
13
+ return { success: true, alerts: [] } unless tasks.is_a?(Array)
14
+
15
+ alerts = generate_alerts(tasks)
16
+ check_and_publish_failure_patterns(tasks)
17
+ { success: true, alerts: alerts, task_count: tasks.size }
18
+ rescue StandardError => e
19
+ { success: false, reason: :error, message: e.message }
20
+ end
21
+
22
+ private
23
+
24
+ def observe_db(since: nil)
25
+ return { alerts: [], observed: 0 } unless defined?(Legion::Data)
26
+
27
+ since ||= Time.now - 60
28
+ db_tasks = Legion::Data.connection[:tasks]
29
+ .where { started_at > since }
30
+ .all
31
+
32
+ alerts = db_tasks.filter_map { |task| evaluate_rules(task) }
33
+
34
+ publish_alerts(alerts) if alerts.any?
35
+ record_observations(db_tasks, alerts)
36
+
37
+ { alerts: alerts, observed: db_tasks.size }
38
+ rescue StandardError => e
39
+ { alerts: [], observed: 0, error: e.message }
40
+ end
41
+
42
+ def evaluate_rules(task)
43
+ check_timeout_risk(task) ||
44
+ check_repeated_failure(task) ||
45
+ check_cost_spike(task)
46
+ end
47
+
48
+ def check_timeout_risk(task, expected_duration: 120)
49
+ return nil unless task[:status] == 'running' && task[:started_at]
50
+
51
+ elapsed = Time.now - task[:started_at]
52
+ return nil unless elapsed > (expected_duration * 2)
53
+
54
+ {
55
+ rule: 'timeout_risk',
56
+ runner: task[:runner_class],
57
+ task_id: task[:id],
58
+ severity: 'warn',
59
+ detail: "Running for #{elapsed.round}s (expected #{expected_duration}s)",
60
+ observed_at: Time.now.utc
61
+ }
62
+ end
63
+
64
+ def check_repeated_failure(task)
65
+ return nil unless defined?(Legion::Data) && task[:runner_class]
66
+
67
+ count = Legion::Data.connection[:tasks]
68
+ .where(runner_class: task[:runner_class], status: 'failed')
69
+ .where { started_at > Time.now - 600 }
70
+ .count
71
+ return nil unless count >= 3
72
+
73
+ {
74
+ rule: 'repeated_failure',
75
+ runner: task[:runner_class],
76
+ task_id: task[:id],
77
+ severity: 'critical',
78
+ detail: "#{count} failures in last 10 minutes",
79
+ observed_at: Time.now.utc
80
+ }
81
+ rescue StandardError
82
+ nil
83
+ end
84
+
85
+ def check_cost_spike(_task)
86
+ nil
87
+ end
88
+
89
+ def publish_alerts(alerts)
90
+ return unless defined?(Legion::Transport)
91
+
92
+ alerts.each do |alert|
93
+ Legion::Transport::Messages::Dynamic.new(
94
+ function: 'observer_alert',
95
+ payload: alert
96
+ ).publish
97
+ end
98
+ rescue StandardError
99
+ nil
100
+ end
101
+
102
+ def record_observations(db_tasks, alerts)
103
+ return unless defined?(Legion::Data::Local)
104
+
105
+ db_tasks.each do |task|
106
+ alert = alerts.find { |a| a[:task_id] == task[:id] }
107
+ Legion::Data::Local.connection[:observer_events].insert(
108
+ task_id: task[:id],
109
+ runner: task[:runner_class],
110
+ rule: alert&.dig(:rule),
111
+ severity: alert&.dig(:severity),
112
+ duration: task[:started_at] ? (Time.now - task[:started_at]).round(2) : nil,
113
+ token_cost: nil,
114
+ observed_at: Time.now.utc
115
+ )
116
+ end
117
+ rescue StandardError
118
+ nil
119
+ end
120
+
121
+ def generate_alerts(tasks)
122
+ failed = tasks.select { |t| t[:status] == 'failed' }
123
+ return [] if failed.empty?
124
+
125
+ failed.group_by { |t| t[:runner_class] }.filter_map do |runner_class, failures|
126
+ next unless failures.size >= 3
127
+
128
+ { runner_class: runner_class, failure_count: failures.size, level: :warning }
129
+ end
130
+ end
131
+
132
+ def check_and_publish_failure_patterns(tasks)
133
+ return unless defined?(Legion::Data) && defined?(Legion::Transport)
134
+
135
+ runner_failures = tasks.select { |t| t[:status] == 'failed' }
136
+ .group_by { |t| t[:runner_class] }
137
+
138
+ runner_failures.each do |runner_class, failures|
139
+ next unless failures.size >= 3
140
+
141
+ gem_name = extract_gem_name(runner_class)
142
+ backtraces = failures.filter_map { |f| f[:error_backtrace] }.first(5)
143
+ error_class = failures.first[:error_class] || 'StandardError'
144
+
145
+ pattern = build_failure_pattern(gem_name, runner_class, error_class, backtraces, failures.size)
146
+ publish_failure_pattern(pattern)
147
+ end
148
+ rescue StandardError
149
+ nil
150
+ end
151
+
152
+ def build_failure_pattern(gem_name, runner_class, error_class, backtraces, count)
153
+ {
154
+ gem_name: gem_name,
155
+ runner_class: runner_class,
156
+ error_class: error_class,
157
+ backtraces: backtraces,
158
+ failure_count: count,
159
+ window_minutes: 60
160
+ }
161
+ end
162
+
163
+ def publish_failure_pattern(pattern)
164
+ return unless defined?(Legion::Transport::Messages::Dynamic)
165
+
166
+ Legion::Transport::Messages::Dynamic.new(
167
+ function: 'auto_fix',
168
+ payload: pattern
169
+ ).publish
170
+ rescue StandardError
171
+ nil
172
+ end
173
+
174
+ def extract_gem_name(runner_class)
175
+ return nil unless runner_class
176
+
177
+ parts = runner_class.to_s.split('::')
178
+ ext_idx = parts.index('Extensions')
179
+ return nil unless ext_idx && parts[ext_idx + 1]
180
+
181
+ "lex-#{parts[ext_idx + 1].gsub(/([a-z])([A-Z])/, '\1_\2').downcase}"
182
+ end
183
+ end
184
+ end
185
+ end
186
+ end
187
+ end
@@ -3,7 +3,7 @@
3
3
  module Legion
4
4
  module Extensions
5
5
  module Detect
6
- VERSION = '0.1.2'
6
+ VERSION = '0.1.4'
7
7
  end
8
8
  end
9
9
  end
@@ -4,6 +4,8 @@ require 'legion/extensions/detect/version'
4
4
  require 'legion/extensions/detect/catalog'
5
5
  require 'legion/extensions/detect/scanner'
6
6
  require 'legion/extensions/detect/installer'
7
+ require_relative 'detect/runners/task_observer'
8
+ require_relative 'detect/runners/cancel_task'
7
9
 
8
10
  module Legion
9
11
  module Extensions
@@ -43,6 +45,7 @@ module Legion
43
45
 
44
46
  require_relative 'detect/actors/full_scan' if defined?(Legion::Extensions::Actors::Once)
45
47
  require_relative 'detect/actors/delta_scan' if defined?(Legion::Extensions::Actors::Every)
48
+ require_relative 'detect/actors/observer_tick' if defined?(Legion::Extensions::Actors::Every)
46
49
 
47
50
  if defined?(Legion::Data::Local)
48
51
  Legion::Data::Local.register_migrations(
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lex-detect
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.1.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -29,9 +29,13 @@ files:
29
29
  - lib/legion/extensions/detect.rb
30
30
  - lib/legion/extensions/detect/actors/delta_scan.rb
31
31
  - lib/legion/extensions/detect/actors/full_scan.rb
32
+ - lib/legion/extensions/detect/actors/observer_tick.rb
32
33
  - lib/legion/extensions/detect/catalog.rb
33
34
  - lib/legion/extensions/detect/installer.rb
34
35
  - lib/legion/extensions/detect/local_migrations/20260319000001_create_detect_results.rb
36
+ - lib/legion/extensions/detect/local_migrations/20260320000001_create_observer_events.rb
37
+ - lib/legion/extensions/detect/runners/cancel_task.rb
38
+ - lib/legion/extensions/detect/runners/task_observer.rb
35
39
  - lib/legion/extensions/detect/scanner.rb
36
40
  - lib/legion/extensions/detect/version.rb
37
41
  homepage: https://github.com/LegionIO/lex-detect