legionio 1.4.119 → 1.4.122

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.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -0
  3. data/legionio.gemspec +1 -0
  4. data/lib/legion/alerts.rb +5 -1
  5. data/lib/legion/api/audit.rb +11 -2
  6. data/lib/legion/api/auth.rb +14 -2
  7. data/lib/legion/api/auth_human.rb +28 -7
  8. data/lib/legion/api/auth_worker.rb +18 -3
  9. data/lib/legion/api/capacity.rb +9 -0
  10. data/lib/legion/api/chains.rb +26 -6
  11. data/lib/legion/api/coldstart.rb +16 -4
  12. data/lib/legion/api/extensions.rb +6 -2
  13. data/lib/legion/api/gaia.rb +6 -2
  14. data/lib/legion/api/graphql.rb +3 -1
  15. data/lib/legion/api/hooks.rb +19 -5
  16. data/lib/legion/api/lex.rb +4 -1
  17. data/lib/legion/api/llm.rb +54 -1
  18. data/lib/legion/api/middleware/auth.rb +3 -0
  19. data/lib/legion/api/middleware/body_limit.rb +3 -0
  20. data/lib/legion/api/middleware/rate_limit.rb +1 -0
  21. data/lib/legion/api/middleware/tenant.rb +4 -1
  22. data/lib/legion/api/prompts.rb +9 -4
  23. data/lib/legion/api/rbac.rb +17 -1
  24. data/lib/legion/api/relationships.rb +5 -0
  25. data/lib/legion/api/schedules.rb +12 -2
  26. data/lib/legion/api/settings.rb +14 -3
  27. data/lib/legion/api/tasks.rb +13 -3
  28. data/lib/legion/api/transport.rb +11 -4
  29. data/lib/legion/api/webhooks.rb +5 -1
  30. data/lib/legion/api/workers.rb +28 -8
  31. data/lib/legion/api.rb +2 -1
  32. data/lib/legion/audit.rb +1 -1
  33. data/lib/legion/capacity/model.rb +11 -2
  34. data/lib/legion/catalog.rb +2 -0
  35. data/lib/legion/cli/error_handler.rb +9 -1
  36. data/lib/legion/context.rb +3 -0
  37. data/lib/legion/digital_worker/lifecycle.rb +5 -1
  38. data/lib/legion/digital_worker/registry.rb +7 -0
  39. data/lib/legion/events.rb +3 -2
  40. data/lib/legion/extensions/actors/every.rb +7 -1
  41. data/lib/legion/extensions/actors/subscription.rb +7 -5
  42. data/lib/legion/extensions/builders/actors.rb +2 -1
  43. data/lib/legion/extensions/builders/routes.rb +1 -0
  44. data/lib/legion/extensions/builders/runners.rb +1 -0
  45. data/lib/legion/extensions/core.rb +9 -1
  46. data/lib/legion/extensions/transport.rb +3 -1
  47. data/lib/legion/graph/builder.rb +2 -0
  48. data/lib/legion/graph/exporter.rb +2 -0
  49. data/lib/legion/guardrails.rb +8 -2
  50. data/lib/legion/ingress.rb +14 -2
  51. data/lib/legion/process.rb +4 -0
  52. data/lib/legion/readiness.rb +10 -4
  53. data/lib/legion/runner/status.rb +8 -8
  54. data/lib/legion/runner.rb +4 -1
  55. data/lib/legion/service.rb +10 -0
  56. data/lib/legion/telemetry.rb +2 -1
  57. data/lib/legion/trace_search.rb +3 -0
  58. data/lib/legion/version.rb +1 -1
  59. data/lib/legion/webhooks.rb +9 -0
  60. metadata +15 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ac22c22ec64e117b1568d5569a2983e46fdc9aadb566642c05c35973fb29428a
4
- data.tar.gz: e6b5a99eebc5b0de37a59ad9cebc4fd5e96b8fb125b1974faa64e2454e268907
3
+ metadata.gz: 102a045825ff17747b2724547e3704b958c55b51e77ce846d4dd8146460e8dbb
4
+ data.tar.gz: eccf79a4549c7f5bc949c2dc7141dd9b5ff794c85483d6cc414c790b7c867c06
5
5
  SHA512:
6
- metadata.gz: 789c1485afd78d006d6d9aad725e483c5b6fd467a952476a11e1db29ac8c710f9d80c7e8dce4ac0045627abc45defae5ae45ebe995d167b95ffa866897ae4979
7
- data.tar.gz: 852f58359d4cb4beb1a1708f6d01f9423dc2d18e0d9f028493f2f018eaa5f999b87c564b8015d39004f08ff70e43e41ff92bfcb075fc28b8608281c702704fd8
6
+ metadata.gz: fb2d2613bfd4e539755cf225e5917ff8aec939840f1435c1308c71ecc862617e31e516876ca6dd513dfe5496106296af1421ae9e3b9431871a99594cdbab5d19
7
+ data.tar.gz: 383b632d9dbb961d01895b273b19ead0a1742d56dae85a6090a371aec4a995b065b226c5db1768454cbab95db947fd5eea3443fe89979a529fcdb06bf4d6af56
data/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Legion Changelog
2
2
 
