decision_agent 0.1.1 → 0.1.3

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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +234 -919
  3. data/bin/decision_agent +5 -5
  4. data/lib/decision_agent/agent.rb +19 -26
  5. data/lib/decision_agent/audit/null_adapter.rb +1 -2
  6. data/lib/decision_agent/decision.rb +3 -1
  7. data/lib/decision_agent/dsl/condition_evaluator.rb +4 -3
  8. data/lib/decision_agent/dsl/rule_parser.rb +4 -6
  9. data/lib/decision_agent/dsl/schema_validator.rb +27 -31
  10. data/lib/decision_agent/errors.rb +21 -6
  11. data/lib/decision_agent/evaluation.rb +3 -1
  12. data/lib/decision_agent/evaluation_validator.rb +78 -0
  13. data/lib/decision_agent/evaluators/json_rule_evaluator.rb +26 -0
  14. data/lib/decision_agent/evaluators/static_evaluator.rb +2 -6
  15. data/lib/decision_agent/monitoring/alert_manager.rb +282 -0
  16. data/lib/decision_agent/monitoring/dashboard/public/dashboard.css +381 -0
  17. data/lib/decision_agent/monitoring/dashboard/public/dashboard.js +471 -0
  18. data/lib/decision_agent/monitoring/dashboard/public/index.html +161 -0
  19. data/lib/decision_agent/monitoring/dashboard_server.rb +340 -0
  20. data/lib/decision_agent/monitoring/metrics_collector.rb +278 -0
  21. data/lib/decision_agent/monitoring/monitored_agent.rb +71 -0
  22. data/lib/decision_agent/monitoring/prometheus_exporter.rb +247 -0
  23. data/lib/decision_agent/replay/replay.rb +12 -22
  24. data/lib/decision_agent/scoring/base.rb +1 -1
  25. data/lib/decision_agent/scoring/consensus.rb +5 -5
  26. data/lib/decision_agent/scoring/weighted_average.rb +1 -1
  27. data/lib/decision_agent/version.rb +1 -1
  28. data/lib/decision_agent/versioning/activerecord_adapter.rb +141 -0
  29. data/lib/decision_agent/versioning/adapter.rb +100 -0
  30. data/lib/decision_agent/versioning/file_storage_adapter.rb +290 -0
  31. data/lib/decision_agent/versioning/version_manager.rb +127 -0
  32. data/lib/decision_agent/web/public/app.js +318 -0
  33. data/lib/decision_agent/web/public/index.html +56 -1
  34. data/lib/decision_agent/web/public/styles.css +219 -0
  35. data/lib/decision_agent/web/server.rb +169 -9
  36. data/lib/decision_agent.rb +11 -0
  37. data/lib/generators/decision_agent/install/install_generator.rb +40 -0
  38. data/lib/generators/decision_agent/install/templates/README +47 -0
  39. data/lib/generators/decision_agent/install/templates/migration.rb +37 -0
  40. data/lib/generators/decision_agent/install/templates/rule.rb +30 -0
  41. data/lib/generators/decision_agent/install/templates/rule_version.rb +66 -0
  42. data/spec/activerecord_thread_safety_spec.rb +553 -0
  43. data/spec/agent_spec.rb +13 -13
  44. data/spec/api_contract_spec.rb +16 -16
  45. data/spec/audit_adapters_spec.rb +3 -3
  46. data/spec/comprehensive_edge_cases_spec.rb +86 -86
  47. data/spec/dsl_validation_spec.rb +83 -83
  48. data/spec/edge_cases_spec.rb +23 -23
  49. data/spec/examples/feedback_aware_evaluator_spec.rb +7 -7
  50. data/spec/examples.txt +548 -0
  51. data/spec/issue_verification_spec.rb +685 -0
  52. data/spec/json_rule_evaluator_spec.rb +15 -15
  53. data/spec/monitoring/alert_manager_spec.rb +378 -0
  54. data/spec/monitoring/metrics_collector_spec.rb +281 -0
  55. data/spec/monitoring/monitored_agent_spec.rb +222 -0
  56. data/spec/monitoring/prometheus_exporter_spec.rb +242 -0
  57. data/spec/replay_edge_cases_spec.rb +58 -58
  58. data/spec/replay_spec.rb +11 -11
  59. data/spec/rfc8785_canonicalization_spec.rb +215 -0
  60. data/spec/scoring_spec.rb +1 -1
  61. data/spec/spec_helper.rb +9 -0
  62. data/spec/thread_safety_spec.rb +482 -0
  63. data/spec/thread_safety_spec.rb.broken +878 -0
  64. data/spec/versioning_spec.rb +777 -0
  65. data/spec/web_ui_rack_spec.rb +135 -0
  66. metadata +84 -11
