decision_agent 1.1.0 → 1.2.0

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 (170) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE.txt +0 -0
  3. data/README.md +3 -2
  4. data/lib/decision_agent/ab_testing/ab_test.rb +0 -0
  5. data/lib/decision_agent/ab_testing/ab_test_assignment.rb +0 -0
  6. data/lib/decision_agent/ab_testing/ab_test_manager.rb +0 -0
  7. data/lib/decision_agent/ab_testing/ab_testing_agent.rb +0 -0
  8. data/lib/decision_agent/ab_testing/storage/activerecord_adapter.rb +0 -3
  9. data/lib/decision_agent/ab_testing/storage/adapter.rb +0 -0
  10. data/lib/decision_agent/ab_testing/storage/memory_adapter.rb +0 -0
  11. data/lib/decision_agent/agent.rb +0 -0
  12. data/lib/decision_agent/audit/adapter.rb +0 -0
  13. data/lib/decision_agent/audit/logger_adapter.rb +0 -0
  14. data/lib/decision_agent/audit/null_adapter.rb +0 -0
  15. data/lib/decision_agent/auth/access_audit_logger.rb +0 -0
  16. data/lib/decision_agent/auth/authenticator.rb +0 -0
  17. data/lib/decision_agent/auth/password_reset_manager.rb +0 -0
  18. data/lib/decision_agent/auth/password_reset_token.rb +0 -0
  19. data/lib/decision_agent/auth/permission.rb +0 -0
  20. data/lib/decision_agent/auth/permission_checker.rb +0 -0
  21. data/lib/decision_agent/auth/rbac_adapter.rb +0 -0
  22. data/lib/decision_agent/auth/rbac_config.rb +0 -0
  23. data/lib/decision_agent/auth/role.rb +0 -0
  24. data/lib/decision_agent/auth/session.rb +0 -0
  25. data/lib/decision_agent/auth/session_manager.rb +0 -0
  26. data/lib/decision_agent/auth/user.rb +0 -0
  27. data/lib/decision_agent/context.rb +0 -0
  28. data/lib/decision_agent/decision.rb +0 -0
  29. data/lib/decision_agent/dmn/adapter.rb +0 -0
  30. data/lib/decision_agent/dmn/cache.rb +0 -0
  31. data/lib/decision_agent/dmn/decision_graph.rb +0 -0
  32. data/lib/decision_agent/dmn/decision_tree.rb +0 -0
  33. data/lib/decision_agent/dmn/errors.rb +0 -0
  34. data/lib/decision_agent/dmn/exporter.rb +41 -2
  35. data/lib/decision_agent/dmn/feel/evaluator.rb +0 -4
  36. data/lib/decision_agent/dmn/feel/functions.rb +0 -0
  37. data/lib/decision_agent/dmn/feel/parser.rb +0 -0
  38. data/lib/decision_agent/dmn/feel/simple_parser.rb +0 -0
  39. data/lib/decision_agent/dmn/feel/transformer.rb +0 -0
  40. data/lib/decision_agent/dmn/feel/types.rb +0 -0
  41. data/lib/decision_agent/dmn/importer.rb +0 -0
  42. data/lib/decision_agent/dmn/model.rb +0 -0
  43. data/lib/decision_agent/dmn/parser.rb +0 -0
  44. data/lib/decision_agent/dmn/testing.rb +0 -4
  45. data/lib/decision_agent/dmn/validator.rb +3 -7
  46. data/lib/decision_agent/dmn/versioning.rb +41 -15
  47. data/lib/decision_agent/dmn/visualizer.rb +0 -0
  48. data/lib/decision_agent/dsl/condition_evaluator.rb +0 -0
  49. data/lib/decision_agent/dsl/helpers/cache_helpers.rb +0 -0
  50. data/lib/decision_agent/dsl/helpers/comparison_helpers.rb +0 -0
  51. data/lib/decision_agent/dsl/helpers/date_helpers.rb +0 -0
  52. data/lib/decision_agent/dsl/helpers/geospatial_helpers.rb +0 -0
  53. data/lib/decision_agent/dsl/helpers/operator_evaluation_helpers.rb +0 -0
  54. data/lib/decision_agent/dsl/helpers/parameter_parsing_helpers.rb +0 -0
  55. data/lib/decision_agent/dsl/helpers/template_helpers.rb +0 -0
  56. data/lib/decision_agent/dsl/helpers/utility_helpers.rb +0 -0
  57. data/lib/decision_agent/dsl/operators/base.rb +2 -2
  58. data/lib/decision_agent/dsl/operators/basic_comparison_operators.rb +0 -0
  59. data/lib/decision_agent/dsl/operators/collection_operators.rb +0 -0
  60. data/lib/decision_agent/dsl/operators/date_arithmetic_operators.rb +0 -0
  61. data/lib/decision_agent/dsl/operators/date_time_operators.rb +1 -1
  62. data/lib/decision_agent/dsl/operators/duration_operators.rb +0 -0
  63. data/lib/decision_agent/dsl/operators/financial_operators.rb +0 -0
  64. data/lib/decision_agent/dsl/operators/geospatial_operators.rb +0 -0
  65. data/lib/decision_agent/dsl/operators/mathematical_operators.rb +0 -0
  66. data/lib/decision_agent/dsl/operators/moving_window_operators.rb +0 -0
  67. data/lib/decision_agent/dsl/operators/numeric_operators.rb +0 -0
  68. data/lib/decision_agent/dsl/operators/rate_operators.rb +0 -0
  69. data/lib/decision_agent/dsl/operators/statistical_aggregations.rb +0 -0
  70. data/lib/decision_agent/dsl/operators/string_aggregations.rb +0 -0
  71. data/lib/decision_agent/dsl/operators/string_operators.rb +0 -0
  72. data/lib/decision_agent/dsl/operators/time_component_operators.rb +0 -0
  73. data/lib/decision_agent/dsl/rule_parser.rb +0 -0
  74. data/lib/decision_agent/dsl/schema_validator.rb +0 -0
  75. data/lib/decision_agent/errors.rb +0 -0
  76. data/lib/decision_agent/evaluation.rb +0 -0
  77. data/lib/decision_agent/evaluation_validator.rb +0 -0
  78. data/lib/decision_agent/evaluators/base.rb +0 -0
  79. data/lib/decision_agent/evaluators/dmn_evaluator.rb +0 -0
  80. data/lib/decision_agent/evaluators/json_rule_evaluator.rb +26 -24
  81. data/lib/decision_agent/evaluators/static_evaluator.rb +0 -0
  82. data/lib/decision_agent/explainability/condition_trace.rb +0 -0
  83. data/lib/decision_agent/explainability/explainability_result.rb +0 -0
  84. data/lib/decision_agent/explainability/rule_trace.rb +0 -0
  85. data/lib/decision_agent/explainability/trace_collector.rb +0 -0
  86. data/lib/decision_agent/monitoring/alert_manager.rb +0 -0
  87. data/lib/decision_agent/monitoring/dashboard/public/dashboard.css +0 -0
  88. data/lib/decision_agent/monitoring/dashboard/public/dashboard.js +0 -0
  89. data/lib/decision_agent/monitoring/dashboard/public/index.html +0 -0
  90. data/lib/decision_agent/monitoring/dashboard_server.rb +0 -0
  91. data/lib/decision_agent/monitoring/metrics_collector.rb +0 -0
  92. data/lib/decision_agent/monitoring/monitored_agent.rb +0 -0
  93. data/lib/decision_agent/monitoring/prometheus_exporter.rb +0 -0
  94. data/lib/decision_agent/monitoring/storage/activerecord_adapter.rb +0 -0
  95. data/lib/decision_agent/monitoring/storage/base_adapter.rb +0 -0
  96. data/lib/decision_agent/monitoring/storage/memory_adapter.rb +1 -1
  97. data/lib/decision_agent/replay/replay.rb +0 -0
  98. data/lib/decision_agent/scoring/base.rb +0 -0
  99. data/lib/decision_agent/scoring/consensus.rb +0 -0
  100. data/lib/decision_agent/scoring/max_weight.rb +0 -0
  101. data/lib/decision_agent/scoring/threshold.rb +0 -0
  102. data/lib/decision_agent/scoring/weighted_average.rb +0 -0
  103. data/lib/decision_agent/simulation/errors.rb +0 -0
  104. data/lib/decision_agent/simulation/impact_analyzer.rb +0 -2
  105. data/lib/decision_agent/simulation/monte_carlo_simulator.rb +0 -2
  106. data/lib/decision_agent/simulation/replay_engine.rb +0 -2
  107. data/lib/decision_agent/simulation/scenario_engine.rb +0 -0
  108. data/lib/decision_agent/simulation/scenario_library.rb +0 -0
  109. data/lib/decision_agent/simulation/shadow_test_engine.rb +0 -0
  110. data/lib/decision_agent/simulation/what_if_analyzer.rb +0 -2
  111. data/lib/decision_agent/simulation.rb +0 -0
  112. data/lib/decision_agent/testing/batch_test_importer.rb +0 -4
  113. data/lib/decision_agent/testing/batch_test_runner.rb +0 -2
  114. data/lib/decision_agent/testing/test_coverage_analyzer.rb +0 -0
  115. data/lib/decision_agent/testing/test_result_comparator.rb +54 -63
  116. data/lib/decision_agent/testing/test_scenario.rb +0 -0
  117. data/lib/decision_agent/version.rb +1 -1
  118. data/lib/decision_agent/versioning/activerecord_adapter.rb +64 -2
  119. data/lib/decision_agent/versioning/adapter.rb +33 -0
  120. data/lib/decision_agent/versioning/file_storage_adapter.rb +77 -7
  121. data/lib/decision_agent/versioning/version_manager.rb +40 -2
  122. data/lib/decision_agent/web/dmn_editor/serialization.rb +0 -0
  123. data/lib/decision_agent/web/dmn_editor/xml_builder.rb +0 -0
  124. data/lib/decision_agent/web/dmn_editor.rb +0 -6
  125. data/lib/decision_agent/web/middleware/auth_middleware.rb +0 -0
  126. data/lib/decision_agent/web/middleware/permission_middleware.rb +0 -0
  127. data/lib/decision_agent/web/public/app.js +0 -0
  128. data/lib/decision_agent/web/public/batch_testing.html +0 -0
  129. data/lib/decision_agent/web/public/dmn-editor.css +0 -0
  130. data/lib/decision_agent/web/public/dmn-editor.html +0 -0
  131. data/lib/decision_agent/web/public/dmn-editor.js +5 -0
  132. data/lib/decision_agent/web/public/index.html +0 -0
  133. data/lib/decision_agent/web/public/login.html +0 -0
  134. data/lib/decision_agent/web/public/sample_batch.csv +0 -0
  135. data/lib/decision_agent/web/public/sample_impact.csv +0 -0
  136. data/lib/decision_agent/web/public/sample_replay.csv +0 -0
  137. data/lib/decision_agent/web/public/sample_rules.json +0 -0
  138. data/lib/decision_agent/web/public/sample_shadow.csv +0 -0
  139. data/lib/decision_agent/web/public/sample_whatif.csv +0 -0
  140. data/lib/decision_agent/web/public/simulation.html +0 -0
  141. data/lib/decision_agent/web/public/simulation_impact.html +0 -0
  142. data/lib/decision_agent/web/public/simulation_replay.html +0 -0
  143. data/lib/decision_agent/web/public/simulation_shadow.html +0 -0
  144. data/lib/decision_agent/web/public/simulation_whatif.html +0 -0
  145. data/lib/decision_agent/web/public/styles.css +0 -0
  146. data/lib/decision_agent/web/public/users.html +0 -0
  147. data/lib/decision_agent/web/rack_helpers.rb +0 -0
  148. data/lib/decision_agent/web/rack_request_helpers.rb +0 -0
  149. data/lib/decision_agent/web/server.rb +8 -2
  150. data/lib/decision_agent.rb +0 -0
  151. data/lib/generators/decision_agent/install/install_generator.rb +0 -0
  152. data/lib/generators/decision_agent/install/templates/README +0 -0
  153. data/lib/generators/decision_agent/install/templates/ab_test_assignment_model.rb +0 -0
  154. data/lib/generators/decision_agent/install/templates/ab_test_model.rb +0 -0
  155. data/lib/generators/decision_agent/install/templates/ab_testing_migration.rb +0 -0
  156. data/lib/generators/decision_agent/install/templates/ab_testing_tasks.rake +0 -0
  157. data/lib/generators/decision_agent/install/templates/decision_agent_tasks.rake +0 -0
  158. data/lib/generators/decision_agent/install/templates/decision_log.rb +0 -0
  159. data/lib/generators/decision_agent/install/templates/error_metric.rb +0 -0
  160. data/lib/generators/decision_agent/install/templates/evaluation_metric.rb +0 -0
  161. data/lib/generators/decision_agent/install/templates/migration.rb +14 -0
  162. data/lib/generators/decision_agent/install/templates/monitoring_migration.rb +0 -2
  163. data/lib/generators/decision_agent/install/templates/performance_metric.rb +0 -0
  164. data/lib/generators/decision_agent/install/templates/rule.rb +0 -0
  165. data/lib/generators/decision_agent/install/templates/rule_version.rb +0 -0
  166. data/lib/generators/decision_agent/install/templates/rule_version_tag.rb +23 -0
  167. data/lib/generators/decision_agent/install/templates/versioning_migration.rb +44 -0
  168. data/lib/generators/decision_agent/monitoring_migration/monitoring_migration_generator.rb +67 -0
  169. data/lib/generators/decision_agent/versioning_migration/versioning_migration_generator.rb +57 -0
  170. metadata +10 -3