3
+ ## [1.4.122] - 2026-03-22
4
+
5
+ ### Added
6
+ - GraphQL API via `graphql-ruby` gem: `POST /api/graphql` endpoint alongside existing REST API
7
+ - Schema types: QueryType, WorkerType, TaskType, ExtensionType, TeamType with field-level resolvers
8
+ - Resolver modules for workers, tasks, extensions, and teams (safe stubs with `defined?` guards)
9
+ - 45 new specs for GraphQL schema, queries, and error handling
10
+
11
+ ## [1.4.121] - 2026-03-22
12
+
13
+ ### Added
14
+ - Route `/api/llm/chat` through full Legion pipeline (Ingress -> RBAC -> Events -> Task -> Gateway metering -> LLM) when `lex-llm-gateway` is loaded
15
+ - `gateway_available?` helper to detect gateway runner presence
16
+ - Proper result extraction from `ingress_result[:result]` with support for RubyLLM response objects, error hashes, and plain strings
17
+ - Error logging in async LLM rescue block (previously silent)
18
+
19
+ ## [1.4.120] - 2026-03-22
20
+
21
+ ### Added
22
+ - Comprehensive logging throughout the framework: 55 files, 443 lines of `.info`, `.warn`, `.error`, `.debug` calls
23
+ - API routes: every non-2xx response logs at warn (4xx) or error (5xx), every mutation logs at info, debug for request entry
24
+ - Core framework: ingress, runner, extensions, actors, service lifecycle, readiness, events all log state transitions
25
+ - Extension system: autobuild, actor hooking, transport setup, builder phases all log at debug/info
26
+ - Digital worker lifecycle, capacity model, catalog, guardrails, webhooks, alerts, audit, telemetry all instrumented
27
+ - CLI error handler logs matched patterns (warn) and unhandled errors (error)
28
+
3
29
  ## [1.4.119] - 2026-03-22
4
30
 
5
31
  ### Added
data/legionio.gemspec CHANGED
@@ -42,6 +42,7 @@ Gem::Specification.new do |spec|
42
42
  spec.add_dependency 'concurrent-ruby', '>= 1.2'
43
43
  spec.add_dependency 'concurrent-ruby-ext', '>= 1.2'
44
44
  spec.add_dependency 'daemons', '>= 1.4'
45
+ spec.add_dependency 'graphql', '>= 2.0'
45
46
  spec.add_dependency 'oj', '>= 3.16'
46
47
  spec.add_dependency 'puma', '>= 6.0'
47
48
  spec.add_dependency 'rackup', '>= 2.0'
data/lib/legion/alerts.rb CHANGED
@@ -40,6 +40,8 @@ module Legion
40
40
  fired = []
41
41
  @rules.each do |rule|
42
42
  next unless event_matches?(event_name, rule.event_pattern)
43
+
44
+ Legion::Logging.debug "[Alerts] evaluating rule=#{rule.name} for event=#{event_name}" if defined?(Legion::Logging)
43
45
  next unless condition_met?(rule, event_name)
44
46
  next if in_cooldown?(rule)
45
47
 
@@ -81,12 +83,14 @@ module Legion
81
83
  alert = { rule: rule.name, event: event_name, severity: rule.severity,
82
84
  payload: payload, fired_at: Time.now.utc }
83
85
 
86
+ Legion::Logging.info "[Alerts] alert fired: rule=#{rule.name} event=#{event_name} severity=#{rule.severity}" if defined?(Legion::Logging)
87
+
84
88
  (rule.channels || []).each do |channel|
85
89
  case channel.to_sym
86
90
  when :events
87
91
  Legion::Events.emit('alert.fired', alert) if defined?(Legion::Events)
88
92
  when :log
89
- Legion::Logging.warn "[alert] #{rule.name}: #{event_name} (#{rule.severity})" if defined?(Legion::Logging)
93
+ Legion::Logging.warn "[Alerts] #{rule.name}: #{event_name} (#{rule.severity})" if defined?(Legion::Logging)
90
94
  when :webhook
91
95
  Legion::Webhooks.dispatch('alert.fired', alert) if defined?(Legion::Webhooks)
92
96
  end
@@ -4,7 +4,7 @@ module Legion
4
4
  class API < Sinatra::Base
5
5
  module Routes
6
6
  module Audit
7
- def self.registered(app)
7
+ def self.registered(app) # rubocop:disable Metrics/AbcSize
8
8
  app.get '/api/audit' do
9
9
  require_data!
10
10
  dataset = Legion::Data::Model::AuditLog.order(Sequel.desc(:id))
@@ -15,15 +15,24 @@ module Legion
15
15
  dataset = dataset.where { created_at >= Time.parse(params[:since]) } if params[:since]
16
16
  dataset = dataset.where { created_at <= Time.parse(params[:until]) } if params[:until]
17
17
  json_collection(dataset)
18
+ rescue StandardError => e
19
+ Legion::Logging.error "API GET /api/audit: #{e.class} — #{e.message}"
20
+ json_error('audit_error', e.message, status_code: 500)
18
21
  end
19
22
 
20
23
  app.get '/api/audit/verify' do
21
24
  require_data!
22
- halt 503, json_error('unavailable', 'lex-audit is not loaded', status_code: 503) unless defined?(Legion::Extensions::Audit::Runners::Audit)
25
+ unless defined?(Legion::Extensions::Audit::Runners::Audit)
26
+ Legion::Logging.warn 'API GET /api/audit/verify returned 503: lex-audit is not loaded'
27
+ halt 503, json_error('unavailable', 'lex-audit is not loaded', status_code: 503)
28
+ end
23
29
 