@@ -0,0 +1,290 @@
1
+ require_relative "adapter"
2
+ require "json"
3
+ require "fileutils"
4
+
5
+ module DecisionAgent
6
+ module Versioning
7
+ # Status validation shared by adapters
8
+ module StatusValidator
9
+ VALID_STATUSES = %w[draft active archived].freeze
10
+
11
+ def validate_status!(status)
12
+ return if VALID_STATUSES.include?(status)
13
+
14
+ raise DecisionAgent::ValidationError,
15
+ "Invalid status '#{status}'. Must be one of: #{VALID_STATUSES.join(', ')}"
16
+ end
17
+ end
18
+
19
+ # File-based version storage adapter for non-Rails applications
20
+ # Stores versions as JSON files in a directory structure
21
+ class FileStorageAdapter < Adapter
22
+ include StatusValidator
23
+
24
+ attr_reader :storage_path
25
+
26
+ # Initialize with a storage directory
27
+ # @param storage_path [String] Path to store version files (default: ./versions)
28
+ def initialize(storage_path: "./versions")
29
+ @storage_path = storage_path
30
+ # Per-rule mutex for better concurrency - allows different rules to be processed in parallel
31
+ @rule_mutexes = Hash.new { |h, k| h[k] = Mutex.new }
32
+ @rule_mutexes_lock = Mutex.new # Protects the hash itself
33
+ # Index cache: version_id => rule_id mapping for O(1) lookups
34
+ @version_index = {}
35
+ @version_index_lock = Mutex.new
36
+ FileUtils.mkdir_p(@storage_path)
37
+ load_version_index
38
+ end
39
+
40
+ def create_version(rule_id:, content:, metadata: {})
41
+ with_rule_lock(rule_id) do
42
+ create_version_unsafe(rule_id: rule_id, content: content, metadata: metadata)
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def create_version_unsafe(rule_id:, content:, metadata: {})
49
+ # Get the next version number
50
+ versions = list_versions_unsafe(rule_id: rule_id)
51
+ next_version_number = versions.empty? ? 1 : versions.first[:version_number] + 1
52
+
53
+ # Validate status if provided
54
+ status = metadata[:status] || "active"
55
+ validate_status!(status)
56
+
57
+ # Deactivate previous active versions
58
+ versions.each do |v|
59
+ update_version_status_unsafe(v[:id], "archived", rule_id) if v[:status] == "active"
60
+ end
61
+
62
+ # Create version data
63
+ version_id = generate_version_id(rule_id, next_version_number)
64
+ version = {
65
+ id: version_id,
66
+ rule_id: rule_id,
67
+ version_number: next_version_number,
68
+ content: content,
69
+ created_by: metadata[:created_by] || "system",
70
+ created_at: Time.now.utc.iso8601,
71
+ changelog: metadata[:changelog] || "Version #{next_version_number}",
72
+ status: status
73
+ }
74
+
75
+ # Write to file
76
+ write_version_file(version)
77
+
78
+ version
79
+ end
80
+
81
+ public
82
+
83
+ def list_versions(rule_id:, limit: nil)
84
+ with_rule_lock(rule_id) do
85
+ list_versions_unsafe(rule_id: rule_id, limit: limit)
86
+ end
87
+ end
88
+
89
+ def get_version(version_id:)
90
+ # Use index to find rule_id quickly - O(1) instead of O(n)
91
+ rule_id = get_rule_id_from_index(version_id)
92
+ return nil unless rule_id
93
+
94
+ # 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 }
99
+ end
100
+ end
101
+
102
+ def get_version_by_number(rule_id:, version_number:)
103
+ with_rule_lock(rule_id) do
104
+ versions = list_versions_unsafe(rule_id: rule_id)
105
+ versions.find { |v| v[:version_number] == version_number }
106
+ end
107
+ end
108
+
109
+ def get_active_version(rule_id:)
110
+ with_rule_lock(rule_id) do
111
+ versions = list_versions_unsafe(rule_id: rule_id)
112
+ versions.find { |v| v[:status] == "active" }
113
+ end
114
+ end
115
+
116
+ def activate_version(version_id:)
117
+ # Use index to find rule_id quickly - O(1) instead of O(n)
118
+ rule_id = get_rule_id_from_index(version_id)
119
+ raise DecisionAgent::NotFoundError, "Version not found: #{version_id}" unless rule_id
120
+
121
+ # Now lock on the specific rule
122
+ with_rule_lock(rule_id) do
123
+ # Read only this rule's versions
124
+ versions = list_versions_unsafe(rule_id: rule_id)
125
+ version = versions.find { |v| v[:id] == version_id }
126
+ raise DecisionAgent::NotFoundError, "Version not found: #{version_id}" unless version
127
+
128
+ # Deactivate all other versions for this rule
129
+ versions.each do |v|
130
+ update_version_status_unsafe(v[:id], "archived", rule_id) if v[:id] != version_id && v[:status] == "active"
131
+ end
132
+
133
+ # Activate this version
134
+ version[:status] = "active"
135
+ write_version_file(version)
136
+
137
+ version
138
+ end
139
+ end
140
+
141
+ def delete_version(version_id:)
142
+ # 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
145
+
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
152
+
153
+ # Prevent deletion of active versions
154
+ if version[:status] == "active"
155
+ raise DecisionAgent::ValidationError, "Cannot delete active version. Please activate another version first."
156
+ 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
166
+ remove_from_index(version_id)
167
+ true
168
+ else
169
+ false
170
+ end
171
+ end
172
+ end
173
+
174
+ private
175
+
176
+ def list_versions_unsafe(rule_id:, limit: nil)
177
+ versions = []
178
+ rule_dir = File.join(@storage_path, sanitize_filename(rule_id))
179
+
180
+ return versions unless Dir.exist?(rule_dir)
181
+
182
+ Dir.glob(File.join(rule_dir, "*.json")).each do |file|
183
+ versions << JSON.parse(File.read(file), symbolize_names: true)
184
+ end
185
+
186
+ versions.sort_by! { |v| -v[:version_number] }
187
+ limit ? versions.take(limit) : versions
188
+ end
189
+
190
+ def all_versions_unsafe
191
+ versions = []
192
+ return versions unless Dir.exist?(@storage_path)
193
+
194
+ Dir.glob(File.join(@storage_path, "*", "*.json")).each do |file|
195
+ versions << JSON.parse(File.read(file), symbolize_names: true)
196
+ end
197
+
198
+ versions
199
+ end
200
+
201
+ def update_version_status_unsafe(version_id, status, rule_id = nil)
202
+ # Validate status first
203
+ validate_status!(status)
204
+
205
+ # Use provided rule_id or look it up from index
206
+ rule_id ||= get_rule_id_from_index(version_id)
207
+ return unless rule_id
208
+
209
+ # Read only this rule's versions
210
+ versions = list_versions_unsafe(rule_id: rule_id)
211
+ version = versions.find { |v| v[:id] == version_id }
212
+ return unless version
213
+
214
+ version[:status] = status
215
+ write_version_file(version)
216
+ end
217
+
218
+ def write_version_file(version)
219
+ rule_dir = File.join(@storage_path, sanitize_filename(version[:rule_id]))
220
+ FileUtils.mkdir_p(rule_dir)
221
+
222
+ filename = "#{version[:version_number]}.json"
223
+ filepath = File.join(rule_dir, filename)
224
+
225
+ # Use atomic write to prevent race conditions during concurrent access
226
+ # Write to temp file first, then atomically rename
227
+ temp_file = "#{filepath}.tmp.#{Process.pid}.#{Thread.current.object_id}"
228
+ begin
229
+ File.write(temp_file, JSON.pretty_generate(version))
230
+ File.rename(temp_file, filepath)
231
+ # Update index after successful write
232
+ add_to_index(version[:id], version[:rule_id])
233
+ ensure
234
+ # Clean up temp file if rename failed
235
+ FileUtils.rm_f(temp_file)
236
+ end
237
+ end
238
+
239
+ def generate_version_id(rule_id, version_number)
240
+ "#{rule_id}_v#{version_number}"
241
+ end
242
+
243
+ def sanitize_filename(name)
244
+ name.to_s.gsub(/[^a-zA-Z0-9_-]/, "_")
245
+ end
246
+
247
+ # Get or create a mutex for a specific rule_id
248
+ # This allows different rules to be processed in parallel
249
+ def with_rule_lock(rule_id, &block)
250
+ mutex = @rule_mutexes_lock.synchronize { @rule_mutexes[rule_id] }
251
+ mutex.synchronize(&block)
252
+ end
253
+
254
+ # Index management methods for O(1) version_id -> rule_id lookups
255
+ # This prevents the need to scan all 50,000 files when looking up a single version
256
+
257
+ def load_version_index
258
+ @version_index_lock.synchronize do
259
+ return unless Dir.exist?(@storage_path)
260
+
261
+ Dir.glob(File.join(@storage_path, "*", "*.json")).each do |file|
262
+ version = JSON.parse(File.read(file), symbolize_names: true)
263
+ @version_index[version[:id]] = version[:rule_id]
264
+ rescue JSON::ParserError
265
+ # Skip corrupted files
266
+ next
267
+ end
268
+ end
269
+ end
270
+
271
+ def get_rule_id_from_index(version_id)
272
+ @version_index_lock.synchronize do
273
+ @version_index[version_id]
274
+ end
275
+ end
276
+
277
+ def add_to_index(version_id, rule_id)
278
+ @version_index_lock.synchronize do
279
+ @version_index[version_id] = rule_id
280
+ end
281
+ end
282
+
283
+ def remove_from_index(version_id)
284
+ @version_index_lock.synchronize do
285
+ @version_index.delete(version_id)
286
+ end
287
+ end
288
+ end
289
+ end
290
+ end
@@ -0,0 +1,127 @@
1
+ module DecisionAgent
2
+ module Versioning
3
+ # High-level service for managing rule versions
4
+ # Provides a framework-agnostic API using pluggable storage adapters
5
+ class VersionManager
6
+ attr_reader :adapter
7
+
8
+ # Initialize with a storage adapter
9
+ # @param adapter [Adapter] The storage adapter to use
10
+ def initialize(adapter: nil)
11
+ @adapter = adapter || default_adapter
12
+ end
13
+
14
+ # Save a new version of a rule
15
+ # @param rule_id [String] Unique identifier for the rule
16
+ # @param rule_content [Hash] The rule definition
17
+ # @param created_by [String] User who created this version
18
+ # @param changelog [String] Description of changes
19
+ # @return [Hash] The created version
20
+ def save_version(rule_id:, rule_content:, created_by: "system", changelog: nil)
21
+ validate_rule_content!(rule_content)
22
+
23
+ metadata = {
24
+ created_by: created_by,
25
+ changelog: changelog || generate_default_changelog(rule_id)
26
+ }
27
+
28
+ @adapter.create_version(
29
+ rule_id: rule_id,
30
+ content: rule_content,
31
+ metadata: metadata
32
+ )
33
+ end
34
+
35
+ # Get all versions for a rule
36
+ # @param rule_id [String] The rule identifier
37
+ # @param limit [Integer, nil] Optional limit
38
+ # @return [Array<Hash>] Array of versions
39
+ def get_versions(rule_id:, limit: nil)
40
+ @adapter.list_versions(rule_id: rule_id, limit: limit)
41
+ end
42
+
43
+ # Get a specific version
44
+ # @param version_id [String, Integer] The version identifier
45
+ # @return [Hash, nil] The version or nil
46
+ def get_version(version_id:)
47
+ @adapter.get_version(version_id: version_id)
48
+ end
49
+
50
+ # Get the currently active version for a rule
51
+ # @param rule_id [String] The rule identifier
52
+ # @return [Hash, nil] The active version or nil
53
+ def get_active_version(rule_id:)
54
+ @adapter.get_active_version(rule_id: rule_id)
55
+ end
56
+
57
+ # Rollback to a previous version (activate it without creating a duplicate)
58
+ # @param version_id [String, Integer] The version to rollback to
59
+ # @param performed_by [String] User performing the rollback
60
+ # @return [Hash] The activated version
61
+ def rollback(version_id:, performed_by: "system")
62
+ # Simply activate the previous version without creating a duplicate
63
+ # The version history already contains the full record
64
+ @adapter.activate_version(version_id: version_id)
65
+ end
66
+
67
+ # Compare two versions
68
+ # @param version_id_1 [String, Integer] First version
69
+ # @param version_id_2 [String, Integer] Second version
70
+ # @return [Hash] Comparison result
71
+ def compare(version_id_1:, version_id_2:)
72
+ @adapter.compare_versions(
73
+ version_id_1: version_id_1,
74
+ version_id_2: version_id_2
75
+ )
76
+ end
77
+
78
+ # Get version history with metadata
79
+ # @param rule_id [String] The rule identifier
80
+ # @return [Hash] History with statistics
81
+ def get_history(rule_id:)
82
+ versions = get_versions(rule_id: rule_id)
83
+
84
+ {
85
+ rule_id: rule_id,
86
+ total_versions: versions.length,
87
+ active_version: get_active_version(rule_id: rule_id),
88
+ versions: versions,
89
+ created_at: versions.last&.dig(:created_at),
90
+ updated_at: versions.first&.dig(:created_at)
91
+ }
92
+ end
93
+
94
+ # Delete a specific version
95
+ # @param version_id [String, Integer] The version to delete
96
+ # @return [Boolean] True if deleted successfully
97
+ def delete_version(version_id:)
98
+ @adapter.delete_version(version_id: version_id)
99
+ end
100
+
101
+ private
102
+
103
+ def default_adapter
104
+ # Auto-detect the best adapter based on available frameworks
105
+ if defined?(ActiveRecord) && defined?(::RuleVersion)
106
+ require_relative "activerecord_adapter"
107
+ ActiveRecordAdapter.new
108
+ else
109
+ require_relative "file_storage_adapter"
110
+ FileStorageAdapter.new
111
+ end
112
+ end
113
+
114
+ def validate_rule_content!(content)
115
+ raise DecisionAgent::ValidationError, "Rule content cannot be nil" if content.nil?
116
+ raise DecisionAgent::ValidationError, "Rule content must be a Hash" unless content.is_a?(Hash)
117
+ raise DecisionAgent::ValidationError, "Rule content cannot be empty" if content.empty?
118
+ end
119
+
120
+ def generate_default_changelog(rule_id)
121
+ versions = get_versions(rule_id: rule_id)
122
+ version_num = versions.empty? ? 1 : versions.first[:version_number] + 1
123
+ "Version #{version_num}"
124
+ end
125
+ end
126
+ end
127
+ end