legionio 1.6.8 → 1.6.9
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/.rubocop.yml +1 -0
- data/CHANGELOG.md +23 -0
- data/Gemfile +2 -0
- data/integration/self_generate_spec.rb +398 -0
- data/lib/legion/extensions/helpers/lex.rb +2 -0
- data/lib/legion/extensions/helpers/secret.rb +144 -0
- data/lib/legion/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e67d5abaf8c274cad673af9996e29577949a9e945c66ee099b1c8e23eeff4a3f
|
|
4
|
+
data.tar.gz: 4c3113323f086005de660cd84128036d59919ee2f2482ce84a855334bab007c4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d221a3d63b90d155b24fabd47594aef550467d6136a96092208a7f710455f87f416ae4110815e323c7bfcb83aec13ec4c41dff7b2db14b0846dafbe55ca9be66
|
|
7
|
+
data.tar.gz: b495e7daddcbb0e5cbda7c93f152ca285e044972ee8b74c9d432f91e97c4536d1087539f7b8daf784910cb359935453b6aa1f9e972c8d81b688d55c8986e2f34
|
data/.rubocop.yml
CHANGED
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# Legion Changelog
|
|
2
2
|
|
|
3
|
+
## [Unreleased]
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- End-to-end integration test for TBI Phase 5 self-generating functions loop (9 examples)
|
|
7
|
+
- Test dependencies: lex-codegen, lex-eval added to Gemfile for integration testing
|
|
8
|
+
- Specs for `legion codegen` CLI subcommand (8 subcommands, 22 examples)
|
|
9
|
+
- Specs for `/api/codegen/*` API routes (8 routes, 20 examples)
|
|
10
|
+
- Specs for `setup_generated_functions` boot loading in Service (4 examples)
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- Guard `Legion::Transport::Messages::Dynamic` stub definition in integration spec with `unless defined?` to prevent redefinition conflicts when real implementations are present
|
|
14
|
+
- Wrap `lex-codegen` and `lex-eval` requires in `LoadError` rescue guards in integration spec; sets `LEGION_CODEGEN_EXTENSION_AVAILABLE` / `LEGION_EVAL_EXTENSION_AVAILABLE` flags and skips entire example group via `before(:all)` when extensions are unavailable
|
|
15
|
+
- Move `Legion::LLM.chat` stub to `RSpec.configure before(:each)` block so it always intercepts regardless of whether the real `legion-llm` gem is loaded, preventing external LLM calls in integration tests
|
|
16
|
+
- Fix `service_setup_apollo_spec` "starts Apollo::Local" example: stub `Legion::Apollo.start` to prevent internal double-call of `Apollo::Local.start`
|
|
17
|
+
|
|
18
|
+
## [1.6.9] - 2026-03-26
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
- `Helpers::Secret` mixin with `SecretAccessor` for per-user and per-lex Vault KV v2 secret access
|
|
22
|
+
- Identity resolution chain: Kerberos principal -> Entra UPN -> explicit user -> ENV['USER']
|
|
23
|
+
- `secret[:name]` / `secret[:name] = { ... }` / `secret.write` / `secret.exist?` / `secret.delete`
|
|
24
|
+
- `shared: true` option for extension-scoped (non-user) secrets
|
|
25
|
+
|
|
3
26
|
## [1.6.8] - 2026-03-26
|
|
4
27
|
|
|
5
28
|
### Added
|
data/Gemfile
CHANGED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rspec'
|
|
4
|
+
require 'tmpdir'
|
|
5
|
+
require 'fileutils'
|
|
6
|
+
|
|
7
|
+
# Load core gems
|
|
8
|
+
require 'legion/json'
|
|
9
|
+
require 'legion/logging'
|
|
10
|
+
require 'legion/settings'
|
|
11
|
+
|
|
12
|
+
# Stub modules that may not be available in isolation
|
|
13
|
+
unless defined?(Legion::Transport::Messages::Dynamic)
|
|
14
|
+
module Legion
|
|
15
|
+
module Transport
|
|
16
|
+
module Messages
|
|
17
|
+
class Dynamic
|
|
18
|
+
attr_reader :function, :data
|
|
19
|
+
|
|
20
|
+
def initialize(function:, data:, **)
|
|
21
|
+
@function = function
|
|
22
|
+
@data = data
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def publish
|
|
26
|
+
Legion::Transport::Local.publish('codegen', @function, Legion::JSON.dump(@data))
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Ensure Legion::LLM module exists so it can be stubbed, but don't overwrite a real implementation.
|
|
35
|
+
unless defined?(Legion::LLM)
|
|
36
|
+
module Legion
|
|
37
|
+
module LLM
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Load transport Local for InProcess mode
|
|
43
|
+
require 'legion/transport/local'
|
|
44
|
+
|
|
45
|
+
# Load codegen extension
|
|
46
|
+
begin
|
|
47
|
+
require 'legion/extensions/codegen'
|
|
48
|
+
LEGION_CODEGEN_EXTENSION_AVAILABLE = true
|
|
49
|
+
rescue LoadError => e
|
|
50
|
+
LEGION_CODEGEN_EXTENSION_AVAILABLE = false
|
|
51
|
+
warn "lex-codegen / legion codegen extension not available; skipping dependent behavior: #{e.message}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Load eval extension (only code_review runner + security evaluator)
|
|
55
|
+
begin
|
|
56
|
+
require 'legion/extensions/eval'
|
|
57
|
+
LEGION_EVAL_EXTENSION_AVAILABLE = true
|
|
58
|
+
rescue LoadError => e
|
|
59
|
+
LEGION_EVAL_EXTENSION_AVAILABLE = false
|
|
60
|
+
warn "lex-eval / legion eval extension not available; skipping dependent behavior: #{e.message}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Stub MCP Server if not available
|
|
64
|
+
unless defined?(Legion::MCP::Server)
|
|
65
|
+
module Legion
|
|
66
|
+
module MCP
|
|
67
|
+
module Server
|
|
68
|
+
@tool_registry = []
|
|
69
|
+
@tool_registry_lock = Mutex.new
|
|
70
|
+
|
|
71
|
+
class << self
|
|
72
|
+
attr_reader :tool_registry
|
|
73
|
+
|
|
74
|
+
def register_tool(tool_class)
|
|
75
|
+
@tool_registry_lock.synchronize do
|
|
76
|
+
return if tool_registry.any? { |tc| tc.respond_to?(:tool_name) && tc.tool_name == tool_class.tool_name }
|
|
77
|
+
|
|
78
|
+
tool_registry << tool_class
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def unregister_tool(tool_name)
|
|
83
|
+
@tool_registry_lock.synchronize do
|
|
84
|
+
tool_registry.reject! { |tc| tc.respond_to?(:tool_name) && tc.tool_name == tool_name }
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def reset_caches!; end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
LLM_STUB_CODE = <<~RUBY
|
|
96
|
+
# frozen_string_literal: true
|
|
97
|
+
|
|
98
|
+
module Legion
|
|
99
|
+
module Generated
|
|
100
|
+
module GreetUser
|
|
101
|
+
extend self
|
|
102
|
+
|
|
103
|
+
def greet(name:)
|
|
104
|
+
{ success: true, greeting: "Hello, \#{name}!" }
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
RUBY
|
|
110
|
+
|
|
111
|
+
RSpec.configure do |config|
|
|
112
|
+
config.disable_monkey_patching!
|
|
113
|
+
config.expect_with(:rspec) { |c| c.syntax = :expect }
|
|
114
|
+
|
|
115
|
+
config.before(:each) do
|
|
116
|
+
allow(Legion::LLM).to receive(:chat) do |messages:, _caller: nil, **_kwargs|
|
|
117
|
+
messages.last[:content]
|
|
118
|
+
Struct.new(:content).new(LLM_STUB_CODE)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
RSpec.describe 'Self-Generating Functions End-to-End' do
|
|
124
|
+
# Skip this entire example group if the required extensions are not available.
|
|
125
|
+
before(:all) do
|
|
126
|
+
extensions_unavailable =
|
|
127
|
+
(defined?(LEGION_CODEGEN_EXTENSION_AVAILABLE) && !LEGION_CODEGEN_EXTENSION_AVAILABLE) ||
|
|
128
|
+
(defined?(LEGION_EVAL_EXTENSION_AVAILABLE) && !LEGION_EVAL_EXTENSION_AVAILABLE)
|
|
129
|
+
|
|
130
|
+
skip('Legion Codegen/Eval extensions are not available; skipping self-generate integration specs.') if extensions_unavailable
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
let(:output_dir) { Dir.mktmpdir('legion_e2e_codegen') }
|
|
134
|
+
|
|
135
|
+
before do
|
|
136
|
+
# Reset Local transport
|
|
137
|
+
Legion::Transport::Local.reset! if Legion::Transport::Local.respond_to?(:reset!)
|
|
138
|
+
|
|
139
|
+
# Reset GeneratedRegistry (only if Codegen extension is loaded)
|
|
140
|
+
Legion::Extensions::Codegen::Helpers::GeneratedRegistry.reset! if defined?(Legion::Extensions::Codegen::Helpers::GeneratedRegistry)
|
|
141
|
+
|
|
142
|
+
# Configure settings for test
|
|
143
|
+
allow(Legion::Settings).to receive(:dig).and_return(nil)
|
|
144
|
+
allow(Legion::Settings).to receive(:dig).with(:codegen, :self_generate, :enabled).and_return(true)
|
|
145
|
+
allow(Legion::Settings).to receive(:dig).with(:codegen, :self_generate, :cooldown_seconds).and_return(0)
|
|
146
|
+
allow(Legion::Settings).to receive(:dig).with(:codegen, :self_generate, :max_gaps_per_cycle).and_return(5)
|
|
147
|
+
allow(Legion::Settings).to receive(:dig).with(:codegen, :self_generate, :runner_method, :output_dir).and_return(output_dir)
|
|
148
|
+
allow(Legion::Settings).to receive(:dig).with(:codegen, :self_generate, :validation).and_return(
|
|
149
|
+
{ syntax_check: true, run_specs: false, llm_review: false, max_retries: 2 }
|
|
150
|
+
)
|
|
151
|
+
allow(Legion::Settings).to receive(:dig).with(:codegen, :self_generate, :corroboration, :enabled).and_return(false)
|
|
152
|
+
allow(Legion::Settings).to receive(:dig).with(:codegen, :self_generate, :corroboration, :min_agents).and_return(2)
|
|
153
|
+
allow(Legion::Settings).to receive(:dig).with(:node, :name).and_return('test-node')
|
|
154
|
+
allow(Legion::Settings).to receive(:[]).and_return(nil)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
after do
|
|
158
|
+
FileUtils.rm_rf(output_dir)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
describe 'gap detection -> generation -> validation -> registration' do
|
|
162
|
+
let(:synthetic_gap) do
|
|
163
|
+
{
|
|
164
|
+
gap_id: 'gap_e2e_001',
|
|
165
|
+
gap_type: 'unmatched_intent',
|
|
166
|
+
intent: 'greet user',
|
|
167
|
+
occurrence_count: 3,
|
|
168
|
+
priority: 0.7,
|
|
169
|
+
metadata: {}
|
|
170
|
+
}
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
it 'generates code from a gap and passes validation' do
|
|
174
|
+
# Phase 1: GapSubscriber receives a gap and generates code
|
|
175
|
+
subscriber = Object.new
|
|
176
|
+
subscriber.extend(Legion::Extensions::Codegen::Actor::GapSubscriber)
|
|
177
|
+
|
|
178
|
+
generation = subscriber.action(synthetic_gap)
|
|
179
|
+
|
|
180
|
+
expect(generation[:success]).to be true
|
|
181
|
+
expect(generation[:generation_id]).to start_with('gen_')
|
|
182
|
+
expect(generation[:tier]).to eq(:simple)
|
|
183
|
+
expect(generation[:code]).to include('module Legion')
|
|
184
|
+
expect(generation[:file_path]).to start_with(output_dir)
|
|
185
|
+
expect(File.exist?(generation[:file_path])).to be true
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
it 'validates generated code through the review pipeline' do
|
|
189
|
+
# Phase 1: Generate
|
|
190
|
+
subscriber = Object.new
|
|
191
|
+
subscriber.extend(Legion::Extensions::Codegen::Actor::GapSubscriber)
|
|
192
|
+
generation = subscriber.action(synthetic_gap)
|
|
193
|
+
expect(generation[:success]).to be true
|
|
194
|
+
|
|
195
|
+
# Phase 2: Review (simulating what CodeReviewSubscriber does)
|
|
196
|
+
review = Legion::Extensions::Eval::Runners::CodeReview.review_generated(
|
|
197
|
+
code: generation[:code],
|
|
198
|
+
spec_code: generation[:spec_code],
|
|
199
|
+
context: { gap_type: 'unmatched_intent', intent: 'greet user' }
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
expect(review[:passed]).to be true
|
|
203
|
+
expect(review[:verdict]).to eq(:approve)
|
|
204
|
+
expect(review[:confidence]).to be > 0.0
|
|
205
|
+
expect(review[:stages][:syntax][:passed]).to be true
|
|
206
|
+
expect(review[:stages][:security][:passed]).to be true
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
it 'registers approved code via ReviewHandler' do
|
|
210
|
+
# Phase 1: Generate
|
|
211
|
+
subscriber = Object.new
|
|
212
|
+
subscriber.extend(Legion::Extensions::Codegen::Actor::GapSubscriber)
|
|
213
|
+
generation = subscriber.action(synthetic_gap)
|
|
214
|
+
expect(generation[:success]).to be true
|
|
215
|
+
|
|
216
|
+
# Phase 2: Persist to registry
|
|
217
|
+
registry_record = Legion::Extensions::Codegen::Helpers::GeneratedRegistry.persist(
|
|
218
|
+
generation: {
|
|
219
|
+
id: generation[:generation_id],
|
|
220
|
+
gap_id: generation[:gap_id],
|
|
221
|
+
gap_type: generation[:gap_type],
|
|
222
|
+
tier: generation[:tier],
|
|
223
|
+
name: 'greet_user',
|
|
224
|
+
file_path: generation[:file_path],
|
|
225
|
+
spec_path: generation[:spec_path]
|
|
226
|
+
}
|
|
227
|
+
)
|
|
228
|
+
expect(registry_record[:status]).to eq('pending')
|
|
229
|
+
|
|
230
|
+
# Phase 3: Review
|
|
231
|
+
review_result = {
|
|
232
|
+
generation_id: generation[:generation_id],
|
|
233
|
+
verdict: :approve,
|
|
234
|
+
confidence: 0.95,
|
|
235
|
+
issues: [],
|
|
236
|
+
scores: {}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
# Phase 4: ReviewHandler processes the verdict
|
|
240
|
+
result = Legion::Extensions::Codegen::Runners::ReviewHandler.handle_verdict(review: review_result)
|
|
241
|
+
|
|
242
|
+
expect(result[:success]).to be true
|
|
243
|
+
expect(result[:action]).to eq(:approved)
|
|
244
|
+
|
|
245
|
+
# Verify registry updated
|
|
246
|
+
record = Legion::Extensions::Codegen::Helpers::GeneratedRegistry.get(id: generation[:generation_id])
|
|
247
|
+
expect(record[:status]).to eq('approved')
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
it 'parks rejected code' do
|
|
251
|
+
subscriber = Object.new
|
|
252
|
+
subscriber.extend(Legion::Extensions::Codegen::Actor::GapSubscriber)
|
|
253
|
+
generation = subscriber.action(synthetic_gap)
|
|
254
|
+
expect(generation[:success]).to be true
|
|
255
|
+
|
|
256
|
+
Legion::Extensions::Codegen::Helpers::GeneratedRegistry.persist(
|
|
257
|
+
generation: {
|
|
258
|
+
id: generation[:generation_id],
|
|
259
|
+
gap_id: generation[:gap_id],
|
|
260
|
+
gap_type: generation[:gap_type],
|
|
261
|
+
tier: generation[:tier],
|
|
262
|
+
name: 'greet_user',
|
|
263
|
+
file_path: generation[:file_path],
|
|
264
|
+
spec_path: generation[:spec_path]
|
|
265
|
+
}
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
result = Legion::Extensions::Codegen::Runners::ReviewHandler.handle_verdict(
|
|
269
|
+
review: { generation_id: generation[:generation_id], verdict: :reject, confidence: 0.1, issues: ['unsafe code'] }
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
expect(result[:success]).to be true
|
|
273
|
+
expect(result[:action]).to eq(:parked)
|
|
274
|
+
|
|
275
|
+
record = Legion::Extensions::Codegen::Helpers::GeneratedRegistry.get(id: generation[:generation_id])
|
|
276
|
+
expect(record[:status]).to eq('parked')
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
it 'retries on revise verdict up to max_retries then parks' do
|
|
280
|
+
subscriber = Object.new
|
|
281
|
+
subscriber.extend(Legion::Extensions::Codegen::Actor::GapSubscriber)
|
|
282
|
+
generation = subscriber.action(synthetic_gap)
|
|
283
|
+
expect(generation[:success]).to be true
|
|
284
|
+
|
|
285
|
+
Legion::Extensions::Codegen::Helpers::GeneratedRegistry.persist(
|
|
286
|
+
generation: {
|
|
287
|
+
id: generation[:generation_id],
|
|
288
|
+
gap_id: generation[:gap_id],
|
|
289
|
+
gap_type: generation[:gap_type],
|
|
290
|
+
tier: generation[:tier],
|
|
291
|
+
name: 'greet_user',
|
|
292
|
+
file_path: generation[:file_path],
|
|
293
|
+
spec_path: generation[:spec_path],
|
|
294
|
+
attempt_count: 2
|
|
295
|
+
}
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
result = Legion::Extensions::Codegen::Runners::ReviewHandler.handle_verdict(
|
|
299
|
+
review: { generation_id: generation[:generation_id], verdict: :revise, confidence: 0.4, issues: ['needs improvement'] }
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
expect(result[:success]).to be true
|
|
303
|
+
expect(result[:action]).to eq(:parked)
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
it 'exercises the full loop: generate -> validate -> register -> boot load' do
|
|
307
|
+
# Step 1: Generate
|
|
308
|
+
subscriber = Object.new
|
|
309
|
+
subscriber.extend(Legion::Extensions::Codegen::Actor::GapSubscriber)
|
|
310
|
+
generation = subscriber.action(synthetic_gap)
|
|
311
|
+
expect(generation[:success]).to be true
|
|
312
|
+
|
|
313
|
+
# Step 2: Validate
|
|
314
|
+
review = Legion::Extensions::Eval::Runners::CodeReview.review_generated(
|
|
315
|
+
code: generation[:code], spec_code: generation[:spec_code], context: {}
|
|
316
|
+
)
|
|
317
|
+
expect(review[:verdict]).to eq(:approve)
|
|
318
|
+
|
|
319
|
+
# Step 3: Persist + Approve
|
|
320
|
+
Legion::Extensions::Codegen::Helpers::GeneratedRegistry.persist(
|
|
321
|
+
generation: {
|
|
322
|
+
id: generation[:generation_id],
|
|
323
|
+
gap_id: generation[:gap_id],
|
|
324
|
+
gap_type: generation[:gap_type],
|
|
325
|
+
tier: generation[:tier],
|
|
326
|
+
name: 'greet_user',
|
|
327
|
+
file_path: generation[:file_path],
|
|
328
|
+
spec_path: generation[:spec_path]
|
|
329
|
+
}
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
Legion::Extensions::Codegen::Runners::ReviewHandler.handle_verdict(
|
|
333
|
+
review: { generation_id: generation[:generation_id], verdict: :approve, confidence: 0.95, issues: [] }
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
# Step 4: Boot load (simulates service restart)
|
|
337
|
+
loaded = Legion::Extensions::Codegen::Helpers::GeneratedRegistry.load_on_boot
|
|
338
|
+
expect(loaded).to eq(1)
|
|
339
|
+
|
|
340
|
+
# Step 5: Verify the generated module is actually loaded
|
|
341
|
+
expect(defined?(Legion::Generated::GreetUser)).to be_truthy
|
|
342
|
+
result = Legion::Generated::GreetUser.greet(name: 'World')
|
|
343
|
+
expect(result[:success]).to be true
|
|
344
|
+
expect(result[:greeting]).to eq('Hello, World!')
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
describe 'tier classification' do
|
|
349
|
+
it 'classifies low occurrence gaps as simple' do
|
|
350
|
+
tier = Legion::Extensions::Codegen::Helpers::TierClassifier.classify(gap: { occurrence_count: 5 })
|
|
351
|
+
expect(tier).to eq(:simple)
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
it 'classifies high occurrence gaps as complex' do
|
|
355
|
+
allow(Legion::Settings).to receive(:dig).with(:codegen, :self_generate, :tier, :simple_max_occurrences).and_return(10)
|
|
356
|
+
tier = Legion::Extensions::Codegen::Helpers::TierClassifier.classify(gap: { occurrence_count: 15 })
|
|
357
|
+
expect(tier).to eq(:complex)
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
describe 'ReviewSubscriber actor' do
|
|
362
|
+
it 'routes verdict through ReviewHandler' do
|
|
363
|
+
subscriber = Object.new
|
|
364
|
+
subscriber.extend(Legion::Extensions::Codegen::Actor::GapSubscriber)
|
|
365
|
+
generation = subscriber.action(
|
|
366
|
+
gap_id: 'gap_rs_001', gap_type: 'unmatched_intent', intent: 'greet user',
|
|
367
|
+
occurrence_count: 3, priority: 0.7
|
|
368
|
+
)
|
|
369
|
+
expect(generation[:success]).to be true
|
|
370
|
+
|
|
371
|
+
Legion::Extensions::Codegen::Helpers::GeneratedRegistry.persist(
|
|
372
|
+
generation: {
|
|
373
|
+
id: generation[:generation_id],
|
|
374
|
+
gap_id: generation[:gap_id],
|
|
375
|
+
gap_type: generation[:gap_type],
|
|
376
|
+
tier: generation[:tier],
|
|
377
|
+
name: 'greet_user',
|
|
378
|
+
file_path: generation[:file_path],
|
|
379
|
+
spec_path: generation[:spec_path]
|
|
380
|
+
}
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
review_sub = Object.new
|
|
384
|
+
review_sub.extend(Legion::Extensions::Codegen::Actor::ReviewSubscriber)
|
|
385
|
+
|
|
386
|
+
result = review_sub.action(
|
|
387
|
+
generation_id: generation[:generation_id],
|
|
388
|
+
verdict: 'approve',
|
|
389
|
+
confidence: 0.9,
|
|
390
|
+
issues: [],
|
|
391
|
+
scores: {}
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
expect(result[:success]).to be true
|
|
395
|
+
expect(result[:action]).to eq(:approved)
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'legion/json/helper'
|
|
4
|
+
require_relative 'secret'
|
|
4
5
|
|
|
5
6
|
module Legion
|
|
6
7
|
module Extensions
|
|
@@ -9,6 +10,7 @@ module Legion
|
|
|
9
10
|
include Legion::Extensions::Helpers::Core
|
|
10
11
|
include Legion::Extensions::Helpers::Logger
|
|
11
12
|
include Legion::JSON::Helper
|
|
13
|
+
include Legion::Extensions::Helpers::Secret
|
|
12
14
|
|
|
13
15
|
module ClassMethods
|
|
14
16
|
def expose_as_mcp_tool(value = :_unset)
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Helpers
|
|
6
|
+
class SecretAccessor
|
|
7
|
+
def initialize(lex_name:)
|
|
8
|
+
@lex_name = lex_name
|
|
9
|
+
@warned = false
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def [](name, shared: false, user: nil)
|
|
13
|
+
return nil unless crypt_available?
|
|
14
|
+
|
|
15
|
+
Legion::Crypt.get(resolve_path(name, shared: shared, user: user))
|
|
16
|
+
rescue StandardError => e
|
|
17
|
+
log_warn("secret read failed for #{name}: #{e.message}")
|
|
18
|
+
nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def []=(name, value)
|
|
22
|
+
return unless crypt_available?
|
|
23
|
+
|
|
24
|
+
Legion::Crypt.write(resolve_path(name, shared: false, user: nil), **value)
|
|
25
|
+
rescue StandardError => e
|
|
26
|
+
log_warn("secret write failed for #{name}: #{e.message}")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def write(name, shared: false, user: nil, **data)
|
|
30
|
+
return false unless crypt_available?
|
|
31
|
+
|
|
32
|
+
Legion::Crypt.write(resolve_path(name, shared: shared, user: user), **data)
|
|
33
|
+
true
|
|
34
|
+
rescue StandardError => e
|
|
35
|
+
log_warn("secret write failed for #{name}: #{e.message}")
|
|
36
|
+
false
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def exist?(name, shared: false, user: nil)
|
|
40
|
+
return false unless crypt_available?
|
|
41
|
+
|
|
42
|
+
Legion::Crypt.exist?(resolve_path(name, shared: shared, user: user))
|
|
43
|
+
rescue StandardError
|
|
44
|
+
false
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def delete(name, shared: false, user: nil)
|
|
48
|
+
return false unless crypt_available?
|
|
49
|
+
|
|
50
|
+
Legion::Crypt.delete(resolve_path(name, shared: shared, user: user))
|
|
51
|
+
true
|
|
52
|
+
rescue StandardError => e
|
|
53
|
+
log_warn("secret delete failed for #{name}: #{e.message}")
|
|
54
|
+
false
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def resolve_path(name, shared:, user:)
|
|
60
|
+
prefix = shared ? 'shared' : "users/#{resolve_user(user)}"
|
|
61
|
+
"#{prefix}/#{@lex_name}/#{name}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def resolve_user(explicit_user)
|
|
65
|
+
return explicit_user if explicit_user
|
|
66
|
+
|
|
67
|
+
Secret.resolved_identity || ENV.fetch('USER', 'default')
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def crypt_available?
|
|
71
|
+
return false unless defined?(Legion::Crypt)
|
|
72
|
+
|
|
73
|
+
unless @warned || vault_connected?
|
|
74
|
+
log_warn('Vault not connected — secret operations may fail')
|
|
75
|
+
@warned = true
|
|
76
|
+
end
|
|
77
|
+
true
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def vault_connected?
|
|
81
|
+
defined?(Legion::Settings) &&
|
|
82
|
+
Legion::Settings[:crypt]&.dig(:vault, :connected) == true
|
|
83
|
+
rescue StandardError
|
|
84
|
+
false
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def log_warn(msg)
|
|
88
|
+
Legion::Logging.warn("[Secret] #{msg}") if defined?(Legion::Logging)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
module Secret
|
|
93
|
+
@resolved_identity = nil
|
|
94
|
+
@identity_source = nil
|
|
95
|
+
|
|
96
|
+
class << self
|
|
97
|
+
attr_reader :resolved_identity, :identity_source
|
|
98
|
+
|
|
99
|
+
def resolve_identity!
|
|
100
|
+
@resolved_identity = nil
|
|
101
|
+
@identity_source = nil
|
|
102
|
+
|
|
103
|
+
if defined?(Legion::Crypt) && Legion::Crypt.respond_to?(:kerberos_principal) &&
|
|
104
|
+
Legion::Crypt.kerberos_principal
|
|
105
|
+
@resolved_identity = Legion::Crypt.kerberos_principal
|
|
106
|
+
@identity_source = :kerberos
|
|
107
|
+
elsif entra_principal
|
|
108
|
+
@resolved_identity = entra_principal
|
|
109
|
+
@identity_source = :entra
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
@resolved_identity
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def reset_identity!
|
|
116
|
+
@resolved_identity = nil
|
|
117
|
+
@identity_source = nil
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
def entra_principal
|
|
123
|
+
return nil unless defined?(Legion::Extensions::MicrosoftTeams::Helpers::TokenCache)
|
|
124
|
+
|
|
125
|
+
cache = Legion::Extensions::MicrosoftTeams::Helpers::TokenCache
|
|
126
|
+
return nil unless cache.respond_to?(:instance)
|
|
127
|
+
|
|
128
|
+
instance = cache.instance
|
|
129
|
+
return nil unless instance.respond_to?(:user_principal)
|
|
130
|
+
|
|
131
|
+
principal = instance.user_principal
|
|
132
|
+
principal unless principal.nil? || principal.empty?
|
|
133
|
+
rescue StandardError
|
|
134
|
+
nil
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def secret
|
|
139
|
+
@secret ||= SecretAccessor.new(lex_name: lex_name)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
data/lib/legion/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: legionio
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.6.
|
|
4
|
+
version: 1.6.9
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Esity
|
|
@@ -433,6 +433,7 @@ files:
|
|
|
433
433
|
- docs/README.md
|
|
434
434
|
- exe/legion
|
|
435
435
|
- exe/legionio
|
|
436
|
+
- integration/self_generate_spec.rb
|
|
436
437
|
- legionio.gemspec
|
|
437
438
|
- lib/legion.rb
|
|
438
439
|
- lib/legion/alerts.rb
|
|
@@ -767,6 +768,7 @@ files:
|
|
|
767
768
|
- lib/legion/extensions/helpers/lex.rb
|
|
768
769
|
- lib/legion/extensions/helpers/llm.rb
|
|
769
770
|
- lib/legion/extensions/helpers/logger.rb
|
|
771
|
+
- lib/legion/extensions/helpers/secret.rb
|
|
770
772
|
- lib/legion/extensions/helpers/segments.rb
|
|
771
773
|
- lib/legion/extensions/helpers/task.rb
|
|
772
774
|
- lib/legion/extensions/helpers/transport.rb
|