24
30
  runner = Object.new.extend(Legion::Extensions::Audit::Runners::Audit)
25
31
  result = runner.verify
26
32
  json_response(result)
33
+ rescue StandardError => e
34
+ Legion::Logging.error "API GET /api/audit/verify: #{e.class} — #{e.message}"
35
+ json_error('audit_error', e.message, status_code: 500)
27
36
  end
28
37
  end
29
38
  end
@@ -10,16 +10,21 @@ module Legion
10
10
 
11
11
  def self.register_token_exchange(app) # rubocop:disable Metrics/MethodLength
12
12
  app.post '/api/auth/token' do
13
+ Legion::Logging.debug "API: POST /api/auth/token params=#{params.keys}"
13
14
  body = parse_request_body
14
15
  grant_type = body[:grant_type]
15
16
  subject_token = body[:subject_token]
16
17
 
17
18
  unless grant_type == 'urn:ietf:params:oauth:grant-type:token-exchange'
19
+ Legion::Logging.warn "API POST /api/auth/token returned 400: unsupported grant_type=#{grant_type}"
18
20
  halt 400, json_error('unsupported_grant_type', 'expected urn:ietf:params:oauth:grant-type:token-exchange',
19
21
  status_code: 400)
20
22
  end
21
23
 
22
- halt 400, json_error('missing_subject_token', 'subject_token is required', status_code: 400) unless subject_token
24
+ unless subject_token
25
+ Legion::Logging.warn 'API POST /api/auth/token returned 400: subject_token is required'
26
+ halt 400, json_error('missing_subject_token', 'subject_token is required', status_code: 400)
27
+ end
23
28
 
24
29
  unless defined?(Legion::Crypt::JWT) && Legion::Crypt::JWT.respond_to?(:verify_with_jwks)
25
30
  halt 501, json_error('jwks_validation_not_available', 'legion-crypt JWKS support not loaded',
@@ -28,7 +33,10 @@ module Legion
28
33
 
29
34
  rbac_settings = (Legion::Settings[:rbac].is_a?(Hash) && Legion::Settings[:rbac][:entra]) || {}
30
35
  tenant_id = rbac_settings[:tenant_id]
31
- halt 500, json_error('entra_tenant_not_configured', 'rbac.entra.tenant_id not set', status_code: 500) unless tenant_id
36
+ unless tenant_id
37
+ Legion::Logging.error 'API POST /api/auth/token returned 500: rbac.entra.tenant_id not set'
38
+ halt 500, json_error('entra_tenant_not_configured', 'rbac.entra.tenant_id not set', status_code: 500)
39
+ end
32
40
 
33
41
  jwks_url = "https://login.microsoftonline.com/#{tenant_id}/discovery/v2.0/keys"
34
42
  issuer = "https://login.microsoftonline.com/#{tenant_id}/v2.0"
@@ -38,10 +46,13 @@ module Legion
38
46
  subject_token, jwks_url: jwks_url, issuers: [issuer]
39
47
  )
40
48
  rescue Legion::Crypt::JWT::ExpiredTokenError
49
+ Legion::Logging.warn 'API POST /api/auth/token returned 401: Entra token has expired'
41
50
  halt 401, json_error('token_expired', 'Entra token has expired', status_code: 401)
42
51
  rescue Legion::Crypt::JWT::InvalidTokenError => e
52
+ Legion::Logging.warn "API POST /api/auth/token returned 401: #{e.message}"
43
53
  halt 401, json_error('invalid_token', e.message, status_code: 401)
44
54
  rescue Legion::Crypt::JWT::Error => e
55
+ Legion::Logging.error "API POST /api/auth/token returned 502: #{e.message}"
45
56
  halt 502, json_error('identity_provider_unavailable', e.message, status_code: 502)
46
57
  end
47
58
 
@@ -63,6 +74,7 @@ module Legion
63
74
  roles: mapped[:roles], ttl: ttl
64
75
  )
65
76
 
