decision_agent 1.0.1 → 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 (176) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE.txt +0 -0
  3. data/README.md +64 -108
  4. data/lib/decision_agent/ab_testing/ab_test.rb +5 -1
  5. data/lib/decision_agent/ab_testing/ab_test_assignment.rb +2 -0
  6. data/lib/decision_agent/ab_testing/ab_test_manager.rb +2 -0
  7. data/lib/decision_agent/ab_testing/ab_testing_agent.rb +2 -0
  8. data/lib/decision_agent/ab_testing/storage/activerecord_adapter.rb +2 -16
  9. data/lib/decision_agent/ab_testing/storage/adapter.rb +2 -0
  10. data/lib/decision_agent/ab_testing/storage/memory_adapter.rb +2 -0
  11. data/lib/decision_agent/agent.rb +49 -51
  12. data/lib/decision_agent/audit/adapter.rb +2 -0
  13. data/lib/decision_agent/audit/logger_adapter.rb +2 -0
  14. data/lib/decision_agent/audit/null_adapter.rb +2 -0
  15. data/lib/decision_agent/auth/access_audit_logger.rb +2 -0
  16. data/lib/decision_agent/auth/authenticator.rb +2 -0
  17. data/lib/decision_agent/auth/password_reset_manager.rb +2 -0
  18. data/lib/decision_agent/auth/password_reset_token.rb +2 -0
  19. data/lib/decision_agent/auth/permission.rb +2 -0
  20. data/lib/decision_agent/auth/permission_checker.rb +2 -0
  21. data/lib/decision_agent/auth/rbac_adapter.rb +2 -0
  22. data/lib/decision_agent/auth/rbac_config.rb +2 -0
  23. data/lib/decision_agent/auth/role.rb +2 -0
  24. data/lib/decision_agent/auth/session.rb +2 -0
  25. data/lib/decision_agent/auth/session_manager.rb +2 -0
  26. data/lib/decision_agent/auth/user.rb +2 -0
  27. data/lib/decision_agent/context.rb +13 -0
  28. data/lib/decision_agent/decision.rb +11 -2
  29. data/lib/decision_agent/dmn/adapter.rb +2 -0
  30. data/lib/decision_agent/dmn/cache.rb +2 -2
  31. data/lib/decision_agent/dmn/decision_graph.rb +7 -7
  32. data/lib/decision_agent/dmn/decision_tree.rb +16 -8
  33. data/lib/decision_agent/dmn/errors.rb +2 -0
  34. data/lib/decision_agent/dmn/exporter.rb +43 -2
  35. data/lib/decision_agent/dmn/feel/evaluator.rb +102 -112
  36. data/lib/decision_agent/dmn/feel/functions.rb +2 -0
  37. data/lib/decision_agent/dmn/feel/parser.rb +2 -0
  38. data/lib/decision_agent/dmn/feel/simple_parser.rb +98 -77
  39. data/lib/decision_agent/dmn/feel/transformer.rb +56 -102
  40. data/lib/decision_agent/dmn/feel/types.rb +2 -0
  41. data/lib/decision_agent/dmn/importer.rb +2 -0
  42. data/lib/decision_agent/dmn/model.rb +2 -4
  43. data/lib/decision_agent/dmn/parser.rb +2 -0
  44. data/lib/decision_agent/dmn/testing.rb +3 -6
  45. data/lib/decision_agent/dmn/validator.rb +8 -10
  46. data/lib/decision_agent/dmn/versioning.rb +41 -15
  47. data/lib/decision_agent/dmn/visualizer.rb +7 -6
  48. data/lib/decision_agent/dsl/condition_evaluator.rb +197 -1473
  49. data/lib/decision_agent/dsl/helpers/cache_helpers.rb +82 -0
  50. data/lib/decision_agent/dsl/helpers/comparison_helpers.rb +98 -0
  51. data/lib/decision_agent/dsl/helpers/date_helpers.rb +91 -0
  52. data/lib/decision_agent/dsl/helpers/geospatial_helpers.rb +85 -0
  53. data/lib/decision_agent/dsl/helpers/operator_evaluation_helpers.rb +160 -0
  54. data/lib/decision_agent/dsl/helpers/parameter_parsing_helpers.rb +206 -0
  55. data/lib/decision_agent/dsl/helpers/template_helpers.rb +39 -0
  56. data/lib/decision_agent/dsl/helpers/utility_helpers.rb +45 -0
  57. data/lib/decision_agent/dsl/operators/base.rb +70 -0
  58. data/lib/decision_agent/dsl/operators/basic_comparison_operators.rb +80 -0
  59. data/lib/decision_agent/dsl/operators/collection_operators.rb +60 -0
  60. data/lib/decision_agent/dsl/operators/date_arithmetic_operators.rb +206 -0
  61. data/lib/decision_agent/dsl/operators/date_time_operators.rb +47 -0
  62. data/lib/decision_agent/dsl/operators/duration_operators.rb +149 -0
  63. data/lib/decision_agent/dsl/operators/financial_operators.rb +237 -0
  64. data/lib/decision_agent/dsl/operators/geospatial_operators.rb +106 -0
  65. data/lib/decision_agent/dsl/operators/mathematical_operators.rb +234 -0
  66. data/lib/decision_agent/dsl/operators/moving_window_operators.rb +135 -0
  67. data/lib/decision_agent/dsl/operators/numeric_operators.rb +120 -0
  68. data/lib/decision_agent/dsl/operators/rate_operators.rb +65 -0
  69. data/lib/decision_agent/dsl/operators/statistical_aggregations.rb +187 -0
  70. data/lib/decision_agent/dsl/operators/string_aggregations.rb +84 -0
  71. data/lib/decision_agent/dsl/operators/string_operators.rb +72 -0
  72. data/lib/decision_agent/dsl/operators/time_component_operators.rb +72 -0
  73. data/lib/decision_agent/dsl/rule_parser.rb +2 -0
  74. data/lib/decision_agent/dsl/schema_validator.rb +9 -24
  75. data/lib/decision_agent/errors.rb +2 -0
  76. data/lib/decision_agent/evaluation.rb +14 -2
  77. data/lib/decision_agent/evaluation_validator.rb +0 -0
  78. data/lib/decision_agent/evaluators/base.rb +2 -0
  79. data/lib/decision_agent/evaluators/dmn_evaluator.rb +2 -0
  80. data/lib/decision_agent/evaluators/json_rule_evaluator.rb +28 -41
  81. data/lib/decision_agent/evaluators/static_evaluator.rb +2 -0
  82. data/lib/decision_agent/explainability/condition_trace.rb +2 -0
  83. data/lib/decision_agent/explainability/explainability_result.rb +2 -4
  84. data/lib/decision_agent/explainability/rule_trace.rb +2 -0
  85. data/lib/decision_agent/explainability/trace_collector.rb +2 -0
  86. data/lib/decision_agent/monitoring/alert_manager.rb +2 -15
  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 +383 -250
  91. data/lib/decision_agent/monitoring/metrics_collector.rb +2 -0
  92. data/lib/decision_agent/monitoring/monitored_agent.rb +2 -0
  93. data/lib/decision_agent/monitoring/prometheus_exporter.rb +3 -1
  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 +4 -1
  98. data/lib/decision_agent/scoring/base.rb +2 -0
  99. data/lib/decision_agent/scoring/consensus.rb +2 -0
  100. data/lib/decision_agent/scoring/max_weight.rb +2 -0
  101. data/lib/decision_agent/scoring/threshold.rb +2 -0
  102. data/lib/decision_agent/scoring/weighted_average.rb +2 -0
  103. data/lib/decision_agent/simulation/errors.rb +2 -0
  104. data/lib/decision_agent/simulation/impact_analyzer.rb +3 -3
  105. data/lib/decision_agent/simulation/monte_carlo_simulator.rb +11 -10
  106. data/lib/decision_agent/simulation/replay_engine.rb +3 -3
  107. data/lib/decision_agent/simulation/scenario_engine.rb +3 -1
  108. data/lib/decision_agent/simulation/scenario_library.rb +2 -0
  109. data/lib/decision_agent/simulation/shadow_test_engine.rb +3 -16
  110. data/lib/decision_agent/simulation/what_if_analyzer.rb +17 -13
  111. data/lib/decision_agent/simulation.rb +2 -0
  112. data/lib/decision_agent/testing/batch_test_importer.rb +6 -6
  113. data/lib/decision_agent/testing/batch_test_runner.rb +5 -4
  114. data/lib/decision_agent/testing/test_coverage_analyzer.rb +2 -0
  115. data/lib/decision_agent/testing/test_result_comparator.rb +56 -63
  116. data/lib/decision_agent/testing/test_scenario.rb +2 -0
  117. data/lib/decision_agent/version.rb +3 -1
  118. data/lib/decision_agent/versioning/activerecord_adapter.rb +159 -47
  119. data/lib/decision_agent/versioning/adapter.rb +42 -0
  120. data/lib/decision_agent/versioning/file_storage_adapter.rb +96 -13
  121. data/lib/decision_agent/versioning/version_manager.rb +49 -2
  122. data/lib/decision_agent/web/dmn_editor/serialization.rb +74 -0
  123. data/lib/decision_agent/web/dmn_editor/xml_builder.rb +107 -0
  124. data/lib/decision_agent/web/dmn_editor.rb +8 -73
  125. data/lib/decision_agent/web/middleware/auth_middleware.rb +2 -0
  126. data/lib/decision_agent/web/middleware/permission_middleware.rb +3 -1
  127. data/lib/decision_agent/web/public/app.js +67 -26
  128. data/lib/decision_agent/web/public/batch_testing.html +80 -6
  129. data/lib/decision_agent/web/public/dmn-editor.css +0 -0
  130. data/lib/decision_agent/web/public/dmn-editor.html +2 -2
  131. data/lib/decision_agent/web/public/dmn-editor.js +79 -8
  132. data/lib/decision_agent/web/public/index.html +20 -3
  133. data/lib/decision_agent/web/public/login.html +1 -1
  134. data/lib/decision_agent/web/public/sample_batch.csv +11 -0
  135. data/lib/decision_agent/web/public/sample_impact.csv +11 -0
  136. data/lib/decision_agent/web/public/sample_replay.csv +11 -0
  137. data/lib/decision_agent/web/public/sample_rules.json +118 -0
  138. data/lib/decision_agent/web/public/sample_shadow.csv +11 -0
  139. data/lib/decision_agent/web/public/sample_whatif.csv +11 -0
  140. data/lib/decision_agent/web/public/simulation.html +23 -7
  141. data/lib/decision_agent/web/public/simulation_impact.html +37 -20
  142. data/lib/decision_agent/web/public/simulation_replay.html +19 -23
  143. data/lib/decision_agent/web/public/simulation_shadow.html +36 -21
  144. data/lib/decision_agent/web/public/simulation_whatif.html +38 -21
  145. data/lib/decision_agent/web/public/styles.css +0 -0
  146. data/lib/decision_agent/web/public/users.html +1 -1
  147. data/lib/decision_agent/web/rack_helpers.rb +106 -0
  148. data/lib/decision_agent/web/rack_request_helpers.rb +196 -0
  149. data/lib/decision_agent/web/server.rb +2038 -1851
  150. data/lib/decision_agent.rb +3 -43
  151. data/lib/generators/decision_agent/install/install_generator.rb +2 -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 +2 -0
  154. data/lib/generators/decision_agent/install/templates/ab_test_model.rb +2 -0
  155. data/lib/generators/decision_agent/install/templates/ab_testing_migration.rb +2 -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 +16 -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 +2 -0
  165. data/lib/generators/decision_agent/install/templates/rule_version.rb +2 -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 +66 -25
  171. data/lib/decision_agent/data_enrichment/cache/memory_adapter.rb +0 -86
  172. data/lib/decision_agent/data_enrichment/cache_adapter.rb +0 -49
  173. data/lib/decision_agent/data_enrichment/circuit_breaker.rb +0 -135
  174. data/lib/decision_agent/data_enrichment/client.rb +0 -220
  175. data/lib/decision_agent/data_enrichment/config.rb +0 -78
  176. data/lib/decision_agent/data_enrichment/errors.rb +0 -36
