decision_agent 0.1.4 → 0.1.6

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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +84 -233
  3. data/lib/decision_agent/ab_testing/ab_testing_agent.rb +46 -10
  4. data/lib/decision_agent/agent.rb +5 -3
  5. data/lib/decision_agent/auth/access_audit_logger.rb +122 -0
  6. data/lib/decision_agent/auth/authenticator.rb +127 -0
  7. data/lib/decision_agent/auth/password_reset_manager.rb +57 -0
  8. data/lib/decision_agent/auth/password_reset_token.rb +33 -0
  9. data/lib/decision_agent/auth/permission.rb +29 -0
  10. data/lib/decision_agent/auth/permission_checker.rb +43 -0
  11. data/lib/decision_agent/auth/rbac_adapter.rb +278 -0
  12. data/lib/decision_agent/auth/rbac_config.rb +51 -0
  13. data/lib/decision_agent/auth/role.rb +56 -0
  14. data/lib/decision_agent/auth/session.rb +33 -0
  15. data/lib/decision_agent/auth/session_manager.rb +57 -0
  16. data/lib/decision_agent/auth/user.rb +70 -0
  17. data/lib/decision_agent/context.rb +24 -4
  18. data/lib/decision_agent/decision.rb +10 -3
  19. data/lib/decision_agent/dsl/condition_evaluator.rb +378 -1
  20. data/lib/decision_agent/dsl/schema_validator.rb +8 -1
  21. data/lib/decision_agent/errors.rb +38 -0
  22. data/lib/decision_agent/evaluation.rb +10 -3
  23. data/lib/decision_agent/evaluation_validator.rb +8 -13
  24. data/lib/decision_agent/monitoring/dashboard_server.rb +1 -0
  25. data/lib/decision_agent/monitoring/metrics_collector.rb +17 -5
  26. data/lib/decision_agent/testing/batch_test_importer.rb +373 -0
  27. data/lib/decision_agent/testing/batch_test_runner.rb +244 -0
  28. data/lib/decision_agent/testing/test_coverage_analyzer.rb +191 -0
  29. data/lib/decision_agent/testing/test_result_comparator.rb +235 -0
  30. data/lib/decision_agent/testing/test_scenario.rb +42 -0
  31. data/lib/decision_agent/version.rb +10 -1
  32. data/lib/decision_agent/versioning/activerecord_adapter.rb +1 -1
  33. data/lib/decision_agent/versioning/file_storage_adapter.rb +96 -28
  34. data/lib/decision_agent/web/middleware/auth_middleware.rb +45 -0
  35. data/lib/decision_agent/web/middleware/permission_middleware.rb +94 -0
  36. data/lib/decision_agent/web/public/app.js +184 -29
  37. data/lib/decision_agent/web/public/batch_testing.html +640 -0
  38. data/lib/decision_agent/web/public/index.html +37 -9
  39. data/lib/decision_agent/web/public/login.html +298 -0
  40. data/lib/decision_agent/web/public/users.html +679 -0
  41. data/lib/decision_agent/web/server.rb +873 -7
  42. data/lib/decision_agent.rb +52 -0
  43. data/lib/generators/decision_agent/install/templates/rule_version.rb +1 -1
  44. data/spec/ab_testing/ab_test_assignment_spec.rb +253 -0
  45. data/spec/ab_testing/ab_test_manager_spec.rb +282 -0
  46. data/spec/ab_testing/ab_testing_agent_spec.rb +481 -0
  47. data/spec/ab_testing/storage/adapter_spec.rb +64 -0
  48. data/spec/ab_testing/storage/memory_adapter_spec.rb +485 -0
  49. data/spec/advanced_operators_spec.rb +1003 -0
  50. data/spec/agent_spec.rb +40 -0
  51. data/spec/audit_adapters_spec.rb +18 -0
  52. data/spec/auth/access_audit_logger_spec.rb +394 -0
  53. data/spec/auth/authenticator_spec.rb +112 -0
  54. data/spec/auth/password_reset_spec.rb +294 -0
  55. data/spec/auth/permission_checker_spec.rb +207 -0
  56. data/spec/auth/permission_spec.rb +73 -0
  57. data/spec/auth/rbac_adapter_spec.rb +550 -0
  58. data/spec/auth/rbac_config_spec.rb +82 -0
  59. data/spec/auth/role_spec.rb +51 -0
  60. data/spec/auth/session_manager_spec.rb +172 -0
  61. data/spec/auth/session_spec.rb +112 -0
  62. data/spec/auth/user_spec.rb +130 -0
  63. data/spec/context_spec.rb +43 -0
  64. data/spec/decision_agent_spec.rb +96 -0
  65. data/spec/decision_spec.rb +423 -0
  66. data/spec/dsl/condition_evaluator_spec.rb +774 -0
  67. data/spec/evaluation_spec.rb +364 -0
  68. data/spec/evaluation_validator_spec.rb +165 -0
  69. data/spec/examples.txt +1542 -612
  70. data/spec/monitoring/metrics_collector_spec.rb +220 -2
  71. data/spec/monitoring/storage/activerecord_adapter_spec.rb +153 -1
  72. data/spec/monitoring/storage/base_adapter_spec.rb +61 -0
  73. data/spec/performance_optimizations_spec.rb +486 -0
  74. data/spec/spec_helper.rb +23 -0
  75. data/spec/testing/batch_test_importer_spec.rb +693 -0
  76. data/spec/testing/batch_test_runner_spec.rb +307 -0
  77. data/spec/testing/test_coverage_analyzer_spec.rb +292 -0
  78. data/spec/testing/test_result_comparator_spec.rb +392 -0
  79. data/spec/testing/test_scenario_spec.rb +113 -0
  80. data/spec/versioning/adapter_spec.rb +156 -0
  81. data/spec/versioning_spec.rb +253 -0
  82. data/spec/web/middleware/auth_middleware_spec.rb +133 -0
  83. data/spec/web/middleware/permission_middleware_spec.rb +247 -0
  84. data/spec/web_ui_rack_spec.rb +1705 -0
  85. metadata +99 -6