File without changes
File without changes
File without changes
File without changes
@@ -5,7 +5,6 @@ require_relative "errors"
5
5
  module DecisionAgent
6
6
  module Simulation
7
7
  # Analyzer for quantifying rule change impact
8
- # rubocop:disable Metrics/ClassLength
9
8
  class ImpactAnalyzer
10
9
  attr_reader :version_manager
11
10
 
@@ -494,7 +493,6 @@ module DecisionAgent
494
493
  "#{parts.join('. ')}."
495
494
  end
496
495
  end
497
- # rubocop:enable Metrics/ClassLength
498
496
  end
499
497
  end
500
498
  end
@@ -27,7 +27,6 @@ module DecisionAgent
27
27
  #
28
28
  # puts "Decision probabilities: #{results[:decision_probabilities]}"
29
29
  # puts "Average confidence: #{results[:average_confidence]}"
30
- # rubocop:disable Metrics/ClassLength
31
30
  class MonteCarloSimulator
32
31
  attr_reader :agent, :version_manager
33
32
 
@@ -632,7 +631,6 @@ module DecisionAgent
632
631
  end
633
632
  target[last_key.to_sym] = value
634
633
  end
635
- # rubocop:enable Metrics/ClassLength
636
634
  end
637
635
  end
638
636
  end