@@ -1,9 +1,11 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module DecisionAgent
2
4
  # Semantic version: MAJOR.MINOR.PATCH
3
5
  # MAJOR: Incremented for incompatible API changes
4
6
  # MINOR: Incremented for backward-compatible functionality additions
5
7
  # PATCH: Incremented for backward-compatible bug fixes
6
- VERSION = "1.0.1".freeze
8
+ VERSION = "1.2.0"
7
9
 
8
10
  # Validate version format (semantic versioning)
9
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/)
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative "adapter"
2
4
  require_relative "file_storage_adapter"
3
5
 
@@ -16,40 +18,43 @@ module DecisionAgent
16
18
  end
17
19
 
18
20
  def create_version(rule_id:, content:, metadata: {})
19
- # Use a transaction with pessimistic locking to prevent race conditions
20
- version = nil
21
-
22
21
  # Validate status if provided
23
22
  status = metadata[:status] || "active"
24
23
  validate_status!(status)
25
24
 
26
- rule_version_class.transaction do
27
- # Lock the last version for this rule to prevent concurrent reads
28
- # This ensures only one thread can calculate the next version number at a time
29
- last_version = rule_version_class.where(rule_id: rule_id)
30
- .order(version_number: :desc)
31
- .lock
32
- .first
33
- next_version_number = last_version ? last_version.version_number + 1 : 1
34
-
35
- # Deactivate previous active versions
36
- # Use update_all for better concurrency (avoids SQLite locking issues)
37
- # Status "archived" is valid, so no need to trigger validations
38
- rule_version_class.where(rule_id: rule_id, status: "active")
39
- .update_all(status: "archived")
40
-
41
- # Create new version
42
- version = rule_version_class.create!(
43
- rule_id: rule_id,
44
- version_number: next_version_number,
45
- content: content.to_json,
46
- created_by: metadata[:created_by] || "system",
47
- changelog: metadata[:changelog] || "Version #{next_version_number}",
48
- status: status
49
- )
50
- end
25
+ # Retry on SQLite busy exceptions (common with concurrent operations)
26
+ retry_with_backoff(max_retries: 10) do
27
+ # Use a transaction with pessimistic locking to prevent race conditions
28
+ version = nil
29
+
30
+ rule_version_class.transaction do
31
+ # Lock the last version for this rule to prevent concurrent reads
32
+ # This ensures only one thread can calculate the next version number at a time
33
+ last_version = rule_version_class.where(rule_id: rule_id)
34
+ .order(version_number: :desc)
35
+ .lock
36
+ .first
37
+ next_version_number = last_version ? last_version.version_number + 1 : 1
38
+
39
+ # Deactivate previous active versions
40
+ # Use update_all for better concurrency (avoids SQLite locking issues)
41
+ # Status "archived" is valid, so no need to trigger validations
42
+ rule_version_class.where(rule_id: rule_id, status: "active")
43
+ .update_all(status: "archived")
44
+
45
+ # Create new version
46
+ version = rule_version_class.create!(
47
+ rule_id: rule_id,
48
+ version_number: next_version_number,
49
+ content: content.to_json,
50
+ created_by: metadata[:created_by] || "system",
51
+ changelog: metadata[:changelog] || "Version #{next_version_number}",
52
+ status: status
53
+ )
54
+ end
51
55
 
