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 +4 -4
- data/CHANGELOG.md +20 -0
- data/CLAUDE.md +10 -7
- data/lib/legion/extensions/detect/actors/observer_tick.rb +24 -0
- data/lib/legion/extensions/detect/local_migrations/20260320000001_create_observer_events.rb +23 -0
- data/lib/legion/extensions/detect/runners/cancel_task.rb +24 -0
- data/lib/legion/extensions/detect/runners/task_observer.rb +187 -0
- data/lib/legion/extensions/detect/version.rb +1 -1
- data/lib/legion/extensions/detect.rb +3 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4be5aba6e8a22792e9f9fcbc2db27f18fe2d51f176044e7a18bdcc7db534becf
|
|
4
|
+
data.tar.gz: de631309f69e478a59d46d7ea9d8f0ff90f4ceff01e198424813e5bece7c00a1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
5
|
-
- **
|
|
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.
|
|
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
|
-
|
|
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/
|
|
53
|
-
- Implementation: `docs/
|
|
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
|
-
|
|
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
|
|
@@ -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.
|
|
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
|