@@ -14,7 +14,6 @@ end
14
14
  module DecisionAgent
15
15
  module Simulation
16
16
  # Engine for replaying historical decisions and backtesting rule changes
17
- # rubocop:disable Metrics/ClassLength
18
17
  class ReplayEngine
19
18
  attr_reader :agent, :version_manager
20
19
 
@@ -482,7 +481,6 @@ module DecisionAgent
482
481
  errors: results.count { |r| r[:error] }
483
482
  }
484
483
  end
485
- # rubocop:enable Metrics/ClassLength
486
484
  end
487
485
  end
488
486
  end
File without changes
File without changes
File without changes
@@ -5,7 +5,6 @@ require_relative "errors"
5
5
  module DecisionAgent
6
6
  module Simulation
7
7
  # Analyzer for what-if scenario simulation
8
- # rubocop:disable Metrics/ClassLength
9
8
  class WhatIfAnalyzer
10
9
  attr_reader :agent, :version_manager
11
10
 
@@ -1002,7 +1001,6 @@ module DecisionAgent
1002
1001
  "<div class='legend-item'><div class='legend-color' style='background: #{color};'></div><span>#{decision}</span></div>"
1003
1002
  end.join
1004
1003
  end
1005
- # rubocop:enable Metrics/ClassLength
1006
1004
  end