52
- serialize_version(version)
56
+ serialize_version(version)
57
+ end
53
58
  end
54
59
 
55
60
  def list_versions(rule_id:, limit: nil)
@@ -60,6 +65,13 @@ module DecisionAgent
60
65
  query.map { |v| serialize_version(v) }
61
66
  end
62
67
 
68
+ def list_all_versions(limit: nil)
69
+ query = rule_version_class.order(created_at: :desc)
70
+ query = query.limit(limit) if limit
71
+
72
+ query.map { |v| serialize_version(v) }
73
+ end
74
+
63
75
  def get_version(version_id:)
64
76
  version = rule_version_class.find_by(id: version_id)
65
77
  version ? serialize_version(version) : nil
@@ -79,25 +91,28 @@ module DecisionAgent
79
91
  end
80
92
 
81
93
  def activate_version(version_id:)
82
- version = nil
83
-
84
- rule_version_class.transaction do
85
- # Find and lock the version to activate
86
- version = rule_version_class.lock.find(version_id)
87
-
88
- # Deactivate all other versions for this rule within the same transaction
89
- # The lock ensures only one thread can perform this operation at a time
90
- # Use update_all for better concurrency (avoids SQLite locking issues)
91
- # Status "archived" is valid, so no need to trigger validations
92
- rule_version_class.where(rule_id: version.rule_id, status: "active")
93
- .where.not(id: version_id)
94
- .update_all(status: "archived")
95
-
96
- # Activate this version
97
- version.update!(status: "active")
98
- end
94
+ # Retry on SQLite busy exceptions (common with concurrent operations)
95
+ retry_with_backoff(max_retries: 10) do
96
+ version = nil
97
+
98
+ rule_version_class.transaction do
99
+ # Find and lock the version to activate
100
+ version = rule_version_class.lock.find(version_id)
99
101
 