@@ -0,0 +1,42 @@
1
+ module DecisionAgent
2
+ module Testing
3
+ # Represents a single test scenario with context and expected results
4
+ class TestScenario
5
+ attr_reader :id, :context, :expected_decision, :expected_confidence, :metadata
6
+
7
+ def initialize(id:, context:, expected_decision: nil, expected_confidence: nil, metadata: {})
8
+ @id = id.to_s.freeze
9
+ @context = context.is_a?(Hash) ? context.freeze : context
10
+ @expected_decision = expected_decision&.to_s&.freeze
11
+ @expected_confidence = expected_confidence&.to_f if expected_confidence
12
+ @metadata = metadata.is_a?(Hash) ? metadata.freeze : metadata
13
+
14
+ freeze
15
+ end
16
+
17
+ def to_h
18
+ {
19
+ id: @id,
20
+ context: @context,
21
+ expected_decision: @expected_decision,
22
+ expected_confidence: @expected_confidence,
23
+ metadata: @metadata
24
+ }
25
+ end
26
+
27
+ def expected_result?
28
+ !@expected_decision.nil?
29
+ end
30
+
31
+ def ==(other)
32
+ other.is_a?(TestScenario) &&
33
+ @id == other.id &&
34
+ @context == other.context &&
35
+ @expected_decision == other.expected_decision &&
36
+ (@expected_confidence.nil? || other.expected_confidence.nil? ||
37
+ (@expected_confidence - other.expected_confidence).abs < 0.0001) &&
38
+ @metadata == other.metadata
39
+ end
40
+ end
41
+ end
42
+ end
@@ -1,3 +1,12 @@
1
1
  module DecisionAgent