1007
1005
  end
1008
1006
  end
File without changes
@@ -24,7 +24,6 @@ module DecisionAgent
24
24
  # - :skip_header [Boolean] Skip first row (default: true)
25
25
  # - :progress_callback [Proc] Callback for progress updates (called with { processed: N, total: M, percentage: X })
26
26
  # @return [Array<TestScenario>] Array of test scenarios
27
- # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
28
27
  def import_csv(file_path, options = {})
29
28
  @errors = []
30
29
  @warnings = []
@@ -101,7 +100,6 @@ module DecisionAgent
101
100
 
102
101
  scenarios
103
102
  end
104
- # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
105
103
 
106
104
  # Import test scenarios from an Excel file (.xlsx, .xls)
107
105
  # @param file_path [String] Path to Excel file
@@ -109,7 +107,6 @@ module DecisionAgent
109
107
  # - :sheet [String|Integer] Sheet name or index (default: first sheet)
110
108
  # - :progress_callback [Proc] Callback for progress updates
111
109
  # @return [Array<TestScenario>] Array of test scenarios
112
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
113
110
  def import_excel(file_path, options = {})
114
111
  @errors = []
115
112
  @warnings = []
@@ -223,7 +220,6 @@ module DecisionAgent
223
220
  raise ImportError, "Failed to read Excel file: #{e.message}"
224
221
  end
225
222
  end
226
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
227
223
 
228
224
  # Import test scenarios from an array of hashes (for programmatic use)
229
225
  # @param data [Array<Hash>] Array of hashes with test data
@@ -55,7 +55,6 @@ module DecisionAgent
55
55
  # - :feedback [Hash] Optional feedback to pass to agent
56
56
  # - :checkpoint_file [String] Path to checkpoint file for resume capability (optional)
57
57
  # @return [Array<TestResult>] Array of test results
58
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
59
58
  def run(scenarios, options = {})
60
59
  @results = []
61
60
  @checkpoint_file = options[:checkpoint_file]
@@ -141,7 +140,6 @@ module DecisionAgent
141
140
  total_execution_time_ms: execution_times.sum
142
141
  }
143
142
  end
144
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
145
143
 
146
144
  private
147
145
 
File without changes
@@ -6,7 +6,6 @@ module DecisionAgent
6
6
  class ComparisonResult
7
7
  attr_reader :scenario_id, :match, :decision_match, :confidence_match, :differences, :actual, :expected
8
8
 
9
- # rubocop:disable Metrics/ParameterLists
10
9
  def initialize(scenario_id:, match:, decision_match:, confidence_match:, differences:, actual:, expected:)
11
10
  @scenario_id = scenario_id.to_s.freeze
12
11
  @match = match
@@ -18,7 +17,6 @@ module DecisionAgent
18
17
 
19
18
  freeze
20
19
  end
21
- # rubocop:enable Metrics/ParameterLists
22
20
 
23
21
  def to_h