100
- serialize_version(version)
102
+ # Deactivate all other versions for this rule within the same transaction
103
+ # The lock ensures only one thread can perform this operation at a time
104
+ # Use update_all for better concurrency (avoids SQLite locking issues)
105
+ # Status "archived" is valid, so no need to trigger validations
106
+ rule_version_class.where(rule_id: version.rule_id, status: "active")
107
+ .where.not(id: version_id)
108
+ .update_all(status: "archived")
109
+
110
+ # Activate this version
111
+ version.update!(status: "active")
112
+ end
113
+
114
+ serialize_version(version)
115
+ end
101
116
  end
102
117
 
103
118
  def delete_version(version_id:)
@@ -116,6 +131,47 @@ module DecisionAgent
116
131
  raise DecisionAgent::NotFoundError, "Version not found: #{version_id}"
117
132
  end
118
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
+
119
175
  private
120
176
 
121
177
  def rule_version_class
@@ -128,6 +184,54 @@ module DecisionAgent
128
184
  end
129
185
  end
130
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
+
196
+ # Retry database operations that may encounter SQLite busy exceptions
197
+ # This is especially important for concurrent operations on different rules
198
+ def retry_with_backoff(max_retries: 10, base_delay: 0.01)
199
+ retries = 0
200
+ begin
201
+ yield
202
+ rescue ActiveRecord::StatementInvalid => e
203
+ # Check if it's a SQLite busy exception
204
+ # Handle different SQLite adapter implementations
205
+ is_busy = begin
206
+ # Check the underlying exception type
207
+ cause = e.cause
208
+ if cause
209
+ cause.class.name.include?("BusyException") ||
210
+ cause.class.name.include?("SQLite3::BusyException") ||
211
+ cause.class.name.include?("LockedException") ||
212
+ cause.class.name.include?("SQLite3::LockedException")
213
+ else
214
+ false
215
+ end
216
+ rescue StandardError => cause_check_error
217
+ warn "[DecisionAgent] Error checking busy exception cause: #{cause_check_error.message}"
218
+ false
219
+ end || e.message.include?("database is locked") ||
220
+ e.message.include?("database table is locked") ||
221
+ e.message.include?("SQLite3::BusyException") ||
222
+ e.message.include?("BusyException") ||
223
+ e.message.include?("LockedException")
224
+
225
+ raise unless is_busy && retries < max_retries
226
+
227
+ retries += 1
228
+ # Exponential backoff with jitter
229
+ delay = (base_delay * (2**retries)) + (rand * base_delay)
230
+ sleep(delay)
231
+ retry
232
+ end
233
+ end
234
+
131
235
  def serialize_version(version)