2
- VERSION = "0.1.4".freeze
2
+ # Semantic version: MAJOR.MINOR.PATCH
3
+ # MAJOR: Incremented for incompatible API changes
4
+ # MINOR: Incremented for backward-compatible functionality additions
5
+ # PATCH: Incremented for backward-compatible bug fixes
6
+ VERSION = "0.1.6".freeze
7
+
8
+ # Validate version format (semantic versioning)
9
+ unless VERSION.match?(/\A\d+\.\d+\.\d+(-[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*)?(\+[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*)?\z/)
10
+ raise ArgumentError, "Invalid version format: #{VERSION}. Must follow semantic versioning (MAJOR.MINOR.PATCH)"
11
+ end
3
12
  end
@@ -91,7 +91,7 @@ module DecisionAgent
91
91
  rule_version_class.where(rule_id: version.rule_id, status: "active")
92
92
  .where.not(id: version_id)
93
93
  .find_each do |v|
94
- v.update!(status: "archived")
94
+ v.update!(status: "archived")
95
95
  end
96
96
 
97
97
  # Activate this version
@@ -88,14 +88,24 @@ module DecisionAgent
88
88
 
89
89
  def get_version(version_id:)
90
90
  # Use index to find rule_id quickly - O(1) instead of O(n)
91
- rule_id = get_rule_id_from_index(version_id)
91
+ begin
92
+ rule_id = get_rule_id_from_index(version_id)
93
+ rescue StandardError
94
+ # If index lookup fails, version doesn't exist
95
+ return nil
96
+ end
92
97
  return nil unless rule_id
93
98
 
94
99
  # Now lock on the specific rule
95
- with_rule_lock(rule_id) do
96
- # Read only this rule's versions
97
- versions = list_versions_unsafe(rule_id: rule_id)
98
- versions.find { |v| v[:id] == version_id }
100
+ begin
101
+ with_rule_lock(rule_id) do
102
+ # Read only this rule's versions
103
+ versions = list_versions_unsafe(rule_id: rule_id)
104
+ versions.find { |v| v[:id] == version_id }
105
+ end
106
+ rescue StandardError
107
+ # If any error occurs during lookup, treat as version not found
108
+ nil
99
109
  end
100
110
  end
101
111
 
@@ -140,34 +150,85 @@ module DecisionAgent
140
150
 
141
151
  def delete_version(version_id:)
142
152
  # Use index to find rule_id quickly - O(1) instead of O(n)
143
- rule_id = get_rule_id_from_index(version_id)
144
- raise DecisionAgent::NotFoundError, "Version not found: #{version_id}" unless rule_id
153
+ begin
154
+ rule_id = get_rule_id_from_index(version_id)
155
+ rescue StandardError
156
+ # If index lookup fails, version doesn't exist
157
+ raise DecisionAgent::NotFoundError, "Version not found: #{version_id}"
158
+ end
145
159
 
146
- # Now lock on the specific rule
147
- with_rule_lock(rule_id) do
148
- # Read only this rule's versions
149
- versions = list_versions_unsafe(rule_id: rule_id)
150
- version = versions.find { |v| v[:id] == version_id }
151
- raise DecisionAgent::NotFoundError, "Version not found: #{version_id}" unless version
160
+ # Validate rule_id - must be present and non-empty
161
+ raise DecisionAgent::NotFoundError, "Version not found: #{version_id}" unless rule_id && !rule_id.to_s.strip.empty?
152
162
 
153
- # Prevent deletion of active versions
154
- if version[:status] == "active"
155
- raise DecisionAgent::ValidationError, "Cannot delete active version. Please activate another version first."
163
+ # Now lock on the specific rule
164
+ begin
165
+ with_rule_lock(rule_id) do
166
+ # Read only this rule's versions
167
+ versions = list_versions_unsafe(rule_id: rule_id)
168
+ version = versions.find { |v| v[:id] == version_id || v[:id].to_s == version_id.to_s }
169
+
170
+ # If version not in list, check if file exists - might have been manually deleted
171
+ unless version
172
+ rule_dir = File.join(@storage_path, sanitize_filename(rule_id))
173
+ # Try to find the file by checking all version files
174
+ file_found = false
175
+ begin
176
+ Dir.glob(File.join(rule_dir, "*.json")).each do |filepath|
177
+ file_data = JSON.parse(File.read(filepath))
178
+ if file_data["id"] == version_id || file_data[:id] == version_id ||
179
+ file_data["id"].to_s == version_id.to_s || file_data[:id].to_s == version_id.to_s
180
+ # File exists but not in versions list - remove from index and return false
181
+ file_found = true
182
+ remove_from_index(version_id)
183
+ return false
184
+ end
185
+ rescue Errno::ENOENT, JSON::ParserError
186
+ # File was deleted or corrupted, continue searching
187
+ next
188
+ end
189
+ rescue Errno::ENOENT
190
+ # Directory doesn't exist, version not found
191
+ end
192
+ # Version not found in list and file doesn't exist - clean up index and return false
193
+ remove_from_index(version_id)
194
+ return false
195
+ end
196
+
197
+ # Prevent deletion of active versions
198
+ if version[:status] == "active"
199
+ raise DecisionAgent::ValidationError, "Cannot delete active version. Please activate another version first."
200
+ end
201
+
202
+ # Delete the file
203
+ rule_dir = File.join(@storage_path, sanitize_filename(rule_id))
204
+ filename = "#{version[:version_number]}.json"
205
+ filepath = File.join(rule_dir, filename)
206
+
207
+ if File.exist?(filepath)
208
+ File.delete(filepath)
209
+ # Remove from index
210
+ remove_from_index(version_id)
211
+ true
212
+ else
213
+ # File already deleted - clean up index and return false
214
+ remove_from_index(version_id)
215
+ false
216
+ end
156
217
  end
157
-
158
- # Delete the file
159
- rule_dir = File.join(@storage_path, sanitize_filename(rule_id))
160
- filename = "#{version[:version_number]}.json"
161
- filepath = File.join(rule_dir, filename)
162
-
163
- if File.exist?(filepath)
164
- File.delete(filepath)
165
- # Remove from index
218
+ rescue DecisionAgent::ValidationError, DecisionAgent::NotFoundError
219
+ # Re-raise expected errors
220
+ raise
221
+ rescue StandardError
222
+ # If any unexpected error occurs during the lock operation, treat as version not found
223
+ # This prevents 500 errors from propagating when version doesn't exist or is in an invalid state
224
+ # This handles ThreadError (deadlocks, recursive locks), SystemCallError (file system issues), etc.
225
+ # This is safe because if the version existed and was valid, we would have found it above
226
+ begin
166
227
  remove_from_index(version_id)
167
- true
168
- else
169
- false
228
+ rescue StandardError
229
+ nil
170
230
  end
231
+ raise DecisionAgent::NotFoundError, "Version not found: #{version_id}"
171
232
  end
172
233
  end
173
234
 
@@ -181,6 +242,9 @@ module DecisionAgent
181
242
 
182
243
  Dir.glob(File.join(rule_dir, "*.json")).each do |file|
183
244
  versions << JSON.parse(File.read(file), symbolize_names: true)
245
+ rescue JSON::ParserError, Errno::ENOENT
246
+ # Skip corrupted or deleted files
247
+ next
184
248
  end
185
249
 
186
250
  versions.sort_by! { |v| -v[:version_number] }
@@ -249,6 +313,10 @@ module DecisionAgent
249
313
  def with_rule_lock(rule_id, &block)
250
314
  mutex = @rule_mutexes_lock.synchronize { @rule_mutexes[rule_id] }
251
315
  mutex.synchronize(&block)
316
+ rescue ThreadError => e
317
+ # Handle potential deadlock or recursive locking issues in Ruby 3.3+
318
+ # Re-raise as StandardError so it can be caught by callers
319
+ raise StandardError, "Lock error: #{e.message}"
252
320
  end
253
321
 
254
322
  # Index management methods for O(1) version_id -> rule_id lookups
@@ -0,0 +1,45 @@
1
+ require "rack"
2
+
3
+ module DecisionAgent
4
+ module Web
5
+ module Middleware
6
+ class AuthMiddleware
7
+ def initialize(app, authenticator:, access_audit_logger: nil)
8
+ @app = app
9
+ @authenticator = authenticator
10
+ @access_audit_logger = access_audit_logger
11
+ end
12
+
13
+ def call(env)
14
+ request = Rack::Request.new(env)
15
+ token = extract_token(request)
16
+
17
+ if token
18
+ auth_result = @authenticator.authenticate(token)
19
+ if auth_result
20
+ env["decision_agent.user"] = auth_result[:user]
21
+ env["decision_agent.session"] = auth_result[:session]
22
+ end
23
+ end
24
+
25
+ @app.call(env)
26
+ end
27
+
28
+ private
29
+
30
+ def extract_token(request)
31
+ # Check Authorization header: Bearer <token>
32
+ auth_header = request.get_header("HTTP_AUTHORIZATION")
33
+ return auth_header[7..] if auth_header&.start_with?("Bearer ")
34
+
35
+ # Check session cookie
36
+ cookie_token = request.cookies["decision_agent_session"]
37
+ return cookie_token if cookie_token
38
+
39
+ # Check query parameter (less secure, but useful for some cases)
40
+ request.params["token"]
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,94 @@
1
+ require "rack"
2
+ require "json"
3
+
4
+ module DecisionAgent
5
+ module Web
6
+ module Middleware
7
+ class PermissionMiddleware
8
+ def initialize(app, permission_checker:, required_permission: nil, access_audit_logger: nil)
9
+ @app = app
10
+ @permission_checker = permission_checker
11
+ @required_permission = required_permission
12
+ @access_audit_logger = access_audit_logger
13
+ end
14
+
15
+ def call(env)
16
+ user = env["decision_agent.user"]
17
+
18
+ return unauthorized_response("Authentication required") unless user
19
+
20
+ # Check if user is active using the adapter
21
+ return forbidden_response("User account is not active") unless @permission_checker.active?(user)
22
+
23
+ if @required_permission
24
+ resource_type = extract_resource_type(env)
25
+ resource_id = extract_resource_id(env)
26
+
27
+ granted = @permission_checker.can?(user, @required_permission, nil)
28
+
29
+ if @access_audit_logger
30
+ user_id = @permission_checker.user_id(user)
31
+ @access_audit_logger.log_permission_check(
32
+ user_id: user_id,
33
+ permission: @required_permission,
34
+ resource_type: resource_type,
35
+ resource_id: resource_id,
36
+ granted: granted
37
+ )
38
+ end
39
+
40
+ return forbidden_response("Permission denied: #{@required_permission}") unless granted
41
+ end
42
+
43
+ @app.call(env)
44
+ end
45
+
46
+ private
47
+
48
+ def extract_resource_type(env)
49
+ request = Rack::Request.new(env)
50
+ # Try to extract from path, e.g., /api/rules/:id -> "rule"
51
+ path = request.path
52
+ if (match = path.match(%r{/api/(\w+)}))
53
+ # Simple singularize: remove trailing 's'
54
+ word = match[1]
55
+ word&.end_with?("s") ? word[0..-2] : word
56
+ end
57
+ end
58
+
59
+ def extract_resource_id(env)
60
+ request = Rack::Request.new(env)
61
+ # Try params first (for query parameters and Sinatra path params)
62
+ resource_id = request.params["id"] || request.params["rule_id"] || request.params["version_id"]
63
+
64
+ # If not in params, try to extract from path (e.g., /api/rules/123 -> 123)
65
+ unless resource_id
66
+ path = request.path
67
+ # Match patterns like /api/rules/123 or /api/versions/v1
68
+ if (match = path.match(%r{/api/(?:rules|versions)/([^/]+)}))
69
+ resource_id = match[1]
70
+ end
71
+ end
72
+
73
+ resource_id
74
+ end
75
+
76
+ def unauthorized_response(message)
77
+ [
78
+ 401,
79
+ { "Content-Type" => "application/json" },
80
+ [{ error: message }.to_json]
81
+ ]
82
+ end
83
+
84
+ def forbidden_response(message)
85
+ [
86
+ 403,
87
+ { "Content-Type" => "application/json" },
88
+ [{ error: message }.to_json]
89
+ ]
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end