77
+ Legion::Logging.info "API: issued human token for sub=#{mapped[:sub]} roles=#{mapped[:roles]&.join(',')}"
66
78
  json_response({
67
79
  access_token: token,
68
80
  token_type: 'Bearer',
@@ -44,7 +44,10 @@ module Legion
44
44
  def self.register_authorize(app)
45
45
  app.get '/api/auth/authorize' do
46
46
  entra = Routes::AuthHuman.resolve_entra_settings
47
- halt 500, json_error('entra_not_configured', 'Entra OAuth settings are missing', status_code: 500) unless entra[:tenant_id] && entra[:client_id]
47
+ unless entra[:tenant_id] && entra[:client_id]
48
+ Legion::Logging.error 'API GET /api/auth/authorize returned 500: Entra OAuth settings are missing'
49
+ halt 500, json_error('entra_not_configured', 'Entra OAuth settings are missing', status_code: 500)
50
+ end
48
51
 
49
52
  state = Legion::Crypt::JWT.issue(
50
53
  { nonce: SecureRandom.hex(16), purpose: 'oauth_state' },
@@ -63,27 +66,43 @@ module Legion
63
66
  end
64
67
  end
65
68
 
66
- def self.register_callback(app) # rubocop:disable Metrics/AbcSize
69
+ def self.register_callback(app) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
67
70
  app.get '/api/auth/callback' do
68
71
  entra = Routes::AuthHuman.resolve_entra_settings
69
- halt 500, json_error('entra_not_configured', 'Entra OAuth settings are missing', status_code: 500) unless entra[:tenant_id] && entra[:client_id]
72
+ unless entra[:tenant_id] && entra[:client_id]
73
+ Legion::Logging.error 'API GET /api/auth/callback returned 500: Entra OAuth settings are missing'
74
+ halt 500, json_error('entra_not_configured', 'Entra OAuth settings are missing', status_code: 500)
75
+ end
70
76
 
71
- halt 400, json_error('oauth_error', params[:error_description] || params[:error], status_code: 400) if params[:error]
72
- halt 400, json_error('missing_code', 'authorization code is required', status_code: 400) unless params[:code]
77
+ if params[:error]
78
+ Legion::Logging.warn "API GET /api/auth/callback returned 400: #{params[:error_description] || params[:error]}"
79
+ halt 400, json_error('oauth_error', params[:error_description] || params[:error], status_code: 400)
80
+ end
81
+ unless params[:code]
82
+ Legion::Logging.warn 'API GET /api/auth/callback returned 400: authorization code is required'
83
+ halt 400, json_error('missing_code', 'authorization code is required', status_code: 400)
84
+ end
73
85
 
74
86
  if params[:state]
75
87
  begin
76
88
  Legion::Crypt::JWT.verify(params[:state])
77
89
  rescue Legion::Crypt::JWT::Error
90
+ Legion::Logging.warn 'API GET /api/auth/callback returned 400: CSRF state token is invalid or expired'
78
91
  halt 400, json_error('invalid_state', 'CSRF state token is invalid or expired', status_code: 400)
79
92
  end
80
93
  end
81
94
 
82
95
  token_response = Routes::AuthHuman.exchange_code(entra, params[:code])
83
- halt 502, json_error('token_exchange_failed', 'Failed to exchange code for tokens', status_code: 502) unless token_response
96
+ unless token_response
97
+ Legion::Logging.error 'API GET /api/auth/callback returned 502: Failed to exchange code for tokens'
98
+ halt 502, json_error('token_exchange_failed', 'Failed to exchange code for tokens', status_code: 502)
99
+ end
84
100
 
85
101
  id_token = token_response[:id_token] || token_response['id_token']
86
- halt 502, json_error('no_id_token', 'Entra did not return an id_token', status_code: 502) unless id_token
102
+ unless id_token
103
+ Legion::Logging.error 'API GET /api/auth/callback returned 502: Entra did not return an id_token'
104
+ halt 502, json_error('no_id_token', 'Entra did not return an id_token', status_code: 502)
105
+ end
87
106
 
88
107
  jwks_url = "https://login.microsoftonline.com/#{entra[:tenant_id]}/discovery/v2.0/keys"
89
108
  issuer = "https://login.microsoftonline.com/#{entra[:tenant_id]}/v2.0"
@@ -91,6 +110,7 @@ module Legion
91
110
  begin
92
111
  claims = Legion::Crypt::JWT.verify_with_jwks(id_token, jwks_url: jwks_url, issuers: [issuer])
93
112
  rescue Legion::Crypt::JWT::Error => e
113
+ Legion::Logging.warn "API GET /api/auth/callback returned 401: #{e.message}"
94
114
  halt 401, json_error('invalid_id_token', e.message, status_code: 401)
95
115
  end
96
116
 
@@ -110,6 +130,7 @@ module Legion
110
130
  msid: mapped[:sub], name: mapped[:name], roles: mapped[:roles], ttl: ttl
111
131
  )
112
132
 
133
+ Legion::Logging.info "API: human OAuth callback issued token for sub=#{mapped[:sub]}"
113
134
  if request.env['HTTP_ACCEPT']&.include?('application/json')
114
135
  json_response({
115
136
  access_token: token,
@@ -8,18 +8,23 @@ module Legion
8
8
  register_worker_token_exchange(app)
9
9
  end
10
10
 
11
- def self.register_worker_token_exchange(app) # rubocop:disable Metrics/MethodLength
11
+ def self.register_worker_token_exchange(app) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
12
12
  app.post '/api/auth/worker-token' do
13
+ Legion::Logging.debug "API: POST /api/auth/worker-token params=#{params.keys}"
13
14
  body = parse_request_body
14
15
  grant_type = body[:grant_type]
15
16
  entra_token = body[:entra_token]
16
17
 
17
18
  unless grant_type == 'client_credentials'
19
+ Legion::Logging.warn "API POST /api/auth/worker-token returned 400: unsupported grant_type=#{grant_type}"
18
20
  halt 400, json_error('unsupported_grant_type', 'grant_type must be client_credentials',
19
21
  status_code: 400)
20
22
  end
21
23
 
22
- halt 400, json_error('missing_entra_token', 'entra_token is required', status_code: 400) unless entra_token
24
+ unless entra_token
25
+ Legion::Logging.warn 'API POST /api/auth/worker-token returned 400: entra_token is required'
26
+ halt 400, json_error('missing_entra_token', 'entra_token is required', status_code: 400)
27
+ end
23
28
 
24
29
  unless defined?(Legion::Crypt::JWT) && Legion::Crypt::JWT.respond_to?(:verify_with_jwks)
25
30
  halt 501, json_error('jwks_validation_not_available',
@@ -29,6 +34,7 @@ module Legion
29
34
  entra_settings = Routes::AuthWorker.resolve_entra_settings
30
35
  tenant_id = entra_settings[:tenant_id]
31
36
  unless tenant_id
37
+ Legion::Logging.error 'API POST /api/auth/worker-token returned 500: Entra tenant_id is not configured'
32
38
  halt 500, json_error('entra_tenant_not_configured',
33
39
  'Entra tenant_id is not configured', status_code: 500)
34
40
  end
@@ -41,25 +47,33 @@ module Legion
41
47
  entra_token, jwks_url: jwks_url, issuers: [issuer]
42
48
  )
43
49
  rescue Legion::Crypt::JWT::ExpiredTokenError
50
+ Legion::Logging.warn 'API POST /api/auth/worker-token returned 401: Entra token has expired'
44
51
  halt 401, json_error('token_expired', 'Entra token has expired', status_code: 401)
45
52
  rescue Legion::Crypt::JWT::InvalidTokenError => e
53
+ Legion::Logging.warn "API POST /api/auth/worker-token returned 401: #{e.message}"
46
54
  halt 401, json_error('invalid_token', e.message, status_code: 401)
47
55
  rescue Legion::Crypt::JWT::Error => e
56
+ Legion::Logging.error "API POST /api/auth/worker-token returned 502: #{e.message}"
48
57
  halt 502, json_error('identity_provider_unavailable', e.message, status_code: 502)
49
58
  end
50
59
 
51
60
  app_id = claims[:appid] || claims[:azp] || claims['appid'] || claims['azp']
52
- halt 401, json_error('invalid_token', 'missing appid claim', status_code: 401) unless app_id
61
+ unless app_id
62
+ Legion::Logging.warn 'API POST /api/auth/worker-token returned 401: missing appid claim'
63
+ halt 401, json_error('invalid_token', 'missing appid claim', status_code: 401)
64
+ end
53
65
 
54
66
  halt 503, json_error('data_unavailable', 'legion-data not connected', status_code: 503) unless defined?(Legion::Data::Model::DigitalWorker)
55
67
 
56
68
  worker = Legion::Data::Model::DigitalWorker.first(entra_app_id: app_id)
57
69
  unless worker
70
+ Legion::Logging.warn "API POST /api/auth/worker-token returned 404: no worker for entra_app_id=#{app_id}"
58
71
  halt 404, json_error('worker_not_found',
59
72
  "no worker registered for entra_app_id #{app_id}", status_code: 404)
60
73
  end
61
74
 
62
75
  unless worker.lifecycle_state == 'active'
76
+ Legion::Logging.warn "API POST /api/auth/worker-token returned 403: worker #{worker.worker_id} is in #{worker.lifecycle_state} state"
63
77
  halt 403, json_error('worker_not_active',
64
78
  "worker is in #{worker.lifecycle_state} state", status_code: 403)
65
79
  end
@@ -69,6 +83,7 @@ module Legion
69
83
  worker_id: worker.worker_id, owner_msid: worker.owner_msid, ttl: ttl
70
84
  )
71
85
 
86
+ Legion::Logging.info "API: issued worker token for worker_id=#{worker.worker_id}"
72
87
  json_response({
73
88
  access_token: token,
74
89
  token_type: 'Bearer',
@@ -11,6 +11,9 @@ module Legion
11
11
  workers = Routes::Capacity.fetch_worker_list
12
12
  model = Legion::Capacity::Model.new(workers: workers)
13
13
  json_response(model.aggregate)
14
+ rescue StandardError => e
15
+ Legion::Logging.error "API GET /api/capacity: #{e.class} — #{e.message}"
16
+ json_error('capacity_error', e.message, status_code: 500)
14
17
  end
15
18
 
16
19
  app.get '/api/capacity/forecast' do
@@ -21,12 +24,18 @@ module Legion
21
24
  growth_rate: (params[:growth_rate] || 0).to_f
22
25
  )
23
26
  json_response(forecast)
27
+ rescue StandardError => e
28
+ Legion::Logging.error "API GET /api/capacity/forecast: #{e.class} — #{e.message}"
29
+ json_error('capacity_error', e.message, status_code: 500)
24
30
  end
25
31
 
26
32
  app.get '/api/capacity/workers' do
27
33
  workers = Routes::Capacity.fetch_worker_list
28
34
  model = Legion::Capacity::Model.new(workers: workers)
29
35
  json_response(model.per_worker_stats)
36
+ rescue StandardError => e
37
+ Legion::Logging.error "API GET /api/capacity/workers: #{e.class} — #{e.message}"
38
+ json_error('capacity_error', e.message, status_code: 500)
30
39
  end
31
40
  end
32
41
 
@@ -4,7 +4,7 @@ module Legion
4
4
  class API < Sinatra::Base
5
5
  module Routes
6
6
  module Chains
7
- def self.registered(app)
7
+ def self.registered(app) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
8
8
  app.get '/api/chains' do
9
9
  require_data!
10
10
  halt 501, json_error('not_implemented', 'chain data model is not yet available', status_code: 501) unless Legion::Data::Model.const_defined?(:Chain)
@@ -13,42 +13,62 @@ module Legion
13
13
  end
14
14
 
15
15
  app.post '/api/chains' do
16
+ Legion::Logging.debug "API: POST /api/chains params=#{params.keys}"
16
17
  require_data!
17
- halt 501, json_error('not_implemented', 'chain data model is not yet available', status_code: 501) unless Legion::Data::Model.const_defined?(:Chain)
18
+ unless Legion::Data::Model.const_defined?(:Chain)
19
+ Legion::Logging.warn 'API POST /api/chains returned 501: chain data model is not yet available'
20
+ halt 501, json_error('not_implemented', 'chain data model is not yet available', status_code: 501)
21
+ end
18
22
 
19
23
  body = parse_request_body
20
- halt 422, json_error('missing_field', 'name is required', status_code: 422) unless body[:name]
24
+ unless body[:name]
25
+ Legion::Logging.warn 'API POST /api/chains returned 422: name is required'
26
+ halt 422, json_error('missing_field', 'name is required', status_code: 422)
27
+ end
21
28
 
22
29
  id = Legion::Data::Model::Chain.insert(body)
23
30
  record = Legion::Data::Model::Chain[id]
31
+ Legion::Logging.info "API: created chain #{id} (#{body[:name]})"
24
32
  json_response(record.values, status_code: 201)
25
33
  end
26
34
 
27
35
  app.get '/api/chains/:id' do
28
36
  require_data!
29
- halt 501, json_error('not_implemented', 'chain data model is not yet available', status_code: 501) unless Legion::Data::Model.const_defined?(:Chain)
37
+ unless Legion::Data::Model.const_defined?(:Chain)
38
+ Legion::Logging.warn "API GET /api/chains/#{params[:id]} returned 501: chain data model is not yet available"
39
+ halt 501, json_error('not_implemented', 'chain data model is not yet available', status_code: 501)
40
+ end
30
41
 
31
42
  record = find_or_halt(Legion::Data::Model::Chain, params[:id])
32
43
  json_response(record.values)
33
44
  end
34
45
 
35
46
  app.put '/api/chains/:id' do
47
+ Legion::Logging.debug "API: PUT /api/chains/#{params[:id]} params=#{params.keys}"
36
48
  require_data!
37
- halt 501, json_error('not_implemented', 'chain data model is not yet available', status_code: 501) unless Legion::Data::Model.const_defined?(:Chain)
49
+ unless Legion::Data::Model.const_defined?(:Chain)
50
+ Legion::Logging.warn "API PUT /api/chains/#{params[:id]} returned 501: chain data model is not yet available"
51
+ halt 501, json_error('not_implemented', 'chain data model is not yet available', status_code: 501)
52
+ end
38
53
 
39
54
  record = find_or_halt(Legion::Data::Model::Chain, params[:id])
40
55
  body = parse_request_body
41
56
  record.update(body)
42
57
  record.refresh
58
+ Legion::Logging.info "API: updated chain #{params[:id]}"
43
59
  json_response(record.values)
44
60
  end
45
61
 
46
62
  app.delete '/api/chains/:id' do
47
63
  require_data!
48
- halt 501, json_error('not_implemented', 'chain data model is not yet available', status_code: 501) unless Legion::Data::Model.const_defined?(:Chain)
64
+ unless Legion::Data::Model.const_defined?(:Chain)
65
+ Legion::Logging.warn "API DELETE /api/chains/#{params[:id]} returned 501: chain data model is not yet available"
66
+ halt 501, json_error('not_implemented', 'chain data model is not yet available', status_code: 501)
67
+ end
49
68
 
50
69
  record = find_or_halt(Legion::Data::Model::Chain, params[:id])
51
70
  record.delete
71
+ Legion::Logging.info "API: deleted chain #{params[:id]}"
52
72
  json_response({ deleted: true })
53
73
  end
54
74
  end
@@ -6,13 +6,23 @@ module Legion
6
6
  module Coldstart
7
7
  def self.registered(app)
8
8
  app.post '/api/coldstart/ingest' do
9
+ Legion::Logging.debug "API: POST /api/coldstart/ingest params=#{params.keys}"
9
10
  body = parse_request_body
10
11
  path = body[:path]
11
- halt 422, json_error('missing_field', 'path is required', status_code: 422) if path.nil? || path.empty?
12
+ if path.nil? || path.empty?
13
+ Legion::Logging.warn 'API POST /api/coldstart/ingest returned 422: path is required'
14
+ halt 422, json_error('missing_field', 'path is required', status_code: 422)
15
+ end
12
16
 
13
- halt 503, json_error('coldstart_unavailable', 'lex-coldstart is not loaded', status_code: 503) unless defined?(Legion::Extensions::Coldstart)
17
+ unless defined?(Legion::Extensions::Coldstart)
18
+ Legion::Logging.warn 'API POST /api/coldstart/ingest returned 503: lex-coldstart is not loaded'
19
+ halt 503, json_error('coldstart_unavailable', 'lex-coldstart is not loaded', status_code: 503)
20
+ end
14
21
 
15
- halt 503, json_error('memory_unavailable', 'lex-memory is not loaded', status_code: 503) unless defined?(Legion::Extensions::Memory)
22
+ unless defined?(Legion::Extensions::Memory)
23
+ Legion::Logging.warn 'API POST /api/coldstart/ingest returned 503: lex-memory is not loaded'
24
+ halt 503, json_error('memory_unavailable', 'lex-memory is not loaded', status_code: 503)
25
+ end
16
26
 
17
27
  runner = Object.new.extend(Legion::Extensions::Coldstart::Runners::Ingest)
18
28
 
@@ -24,12 +34,14 @@ module Legion
24
34
  pattern: body[:pattern] || '**/{CLAUDE,MEMORY}.md'
25
35
  )
26
36
  else
37
+ Legion::Logging.warn "API POST /api/coldstart/ingest returned 404: path not found: #{path}"
27
38
  halt 404, json_error('path_not_found', "path not found: #{path}", status_code: 404)
28
39
  end
29
40
 
41
+ Legion::Logging.info "API: coldstart ingest completed for path=#{path}"
30
42
  json_response(result, status_code: 201)
31
43
  rescue StandardError => e
32
- Legion::Logging.error "API coldstart ingest error: #{e.message}"
44
+ Legion::Logging.error "API POST /api/coldstart/ingest: #{e.class} — #{e.message}"
33
45
  json_error('execution_error', e.message, status_code: 500)
34
46
  end
35
47
  end
@@ -41,7 +41,7 @@ module Legion
41
41
  end
42
42
  end
43
43
 
44
- def self.register_function_routes(app)
44
+ def self.register_function_routes(app) # rubocop:disable Metrics/AbcSize
45
45
  app.get '/api/extensions/:id/runners/:runner_id/functions' do
46
46
  require_data!
47
47
  find_or_halt(Legion::Data::Model::Extension, params[:id])
@@ -60,6 +60,8 @@ module Legion
60
60
 
61
61
  app.post '/api/extensions/:id/runners/:runner_id/functions/:function_id/invoke' do
62
62
  require_data!
63
+ path = "/api/extensions/#{params[:id]}/runners/#{params[:runner_id]}/functions/#{params[:function_id]}/invoke"
64
+ Legion::Logging.debug "API: POST #{path} params=#{params.keys}"
63
65
  find_or_halt(Legion::Data::Model::Extension, params[:id])
64
66
  runner = find_or_halt(Legion::Data::Model::Runner, params[:runner_id])
65
67
  func = find_or_halt(Legion::Data::Model::Function, params[:function_id])
@@ -71,11 +73,13 @@ module Legion
71
73
  check_subtask: body.fetch(:check_subtask, true),
72
74
  generate_task: body.fetch(:generate_task, true)
73
75
  )
76
+ Legion::Logging.info "API: invoked function #{func.values[:name]} via runner #{runner.values[:namespace]}, task #{result[:task_id]}"
74
77
  json_response(result, status_code: 201)
75
78
  rescue NameError => e
79
+ Legion::Logging.warn "API POST /api/extensions invoke returned 422: #{e.message}"
76
80
  json_error('invalid_runner', e.message, status_code: 422)
77
81
  rescue StandardError => e
78
- Legion::Logging.error "API invoke error: #{e.message}"
82
+ Legion::Logging.error "API POST /api/extensions invoke: #{e.class} — #{e.message}"
79
83
  json_error('execution_error', e.message, status_code: 500)
80
84
  end
81
85
  end
@@ -14,15 +14,19 @@ module Legion
14
14
  end
15
15
 
16
16
  app.post '/api/channels/teams/webhook' do
17
+ Legion::Logging.debug "API: POST /api/channels/teams/webhook params=#{params.keys}"
17
18
  body = request.body.read
18
19
  activity = Legion::JSON.load(body)
19
20
 
20
21
  adapter = Routes::Gaia.teams_adapter
21
- halt 503, json_response({ error: 'teams adapter not available' }, status_code: 503) unless adapter
22
+ unless adapter
23
+ Legion::Logging.warn 'API POST /api/channels/teams/webhook returned 503: teams adapter not available'
24
+ halt 503, json_response({ error: 'teams adapter not available' }, status_code: 503)
25
+ end
22
26
 
23
27
  input_frame = adapter.translate_inbound(activity)
24
28
  Legion::Gaia.sensory_buffer&.push(input_frame) if defined?(Legion::Gaia)
25
-
29
+ Legion::Logging.info "API: accepted Teams webhook frame_id=#{input_frame&.id}"
26
30
  json_response({ status: 'accepted', frame_id: input_frame&.id })
27
31
  end
28
32
  end
@@ -11,6 +11,7 @@ module Legion
11
11
  def self.registered(app)
12
12
  app.post '/api/graphql' do
13
13
  content_type :json
14
+ Legion::Logging.debug "API: POST /api/graphql params=#{params.keys}" if defined?(Legion::Logging)
14
15
 
15
16
  body_str = request.body.read
16
17
  payload = body_str.empty? ? {} : Legion::JSON.load(body_str)
@@ -21,6 +22,7 @@ module Legion
21
22
  operation_name = payload[:operationName]
22
23
 
23
24
  if query.nil? || query.strip.empty?
25
+ Legion::Logging.warn 'API POST /api/graphql returned 400: query is required' if defined?(Legion::Logging)
24
26
  status 400
25
27
  next Legion::JSON.dump({
26
28
  errors: [{ message: 'query is required' }]
@@ -37,7 +39,7 @@ module Legion
37
39
  status 200
38
40
  Legion::JSON.dump(result.to_h)
39
41
  rescue StandardError => e
40
- Legion::Logging.error "GraphQL execution error: #{e.message}" if defined?(Legion::Logging)
42
+ Legion::Logging.error "API POST /api/graphql: #{e.class} — #{e.message}" if defined?(Legion::Logging)
41
43
  status 500
42
44
  Legion::JSON.dump({ errors: [{ message: e.message }] })
43
45
  end
@@ -37,23 +37,36 @@ module Legion
37
37
 
38
38
  def self.handle_hook_request(context, request)
39
39
  splat_path = request.path_info.sub(%r{^/api/hooks/lex/}, '')
40
+ Legion::Logging.debug "API: #{request.request_method} /api/hooks/lex/#{splat_path}"
40
41
  hook_entry = Legion::API.find_hook_by_path(splat_path)
41
- context.halt 404, context.json_error('not_found', "no hook registered for '#{splat_path}'", status_code: 404) if hook_entry.nil?
42
+ if hook_entry.nil?
43
+ Legion::Logging.warn "API #{request.request_method} #{request.path_info} returned 404: no hook registered for '#{splat_path}'"
44
+ context.halt 404, context.json_error('not_found', "no hook registered for '#{splat_path}'", status_code: 404)
45
+ end
42
46
 
43
47
  body = request.request_method == 'POST' ? request.body.read : nil
44
48
  hook = hook_entry[:hook_class].new
45
- context.halt 401, context.json_error('unauthorized', 'hook verification failed', status_code: 401) unless hook.verify(request.env, body || '')
49
+ unless hook.verify(request.env, body || '')
50
+ Legion::Logging.warn "API #{request.request_method} #{request.path_info} returned 401: hook verification failed"
51
+ context.halt 401, context.json_error('unauthorized', 'hook verification failed', status_code: 401)
52
+ end
46
53
 
47
54
  payload = build_payload(request, body)
48
55
  function = hook.route(request.env, payload)
49
- context.halt 422, context.json_error('unhandled_event', 'hook could not route this event', status_code: 422) if function.nil?
56
+ if function.nil?
57
+ Legion::Logging.warn "API #{request.request_method} #{request.path_info} returned 422: hook could not route this event"
58
+ context.halt 422, context.json_error('unhandled_event', 'hook could not route this event', status_code: 422)
59
+ end
50
60
 
51
61
  runner = hook.runner_class || hook_entry[:default_runner]
52
- context.halt 500, context.json_error('no_runner', 'no runner class configured for this hook', status_code: 500) if runner.nil?
62
+ if runner.nil?
63
+ Legion::Logging.error "API #{request.request_method} #{request.path_info} returned 500: no runner class configured for hook '#{splat_path}'"
64
+ context.halt 500, context.json_error('no_runner', 'no runner class configured for this hook', status_code: 500)
65
+ end
53
66
 
54
67
  dispatch_hook(context, payload: payload, runner: runner, function: function)
55
68
  rescue StandardError => e
56
- Legion::Logging.error "Hook error: #{e.message}"
69
+ Legion::Logging.error "API #{request.request_method} #{request.path_info}: #{e.class} — #{e.message}"
57
70
  Legion::Logging.error e.backtrace&.first(5)
58
71
  context.json_error('internal_error', e.message, status_code: 500)
59
72
  end
@@ -74,6 +87,7 @@ module Legion
74
87
  payload: payload, runner_class: runner, function: function,
75
88
  source: 'hook', check_subtask: true, generate_task: true
76
89
  )
90
+ Legion::Logging.info "API: dispatched hook to #{runner}##{function}, task #{result[:task_id]}"
77
91
  return render_custom_response(context, result[:response]) if result.is_a?(Hash) && result[:response]
78
92
 
79
93
  context.json_response({ task_id: result[:task_id], status: result[:status] })
@@ -33,8 +33,10 @@ module Legion
33
33
 
34
34
  def self.handle_lex_request(context, request)
35
35
  splat_path = request.path_info.sub(%r{^/api/lex/}, '')
36
+ Legion::Logging.debug "API: POST /api/lex/#{splat_path}"
36
37
  route_entry = Legion::API.find_route_by_path(splat_path)
37
38
  if route_entry.nil?
39
+ Legion::Logging.warn "API POST /api/lex/#{splat_path} returned 404: no route registered"
38
40
  context.halt 404, context.json_error('route_not_found',
39
41
  "no route registered for '#{splat_path}'", status_code: 404)
40
42
  end
@@ -47,10 +49,11 @@ module Legion
47
49
  source: 'lex_route',
48
50
  generate_task: true
49
51
  )
52
+ Legion::Logging.info "API: LEX route #{splat_path} dispatched to #{route_entry[:runner_class]}, task #{result[:task_id]}"
50
53
  context.json_response({ task_id: result[:task_id], status: result[:status],
51
54
  result: result[:result] }.compact)
52
55
  rescue StandardError => e
53
- Legion::Logging.error "LEX route error: #{e.message}"
56
+ Legion::Logging.error "API POST /api/lex/#{request.path_info.sub(%r{^/api/lex/}, '')}: #{e.class} — #{e.message}"
54
57
  Legion::Logging.error e.backtrace&.first(5)
55
58
  context.json_error('internal_error', e.message, status_code: 500)
56
59
  end