132
236
  # Parse JSON content with proper error handling
133
237
  parsed_content = begin
@@ -151,6 +255,14 @@ module DecisionAgent
151
255
  status: version.status
152
256
  }
153
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
154
266
  end
155
267
  end
156
268
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module DecisionAgent
2
4
  module Versioning
3
5
  # Abstract base class for version storage adapters
@@ -20,6 +22,13 @@ module DecisionAgent
20
22
  raise NotImplementedError, "#{self.class} must implement #list_versions"
21
23
  end
22
24
 
25
+ # List all versions across all rules
26
+ # @param limit [Integer, nil] Optional limit for number of versions
27
+ # @return [Array<Hash>] Array of version hashes
28
+ def list_all_versions(limit: nil)
29
+ raise NotImplementedError, "#{self.class} must implement #list_all_versions"
30
+ end
31
+
23
32
  # Get a specific version by ID
24
33
  # @param version_id [String, Integer] The version identifier
25
34
  # @return [Hash, nil] The version hash or nil if not found
@@ -73,6 +82,39 @@ module DecisionAgent
73
82
  raise NotImplementedError, "#{self.class} must implement #delete_version"
74
83
  end
75
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
+
76
118
  private
77
119
 
78
120
  # Calculate differences between two content hashes
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative "adapter"
2
4
  require "json"