24
22
  {
@@ -140,78 +138,71 @@ module DecisionAgent
140
138
 
141
139
  private
142
140
 
143
- # rubocop:disable Metrics/MethodLength, Metrics/PerceivedComplexity
144
141
  def compare_single(scenario, result)
142
+ return failed_comparison_result(scenario, result) if result.nil? || !result.success?
143
+
145
144
  differences = []
146
- confidence_match = false
147
-
148
- if result.nil? || !result.success?
149
- differences << "Test execution failed: #{result&.error&.message || 'No result'}"
150
- return ComparisonResult.new(
151
- scenario_id: scenario.id,
152
- match: false,
153
- decision_match: false,
154
- confidence_match: false,
155
- differences: differences,
156
- actual: { decision: nil, confidence: nil },
157
- expected: {
158
- decision: scenario.expected_decision,
159
- confidence: scenario.expected_confidence
160
- }
161
- )
162
- end
145
+ decision_match = compare_decision(scenario, result, differences)
146
+ confidence_match = compare_confidence(scenario, result, differences)
147
+
148
+ ComparisonResult.new(
149
+ scenario_id: scenario.id,
150
+ match: decision_match && confidence_match,
151
+ decision_match: decision_match,
152
+ confidence_match: confidence_match,
153
+ differences: differences,
154
+ actual: { decision: result.decision&.to_s, confidence: result.confidence },
155
+ expected: { decision: scenario.expected_decision&.to_s, confidence: scenario.expected_confidence }
156
+ )
157
+ end
158
+
159
+ def failed_comparison_result(scenario, result)
160
+ ComparisonResult.new(
161
+ scenario_id: scenario.id,
162
+ match: false,
163
+ decision_match: false,
164
+ confidence_match: false,
165
+ differences: ["Test execution failed: #{result&.error&.message || 'No result'}"],
166
+ actual: { decision: nil, confidence: nil },
167
+ expected: { decision: scenario.expected_decision, confidence: scenario.expected_confidence }
168
+ )
169
+ end
163
170
 
164
- # Compare decision
165
- expected_decision = scenario.expected_decision&.to_s
166
- actual_decision = result.decision&.to_s
171
+ def compare_decision(scenario, result, differences)
172
+ expected = scenario.expected_decision&.to_s
173
+ actual = result.decision&.to_s
167
174
 
168
- decision_match = if expected_decision.nil?
169
- true # No expectation, so it matches
170
- elsif @options[:fuzzy_match]
171
- fuzzy_decision_match?(expected_decision, actual_decision)
172
- else
173
- expected_decision == actual_decision
174
- end
175
+ match = if expected.nil?
176
+ true
177
+ elsif @options[:fuzzy_match]
178
+ fuzzy_decision_match?(expected, actual)
179
+ else
180
+ expected == actual
181
+ end
175
182
 
176
- differences << "Decision mismatch: expected '#{expected_decision}', got '#{actual_decision}'" unless decision_match
183
+ differences << "Decision mismatch: expected '#{expected}', got '#{actual}'" unless match
184
+ match
185
+ end
186
+
187
+ def compare_confidence(scenario, result, differences)
188
+ expected = scenario.expected_confidence
189
+ actual = result.confidence
177
190
 
178
- # Compare confidence
179
- expected_confidence = scenario.expected_confidence
180
- actual_confidence = result.confidence
191
+ return true if expected.nil?
181
192
 
182
- if expected_confidence.nil?
183
- confidence_match = true # No expectation, so it matches
184
- elsif actual_confidence.nil?
185
- confidence_match = false
193
+ if actual.nil?
186
194
  differences << "Confidence missing in actual result"
187
- else
188
- tolerance = @options[:confidence_tolerance]
189
- confidence_match = (expected_confidence - actual_confidence).abs <= tolerance
190
- unless confidence_match
191
- diff = (expected_confidence - actual_confidence).abs.round(4)
192
- differences << "Confidence mismatch: expected #{expected_confidence}, got #{actual_confidence} (diff: #{diff})"
193
- end
195
+ return false
194
196
  end
195
197
 
196
- match = decision_match && confidence_match
197
-
198
- ComparisonResult.new(
199
- scenario_id: scenario.id,
200
- match: match,
201
- decision_match: decision_match,
202
- confidence_match: confidence_match,
203
- differences: differences,
204
- actual: {
205
- decision: actual_decision,
206
- confidence: actual_confidence
207
- },
208
- expected: {
209
- decision: expected_decision,
210
- confidence: expected_confidence
211
- }
212
- )
198
+ tolerance = @options[:confidence_tolerance]
199
+ match = (expected - actual).abs <= tolerance
200
+ unless match
201
+ diff = (expected - actual).abs.round(4)
202
+ differences << "Confidence mismatch: expected #{expected}, got #{actual} (diff: #{diff})"
203
+ end
204
+ match
213
205
  end
214
- # rubocop:enable Metrics/MethodLength, Metrics/PerceivedComplexity
215
206
 
216
207
  def fuzzy_decision_match?(expected, actual)
217
208
  return true if expected == actual
File without changes
@@ -5,7 +5,7 @@ module DecisionAgent
5
5
  # MAJOR: Incremented for incompatible API changes
6
6
  # MINOR: Incremented for backward-compatible functionality additions
7
7
  # PATCH: Incremented for backward-compatible bug fixes
8
- VERSION = "1.1.0"
8
+ VERSION = "1.2.0"
9
9
 
10
10
  # Validate version format (semantic versioning)
11
11
  unless VERSION.match?(/\A\d+\.\d+\.\d+(-[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*)?(\+[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*)?\z/)
@@ -131,6 +131,47 @@ module DecisionAgent
131
131
  raise DecisionAgent::NotFoundError, "Version not found: #{version_id}"
132
132
  end
133
133
 
134
+ # Create (or update) a named tag pointing to a specific version.
135
+ # Tags are unique per model; calling this with an existing name re-points the tag.
136
+ def create_tag(model_id:, version_id:, name:)
137
+ raise DecisionAgent::ValidationError, "Tag name cannot be blank" if name.nil? || name.to_s.strip.empty?
138
+ raise DecisionAgent::NotFoundError, "Version not found: #{version_id}" unless get_version(version_id: version_id)
139
+
140
+ retry_with_backoff(max_retries: 10) do
141
+ tag = nil
142
+ rule_version_tag_class.transaction do
143
+ existing = rule_version_tag_class.find_by(model_id: model_id, name: name)
144
+ if existing
145
+ existing.update!(version_id: version_id)
146
+ tag = existing.reload
147
+ else
148
+ tag = rule_version_tag_class.create!(model_id: model_id, name: name, version_id: version_id)
149
+ end
150
+ end
151
+ serialize_tag(tag)
152
+ end
153
+ end
154
+
155
+ # Retrieve a tag by name for a given model.
156
+ def get_tag(model_id:, name:)
157
+ tag = rule_version_tag_class.find_by(model_id: model_id, name: name)
158
+ tag ? serialize_tag(tag) : nil
159
+ end
160
+
161
+ # List all tags for a given model, sorted by name.
162
+ def list_tags(model_id:)
163
+ rule_version_tag_class.where(model_id: model_id).order(name: :asc).map { |t| serialize_tag(t) }
164
+ end
165
+
166
+ # Delete a tag by name. Returns true if deleted, false if the tag did not exist.
167
+ def delete_tag(model_id:, name:)
168
+ tag = rule_version_tag_class.find_by(model_id: model_id, name: name)
169
+ return false unless tag
170
+
171
+ tag.destroy
172
+ true
173
+ end
174
+
134
175
  private
135
176
 
136
177
  def rule_version_class
@@ -143,6 +184,15 @@ module DecisionAgent
143
184
  end
144
185
  end
145
186
 
187
+ def rule_version_tag_class
188
+ if defined?(::RuleVersionTag)
189
+ ::RuleVersionTag
190
+ else
191
+ raise DecisionAgent::ConfigurationError,
192
+ "RuleVersionTag model not found. Please run the versioning generator to create it."
193
+ end
194
+ end
195
+
146
196
  # Retry database operations that may encounter SQLite busy exceptions
147
197
  # This is especially important for concurrent operations on different rules
148
198
  def retry_with_backoff(max_retries: 10, base_delay: 0.01)
@@ -157,7 +207,9 @@ module DecisionAgent
157
207
  cause = e.cause
158
208
  if cause
159
209
  cause.class.name.include?("BusyException") ||
160
- cause.class.name.include?("SQLite3::BusyException")
210
+ cause.class.name.include?("SQLite3::BusyException") ||
211
+ cause.class.name.include?("LockedException") ||
212
+ cause.class.name.include?("SQLite3::LockedException")
161
213
  else
162
214
  false
163
215
  end
@@ -165,8 +217,10 @@ module DecisionAgent
165
217
  warn "[DecisionAgent] Error checking busy exception cause: #{cause_check_error.message}"
166
218
  false
167
219
  end || e.message.include?("database is locked") ||
220
+ e.message.include?("database table is locked") ||
168
221
  e.message.include?("SQLite3::BusyException") ||
169
- e.message.include?("BusyException")
222
+ e.message.include?("BusyException") ||
223
+ e.message.include?("LockedException")
170
224
 
171
225
  raise unless is_busy && retries < max_retries
172
226
 
@@ -201,6 +255,14 @@ module DecisionAgent
201
255
  status: version.status
202
256
  }
203
257
  end
258
+
259
+ def serialize_tag(tag)
260
+ {
261
+ name: tag.name,
262
+ version_id: tag.version_id,
263
+ created_at: tag.updated_at || tag.created_at
264
+ }
265
+ end
204
266
  end
205
267
  end
206
268
  end
@@ -82,6 +82,39 @@ module DecisionAgent
82
82
  raise NotImplementedError, "#{self.class} must implement #delete_version"
83
83
  end
84
84
 
85
+ # Create (or update) a named tag pointing to a specific version.
86
+ # Tags are unique per model; calling this with an existing name re-points the tag.
87
+ # @param model_id [String] The rule/model identifier
88
+ # @param version_id [String] The version to tag
89
+ # @param name [String] The tag name (e.g. "release-candidate")
90
+ # @return [Hash] The created/updated tag ({ name:, version_id:, created_at: })
91
+ def create_tag(model_id:, version_id:, name:)
92
+ raise NotImplementedError, "#{self.class} must implement #create_tag"
93
+ end
94
+
95
+ # Retrieve a tag by name for a given model.
96
+ # @param model_id [String] The rule/model identifier
97
+ # @param name [String] The tag name
98
+ # @return [Hash, nil] The tag hash or nil if not found
99
+ def get_tag(model_id:, name:)
100
+ raise NotImplementedError, "#{self.class} must implement #get_tag"
101
+ end
102
+
103
+ # List all tags for a given model.
104
+ # @param model_id [String] The rule/model identifier
105
+ # @return [Array<Hash>] Array of tag hashes, sorted by name
106
+ def list_tags(model_id:)
107
+ raise NotImplementedError, "#{self.class} must implement #list_tags"
108
+ end
109
+
110
+ # Delete a tag by name.
111
+ # @param model_id [String] The rule/model identifier
112
+ # @param name [String] The tag name
113
+ # @return [Boolean] True if deleted, false if tag did not exist
114
+ def delete_tag(model_id:, name:)
115
+ raise NotImplementedError, "#{self.class} must implement #delete_tag"
116
+ end
117
+
85
118
  private
86
119
 
87
120
  # Calculate differences between two content hashes
@@ -159,6 +159,45 @@ module DecisionAgent
159
159
  end
160
160
  end
161
161
 
162
+ def create_tag(model_id:, version_id:, name:)
163
+ raise DecisionAgent::ValidationError, "Tag name cannot be blank" if name.nil? || name.to_s.strip.empty?
164
+
165
+ # Validate the version exists
166
+ version = get_version(version_id: version_id)
167
+ raise DecisionAgent::NotFoundError, "Version not found: #{version_id}" unless version
168
+
169
+ with_rule_lock(model_id) do
170
+ tags = read_tags_unsafe(model_id)
171
+ tag = { name: name, version_id: version_id, created_at: Time.now.utc.iso8601 }
172
+ tags[name] = tag
173
+ write_tags_unsafe(model_id, tags)
174
+ tag
175
+ end
176
+ end
177
+
178
+ def get_tag(model_id:, name:)
179
+ with_rule_lock(model_id) do
180
+ read_tags_unsafe(model_id)[name]
181
+ end
182
+ end
183
+
184
+ def list_tags(model_id:)
185
+ with_rule_lock(model_id) do
186
+ read_tags_unsafe(model_id).values.sort_by { |t| t[:name] }
187
+ end
188
+ end
189
+
190
+ def delete_tag(model_id:, name:)
191
+ with_rule_lock(model_id) do
192
+ tags = read_tags_unsafe(model_id)
193
+ return false unless tags.key?(name)
194
+
195
+ tags.delete(name)
196
+ write_tags_unsafe(model_id, tags)
197
+ true
198
+ end
199
+ end
200
+
162
201
  def delete_version(version_id:)
163
202
  # Use index to find rule_id quickly - O(1) instead of O(n)
164
203
  begin
@@ -253,11 +292,13 @@ module DecisionAgent
253
292
 
254
293
  return versions unless Dir.exist?(rule_dir)
255
294
 
256
- Dir.glob(File.join(rule_dir, "*.json")).each do |file|
257
- versions << JSON.parse(File.read(file), symbolize_names: true)
258
- rescue JSON::ParserError, Errno::ENOENT
259
- # Skip corrupted or deleted files
260
- next
295
+ Dir.glob(File.join(rule_dir, "*.json"))
296
+ .reject { |f| File.basename(f).start_with?("_") }
297
+ .each do |file|
298
+ versions << JSON.parse(File.read(file), symbolize_names: true)
299
+ rescue JSON::ParserError, Errno::ENOENT
300
+ # Skip corrupted or deleted files
301
+ next
261
302
  end
262
303
 
263
304
  versions.sort_by! { |v| -v[:version_number] }
@@ -268,8 +309,10 @@ module DecisionAgent
268
309
  versions = []
269
310
  return versions unless Dir.exist?(@storage_path)
270
311
 
271
- Dir.glob(File.join(@storage_path, "*", "*.json")).each do |file|
272
- versions << JSON.parse(File.read(file), symbolize_names: true)
312
+ Dir.glob(File.join(@storage_path, "*", "*.json"))
313
+ .reject { |f| File.basename(f).start_with?("_") }
314
+ .each do |file|
315
+ versions << JSON.parse(File.read(file), symbolize_names: true)
273
316
  end
274
317
 
275
318
  versions
@@ -366,6 +409,33 @@ module DecisionAgent
366
409
  @version_index.delete(version_id)
367
410
  end
368
411
  end
412
+
413
+ # Tags helpers — called while the rule lock is already held (unsafe = no extra lock)
414
+
415
+ def tags_filepath(model_id)
416
+ rule_dir = File.join(@storage_path, sanitize_filename(model_id))
417
+ FileUtils.mkdir_p(rule_dir)
418
+ File.join(rule_dir, "_tags.json")
419
+ end
420
+
421
+ def read_tags_unsafe(model_id)
422
+ path = tags_filepath(model_id)
423
+ return {} unless File.exist?(path)
424
+
425
+ JSON.parse(File.read(path), symbolize_names: false)
426
+ .transform_values { |t| t.transform_keys(&:to_sym) }
427
+ rescue JSON::ParserError
428
+ {}
429
+ end
430
+
431
+ def write_tags_unsafe(model_id, tags)
432
+ path = tags_filepath(model_id)
433
+ temp = "#{path}.tmp.#{Process.pid}.#{Thread.current.object_id}"
434
+ File.write(temp, JSON.pretty_generate(tags))
435
+ File.rename(temp, path)
436
+ ensure
437
+ FileUtils.rm_f(temp)
438
+ end
369
439
  end
370
440
  end
371
441
  end
@@ -18,8 +18,9 @@ module DecisionAgent
18
18
  # @param rule_content [Hash] The rule definition
19
19
  # @param created_by [String] User who created this version
20
20
  # @param changelog [String] Description of changes
21
+ # @param tag [String, nil] Optional tag name to apply to the new version at creation time
21
22
  # @return [Hash] The created version
22
- def save_version(rule_id:, rule_content:, created_by: "system", changelog: nil)
23
+ def save_version(rule_id:, rule_content:, created_by: "system", changelog: nil, tag: nil)
23
24
  validate_rule_content!(rule_content)
24
25
 
25
26
  metadata = {
@@ -27,11 +28,15 @@ module DecisionAgent
27
28
  changelog: changelog || generate_default_changelog(rule_id)
28
29
  }
29
30
 
30
- @adapter.create_version(
31
+ version = @adapter.create_version(
31
32
  rule_id: rule_id,
32
33
  content: rule_content,
33
34
  metadata: metadata
34
35
  )
36
+
37
+ tag!(rule_id, version[:id], tag) if tag
38
+
39
+ version
35
40
  end
36
41
 
37
42
  # Get all versions for a rule
@@ -107,6 +112,39 @@ module DecisionAgent
107
112
  @adapter.delete_version(version_id: version_id)
108
113
  end
109
114
 
115
+ # Tag a specific version after the fact.
116
+ # Creates the tag if it does not exist; re-points it if the name is already used.
117
+ # @param model_id [String] The rule/model identifier
118
+ # @param version_id [String] The version to tag
119
+ # @param name [String] The tag name
120
+ # @return [Hash] The tag ({ name:, version_id:, created_at: })
121
+ def tag!(model_id, version_id, name)
122
+ @adapter.create_tag(model_id: model_id, version_id: version_id, name: name)
123
+ end
124
+
125
+ # Resolve a tag to its version hash, or nil if the tag does not exist.
126
+ # @param model_id [String] The rule/model identifier
127
+ # @param name [String] The tag name
128
+ # @return [Hash, nil] Tag hash or nil
129
+ def get_tag(model_id:, name:)
130
+ @adapter.get_tag(model_id: model_id, name: name)
131
+ end
132
+
133
+ # List all tags for a model.
134
+ # @param model_id [String] The rule/model identifier
135
+ # @return [Array<Hash>] Tag hashes sorted by name
136
+ def list_tags(model_id:)
137
+ @adapter.list_tags(model_id: model_id)
138
+ end
139
+
140
+ # Delete a tag by name.
141
+ # @param model_id [String] The rule/model identifier
142
+ # @param name [String] The tag name
143
+ # @return [Boolean] True if deleted, false if the tag did not exist
144
+ def delete_tag(model_id:, name:)
145
+ @adapter.delete_tag(model_id: model_id, name: name)
146
+ end
147
+
110
148
  private
111
149
 
112
150
  def default_adapter
File without changes
File without changes
@@ -70,14 +70,12 @@ module DecisionAgent
70
70
  end
71
71
 
72
72
  # Delete a DMN model
73
- # rubocop:disable Naming/PredicateMethod
74
73
  def delete_model(model_id)
75
74
  @storage_mutex.synchronize do
76
75
  @storage.delete(model_id)
77
76
  end
78
77
  true
79
78
  end
80
- # rubocop:enable Naming/PredicateMethod
81
79
 
82
80
  # Add a decision to a model
83
81
  def add_decision(model_id:, decision_id:, name:, type: "decision_table")
@@ -128,7 +126,6 @@ module DecisionAgent
128
126
  end
129
127
 
130
128
  # Delete a decision
131
- # rubocop:disable Naming/PredicateMethod
132
129
  def delete_decision(model_id:, decision_id:)
133
130
  model = retrieve_model(model_id)
134
131
  return false unless model
@@ -137,7 +134,6 @@ module DecisionAgent
137
134
  store_model(model_id, model)
138
135
  true
139
136
  end
140
- # rubocop:enable Naming/PredicateMethod
141
137
 
142
138
  # Add input to decision table
143
139
  def add_input(model_id:, decision_id:, input_id:, label:, type_ref: nil, expression: nil)
@@ -220,7 +216,6 @@ module DecisionAgent
220
216
  end
221
217
 
222
218
  # Delete rule
223
- # rubocop:disable Naming/PredicateMethod
224
219
  def delete_rule(model_id:, decision_id:, rule_id:)
225
220
  model = retrieve_model(model_id)
226
221
  return false unless model
@@ -232,7 +227,6 @@ module DecisionAgent
232
227
  store_model(model_id, model)
233
228
  true
234
229
  end
235
- # rubocop:enable Naming/PredicateMethod
236
230
 
237
231
  # Validate a DMN model
238
232
  def validate_model(model_id)
File without changes