3
5
  require "fileutils"
@@ -86,12 +88,20 @@ module DecisionAgent
86
88
  end
87
89
  end
88
90
 
91
+ def list_all_versions(limit: nil)
92
+ @version_index_lock.synchronize do
93
+ versions = all_versions_unsafe
94
+ limit ? versions.take(limit) : versions
95
+ end
96
+ end
97
+
89
98
  def get_version(version_id:)
90
99
  # Use index to find rule_id quickly - O(1) instead of O(n)
91
100
  begin
92
101
  rule_id = get_rule_id_from_index(version_id)
93
- rescue StandardError
102
+ rescue StandardError => e
94
103
  # If index lookup fails, version doesn't exist
104
+ warn "[DecisionAgent] Version index lookup failed for '#{version_id}': #{e.message}"
95
105
  return nil
96
106
  end
97
107
  return nil unless rule_id
@@ -103,8 +113,9 @@ module DecisionAgent
103
113
  versions = list_versions_unsafe(rule_id: rule_id)
104
114
  versions.find { |v| v[:id] == version_id }
105
115
  end
106
- rescue StandardError
116
+ rescue StandardError => e
107
117
  # If any error occurs during lookup, treat as version not found
118
+ warn "[DecisionAgent] Version lookup failed for '#{version_id}': #{e.message}"
108
119
  nil
109
120
  end
110
121
  end
@@ -148,12 +159,52 @@ module DecisionAgent
148
159
  end
149
160
  end
150
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
+
151
201
  def delete_version(version_id:)
152
202
  # Use index to find rule_id quickly - O(1) instead of O(n)
153
203
  begin
154
204
  rule_id = get_rule_id_from_index(version_id)
155
- rescue StandardError
205
+ rescue StandardError => e
156
206
  # If index lookup fails, version doesn't exist
207
+ warn "[DecisionAgent] Version index lookup failed for '#{version_id}': #{e.message}"
157
208
  raise DecisionAgent::NotFoundError, "Version not found: #{version_id}"
158
209
  end
159
210
 
@@ -218,15 +269,16 @@ module DecisionAgent
218
269
  rescue DecisionAgent::ValidationError, DecisionAgent::NotFoundError
219
270
  # Re-raise expected errors
220
271
  raise
221
- rescue StandardError
272
+ rescue StandardError => e
222
273
  # If any unexpected error occurs during the lock operation, treat as version not found
223
274
  # This prevents 500 errors from propagating when version doesn't exist or is in an invalid state
224
275
  # This handles ThreadError (deadlocks, recursive locks), SystemCallError (file system issues), etc.
225
276
  # This is safe because if the version existed and was valid, we would have found it above
277
+ warn "[DecisionAgent] Version delete lock operation failed for '#{version_id}': #{e.message}"
226
278
  begin
227
279
  remove_from_index(version_id)
228
- rescue StandardError
229
- nil
280
+ rescue StandardError => cleanup_error
281
+ warn "[DecisionAgent] Failed to clean up index for '#{version_id}': #{cleanup_error.message}"
230
282
  end
231
283
  raise DecisionAgent::NotFoundError, "Version not found: #{version_id}"
232
284
  end
@@ -240,11 +292,13 @@ module DecisionAgent
240
292
 
241
293
  return versions unless Dir.exist?(rule_dir)
242
294
 
243
- Dir.glob(File.join(rule_dir, "*.json")).each do |file|
244
- versions << JSON.parse(File.read(file), symbolize_names: true)
245
- rescue JSON::ParserError, Errno::ENOENT
246
- # Skip corrupted or deleted files
247
- 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
248
302
  end
249
303
 
250
304
  versions.sort_by! { |v| -v[:version_number] }
@@ -255,8 +309,10 @@ module DecisionAgent
255
309
  versions = []
256
310
  return versions unless Dir.exist?(@storage_path)
257
311
 
258
- Dir.glob(File.join(@storage_path, "*", "*.json")).each do |file|
259
- 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)
260
316
  end
261
317
 
262
318
  versions
@@ -353,6 +409,33 @@ module DecisionAgent
353
409
  @version_index.delete(version_id)
354
410
  end
355
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
356
439
  end
357
440
  end
358
441
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module DecisionAgent
2
4
  module Versioning
3
5
  # High-level service for managing rule versions
@@ -16,8 +18,9 @@ module DecisionAgent
16
18
  # @param rule_content [Hash] The rule definition
17
19
  # @param created_by [String] User who created this version
18
20
  # @param changelog [String] Description of changes
21
+ # @param tag [String, nil] Optional tag name to apply to the new version at creation time
19
22
  # @return [Hash] The created version
20
- 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)
21
24
  validate_rule_content!(rule_content)
22
25
 
23
26
  metadata = {
@@ -25,11 +28,15 @@ module DecisionAgent
25
28
  changelog: changelog || generate_default_changelog(rule_id)
26
29
  }
27
30
 
28
- @adapter.create_version(
31
+ version = @adapter.create_version(
29
32
  rule_id: rule_id,
30
33
  content: rule_content,
31
34
  metadata: metadata
32
35
  )
36
+
37
+ tag!(rule_id, version[:id], tag) if tag
38
+
39
+ version
33
40
  end
34
41
 
35
42
  # Get all versions for a rule
@@ -40,6 +47,13 @@ module DecisionAgent
40
47
  @adapter.list_versions(rule_id: rule_id, limit: limit)
41
48
  end
42
49
 
50
+ # Get all versions across all rules
51
+ # @param limit [Integer, nil] Optional limit
52
+ # @return [Array<Hash>] Array of versions
53
+ def list_all_versions(limit: nil)
54
+ @adapter.list_all_versions(limit: limit)
55
+ end
56
+
43
57
  # Get a specific version
44
58
  # @param version_id [String, Integer] The version identifier
45
59
  # @return [Hash, nil] The version or nil
@@ -98,6 +112,39 @@ module DecisionAgent
98
112
  @adapter.delete_version(version_id: version_id)
99
113
  end
100
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
+
101
148
  private
102
149
 
103
150
  def default_adapter
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DecisionAgent
4
+ module Web
5
+ class DmnEditor
6
+ module Serialization
7
+ def serialize_model(model)
8
+ {
9
+ id: model.id,
10
+ name: model.name,
11
+ namespace: model.namespace,
12
+ decisions: model.decisions.map { |d| serialize_decision(d) }
13
+ }
14
+ end
15
+
16
+ def serialize_decision(decision)
17
+ result = {
18
+ id: decision.id,
19
+ name: decision.name
20
+ }
21
+
22
+ if decision.decision_table
23
+ result[:decision_table] = serialize_decision_table(decision.decision_table)
24
+ elsif decision.decision_tree
25
+ result[:decision_tree] = decision.decision_tree.to_h
26
+ elsif decision.instance_variable_get(:@literal_expression)
27
+ result[:literal_expression] = decision.instance_variable_get(:@literal_expression)
28
+ end
29
+
30
+ result[:information_requirements] = decision.information_requirements if decision.information_requirements.any?
31
+
32
+ result
33
+ end
34
+
35
+ def serialize_decision_table(table)
36
+ {
37
+ id: table.id,
38
+ hit_policy: table.hit_policy,
39
+ inputs: table.inputs.map { |i| serialize_input(i) },
40
+ outputs: table.outputs.map { |o| serialize_output(o) },
41
+ rules: table.rules.map { |r| serialize_rule(r) }
42
+ }
43
+ end
44
+
45
+ def serialize_input(input)
46
+ {
47
+ id: input.id,
48
+ label: input.label,
49
+ type_ref: input.type_ref,
50
+ expression: input.expression
51
+ }
52
+ end
53
+
54
+ def serialize_output(output)
55
+ {
56
+ id: output.id,
57
+ label: output.label,
58
+ type_ref: output.type_ref,
59
+ name: output.name
60
+ }
61
+ end
62
+
63
+ def serialize_rule(rule)
64
+ {
65
+ id: rule.id,
66
+ input_entries: rule.input_entries,
67
+ output_entries: rule.output_entries,
68
+ description: rule.description
69
+ }
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end