decision_agent 0.1.2 → 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.
- checksums.yaml +4 -4
- data/README.md +212 -35
- data/bin/decision_agent +3 -8
- data/lib/decision_agent/agent.rb +19 -26
- data/lib/decision_agent/audit/null_adapter.rb +1 -2
- data/lib/decision_agent/decision.rb +3 -1
- data/lib/decision_agent/dsl/condition_evaluator.rb +4 -3
- data/lib/decision_agent/dsl/rule_parser.rb +4 -6
- data/lib/decision_agent/dsl/schema_validator.rb +27 -31
- data/lib/decision_agent/errors.rb +11 -8
- data/lib/decision_agent/evaluation.rb +3 -1
- data/lib/decision_agent/evaluation_validator.rb +78 -0
- data/lib/decision_agent/evaluators/json_rule_evaluator.rb +26 -0
- data/lib/decision_agent/evaluators/static_evaluator.rb +2 -6
- data/lib/decision_agent/monitoring/alert_manager.rb +282 -0
- data/lib/decision_agent/monitoring/dashboard/public/dashboard.css +381 -0
- data/lib/decision_agent/monitoring/dashboard/public/dashboard.js +471 -0
- data/lib/decision_agent/monitoring/dashboard/public/index.html +161 -0
- data/lib/decision_agent/monitoring/dashboard_server.rb +340 -0
- data/lib/decision_agent/monitoring/metrics_collector.rb +278 -0
- data/lib/decision_agent/monitoring/monitored_agent.rb +71 -0
- data/lib/decision_agent/monitoring/prometheus_exporter.rb +247 -0
- data/lib/decision_agent/replay/replay.rb +12 -22
- data/lib/decision_agent/scoring/base.rb +1 -1
- data/lib/decision_agent/scoring/consensus.rb +5 -5
- data/lib/decision_agent/scoring/weighted_average.rb +1 -1
- data/lib/decision_agent/version.rb +1 -1
- data/lib/decision_agent/versioning/activerecord_adapter.rb +69 -33
- data/lib/decision_agent/versioning/adapter.rb +1 -3
- data/lib/decision_agent/versioning/file_storage_adapter.rb +143 -35
- data/lib/decision_agent/versioning/version_manager.rb +4 -12
- data/lib/decision_agent/web/public/index.html +1 -1
- data/lib/decision_agent/web/server.rb +19 -24
- data/lib/decision_agent.rb +7 -0
- data/lib/generators/decision_agent/install/install_generator.rb +5 -5
- data/lib/generators/decision_agent/install/templates/migration.rb +17 -6
- data/lib/generators/decision_agent/install/templates/rule.rb +3 -3
- data/lib/generators/decision_agent/install/templates/rule_version.rb +13 -7
- data/spec/activerecord_thread_safety_spec.rb +553 -0
- data/spec/agent_spec.rb +13 -13
- data/spec/api_contract_spec.rb +16 -16
- data/spec/audit_adapters_spec.rb +3 -3
- data/spec/comprehensive_edge_cases_spec.rb +86 -86
- data/spec/dsl_validation_spec.rb +83 -83
- data/spec/edge_cases_spec.rb +23 -23
- data/spec/examples/feedback_aware_evaluator_spec.rb +7 -7
- data/spec/examples.txt +548 -0
- data/spec/issue_verification_spec.rb +685 -0
- data/spec/json_rule_evaluator_spec.rb +15 -15
- data/spec/monitoring/alert_manager_spec.rb +378 -0
- data/spec/monitoring/metrics_collector_spec.rb +281 -0
- data/spec/monitoring/monitored_agent_spec.rb +222 -0
- data/spec/monitoring/prometheus_exporter_spec.rb +242 -0
- data/spec/replay_edge_cases_spec.rb +58 -58
- data/spec/replay_spec.rb +11 -11
- data/spec/rfc8785_canonicalization_spec.rb +215 -0
- data/spec/scoring_spec.rb +1 -1
- data/spec/spec_helper.rb +9 -0
- data/spec/thread_safety_spec.rb +482 -0
- data/spec/thread_safety_spec.rb.broken +878 -0
- data/spec/versioning_spec.rb +141 -37
- data/spec/web_ui_rack_spec.rb +135 -0
- metadata +69 -6
|
@@ -4,21 +4,41 @@ require "fileutils"
|
|
|
4
4
|
|
|
5
5
|
module DecisionAgent
|
|
6
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
|
+
|
|
7
19
|
# File-based version storage adapter for non-Rails applications
|
|
8
20
|
# Stores versions as JSON files in a directory structure
|
|
9
21
|
class FileStorageAdapter < Adapter
|
|
22
|
+
include StatusValidator
|
|
23
|
+
|
|
10
24
|
attr_reader :storage_path
|
|
11
25
|
|
|
12
26
|
# Initialize with a storage directory
|
|
13
27
|
# @param storage_path [String] Path to store version files (default: ./versions)
|
|
14
28
|
def initialize(storage_path: "./versions")
|
|
15
29
|
@storage_path = storage_path
|
|
16
|
-
|
|
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
|
|
17
36
|
FileUtils.mkdir_p(@storage_path)
|
|
37
|
+
load_version_index
|
|
18
38
|
end
|
|
19
39
|
|
|
20
40
|
def create_version(rule_id:, content:, metadata: {})
|
|
21
|
-
|
|
41
|
+
with_rule_lock(rule_id) do
|
|
22
42
|
create_version_unsafe(rule_id: rule_id, content: content, metadata: metadata)
|
|
23
43
|
end
|
|
24
44
|
end
|
|
@@ -27,14 +47,16 @@ module DecisionAgent
|
|
|
27
47
|
|
|
28
48
|
def create_version_unsafe(rule_id:, content:, metadata: {})
|
|
29
49
|
# Get the next version number
|
|
30
|
-
versions =
|
|
50
|
+
versions = list_versions_unsafe(rule_id: rule_id)
|
|
31
51
|
next_version_number = versions.empty? ? 1 : versions.first[:version_number] + 1
|
|
32
52
|
|
|
53
|
+
# Validate status if provided
|
|
54
|
+
status = metadata[:status] || "active"
|
|
55
|
+
validate_status!(status)
|
|
56
|
+
|
|
33
57
|
# Deactivate previous active versions
|
|
34
58
|
versions.each do |v|
|
|
35
|
-
if v[:status] == "active"
|
|
36
|
-
update_version_status(v[:id], "archived")
|
|
37
|
-
end
|
|
59
|
+
update_version_status_unsafe(v[:id], "archived", rule_id) if v[:status] == "active"
|
|
38
60
|
end
|
|
39
61
|
|
|
40
62
|
# Create version data
|
|
@@ -47,7 +69,7 @@ module DecisionAgent
|
|
|
47
69
|
created_by: metadata[:created_by] || "system",
|
|
48
70
|
created_at: Time.now.utc.iso8601,
|
|
49
71
|
changelog: metadata[:changelog] || "Version #{next_version_number}",
|
|
50
|
-
status:
|
|
72
|
+
status: status
|
|
51
73
|
}
|
|
52
74
|
|
|
53
75
|
# Write to file
|
|
@@ -59,43 +81,53 @@ module DecisionAgent
|
|
|
59
81
|
public
|
|
60
82
|
|
|
61
83
|
def list_versions(rule_id:, limit: nil)
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
return versions unless Dir.exist?(rule_dir)
|
|
66
|
-
|
|
67
|
-
Dir.glob(File.join(rule_dir, "*.json")).each do |file|
|
|
68
|
-
versions << JSON.parse(File.read(file), symbolize_names: true)
|
|
84
|
+
with_rule_lock(rule_id) do
|
|
85
|
+
list_versions_unsafe(rule_id: rule_id, limit: limit)
|
|
69
86
|
end
|
|
70
|
-
|
|
71
|
-
versions.sort_by! { |v| -v[:version_number] }
|
|
72
|
-
limit ? versions.take(limit) : versions
|
|
73
87
|
end
|
|
74
88
|
|
|
75
89
|
def get_version(version_id:)
|
|
76
|
-
|
|
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
|
|
77
100
|
end
|
|
78
101
|
|
|
79
102
|
def get_version_by_number(rule_id:, version_number:)
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
82
107
|
end
|
|
83
108
|
|
|
84
109
|
def get_active_version(rule_id:)
|
|
85
|
-
|
|
86
|
-
|
|
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
|
|
87
114
|
end
|
|
88
115
|
|
|
89
116
|
def activate_version(version_id:)
|
|
90
|
-
|
|
91
|
-
|
|
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 }
|
|
92
126
|
raise DecisionAgent::NotFoundError, "Version not found: #{version_id}" unless version
|
|
93
127
|
|
|
94
128
|
# Deactivate all other versions for this rule
|
|
95
|
-
|
|
96
|
-
if v[:id] != version_id && v[:status] == "active"
|
|
97
|
-
update_version_status(v[:id], "archived")
|
|
98
|
-
end
|
|
129
|
+
versions.each do |v|
|
|
130
|
+
update_version_status_unsafe(v[:id], "archived", rule_id) if v[:id] != version_id && v[:status] == "active"
|
|
99
131
|
end
|
|
100
132
|
|
|
101
133
|
# Activate this version
|
|
@@ -107,8 +139,15 @@ module DecisionAgent
|
|
|
107
139
|
end
|
|
108
140
|
|
|
109
141
|
def delete_version(version_id:)
|
|
110
|
-
|
|
111
|
-
|
|
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 }
|
|
112
151
|
raise DecisionAgent::NotFoundError, "Version not found: #{version_id}" unless version
|
|
113
152
|
|
|
114
153
|
# Prevent deletion of active versions
|
|
@@ -117,12 +156,14 @@ module DecisionAgent
|
|
|
117
156
|
end
|
|
118
157
|
|
|
119
158
|
# Delete the file
|
|
120
|
-
rule_dir = File.join(@storage_path, sanitize_filename(
|
|
159
|
+
rule_dir = File.join(@storage_path, sanitize_filename(rule_id))
|
|
121
160
|
filename = "#{version[:version_number]}.json"
|
|
122
161
|
filepath = File.join(rule_dir, filename)
|
|
123
162
|
|
|
124
163
|
if File.exist?(filepath)
|
|
125
164
|
File.delete(filepath)
|
|
165
|
+
# Remove from index
|
|
166
|
+
remove_from_index(version_id)
|
|
126
167
|
true
|
|
127
168
|
else
|
|
128
169
|
false
|
|
@@ -132,7 +173,21 @@ module DecisionAgent
|
|
|
132
173
|
|
|
133
174
|
private
|
|
134
175
|
|
|
135
|
-
def
|
|
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
|
|
136
191
|
versions = []
|
|
137
192
|
return versions unless Dir.exist?(@storage_path)
|
|
138
193
|
|
|
@@ -143,8 +198,17 @@ module DecisionAgent
|
|
|
143
198
|
versions
|
|
144
199
|
end
|
|
145
200
|
|
|
146
|
-
def
|
|
147
|
-
|
|
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 }
|
|
148
212
|
return unless version
|
|
149
213
|
|
|
150
214
|
version[:status] = status
|
|
@@ -164,9 +228,11 @@ module DecisionAgent
|
|
|
164
228
|
begin
|
|
165
229
|
File.write(temp_file, JSON.pretty_generate(version))
|
|
166
230
|
File.rename(temp_file, filepath)
|
|
231
|
+
# Update index after successful write
|
|
232
|
+
add_to_index(version[:id], version[:rule_id])
|
|
167
233
|
ensure
|
|
168
234
|
# Clean up temp file if rename failed
|
|
169
|
-
|
|
235
|
+
FileUtils.rm_f(temp_file)
|
|
170
236
|
end
|
|
171
237
|
end
|
|
172
238
|
|
|
@@ -177,6 +243,48 @@ module DecisionAgent
|
|
|
177
243
|
def sanitize_filename(name)
|
|
178
244
|
name.to_s.gsub(/[^a-zA-Z0-9_-]/, "_")
|
|
179
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
|
|
180
288
|
end
|
|
181
289
|
end
|
|
182
290
|
end
|
|
@@ -54,22 +54,14 @@ module DecisionAgent
|
|
|
54
54
|
@adapter.get_active_version(rule_id: rule_id)
|
|
55
55
|
end
|
|
56
56
|
|
|
57
|
-
# Rollback to a previous version (activate it)
|
|
57
|
+
# Rollback to a previous version (activate it without creating a duplicate)
|
|
58
58
|
# @param version_id [String, Integer] The version to rollback to
|
|
59
59
|
# @param performed_by [String] User performing the rollback
|
|
60
60
|
# @return [Hash] The activated version
|
|
61
61
|
def rollback(version_id:, performed_by: "system")
|
|
62
|
-
version
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
save_version(
|
|
66
|
-
rule_id: version[:rule_id],
|
|
67
|
-
rule_content: version[:content],
|
|
68
|
-
created_by: performed_by,
|
|
69
|
-
changelog: "Rolled back to version #{version[:version_number]}"
|
|
70
|
-
)
|
|
71
|
-
|
|
72
|
-
version
|
|
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)
|
|
73
65
|
end
|
|
74
66
|
|
|
75
67
|
# Compare two versions
|
|
@@ -237,7 +237,7 @@
|
|
|
237
237
|
</div>
|
|
238
238
|
|
|
239
239
|
<footer class="footer">
|
|
240
|
-
<p>DecisionAgent Rule Builder | <a href="https://github.com/
|
|
240
|
+
<p>DecisionAgent Rule Builder | <a href="https://github.com/samaswin87/decision_agent" target="_blank">Documentation</a></p>
|
|
241
241
|
</footer>
|
|
242
242
|
|
|
243
243
|
<script src="app.js"></script>
|
|
@@ -43,14 +43,12 @@ module DecisionAgent
|
|
|
43
43
|
valid: true,
|
|
44
44
|
message: "Rules are valid!"
|
|
45
45
|
}.to_json
|
|
46
|
-
|
|
47
46
|
rescue JSON::ParserError => e
|
|
48
47
|
status 400
|
|
49
48
|
{
|
|
50
49
|
valid: false,
|
|
51
50
|
errors: ["Invalid JSON: #{e.message}"]
|
|
52
51
|
}.to_json
|
|
53
|
-
|
|
54
52
|
rescue DecisionAgent::InvalidRuleDslError => e
|
|
55
53
|
# Validation failed
|
|
56
54
|
status 422
|
|
@@ -58,8 +56,7 @@ module DecisionAgent
|
|
|
58
56
|
valid: false,
|
|
59
57
|
errors: parse_validation_errors(e.message)
|
|
60
58
|
}.to_json
|
|
61
|
-
|
|
62
|
-
rescue => e
|
|
59
|
+
rescue StandardError => e
|
|
63
60
|
# Unexpected error
|
|
64
61
|
status 500
|
|
65
62
|
{
|
|
@@ -102,8 +99,7 @@ module DecisionAgent
|
|
|
102
99
|
message: "No rules matched the given context"
|
|
103
100
|
}.to_json
|
|
104
101
|
end
|
|
105
|
-
|
|
106
|
-
rescue => e
|
|
102
|
+
rescue StandardError => e
|
|
107
103
|
status 500
|
|
108
104
|
{
|
|
109
105
|
success: false,
|
|
@@ -136,7 +132,7 @@ module DecisionAgent
|
|
|
136
132
|
},
|
|
137
133
|
{
|
|
138
134
|
id: "high_amount_review",
|
|
139
|
-
if: { field: "amount", op: "gte", value:
|
|
135
|
+
if: { field: "amount", op: "gte", value: 10_000 },
|
|
140
136
|
then: { decision: "manual_review", weight: 0.9, reason: "High amount requires review" }
|
|
141
137
|
}
|
|
142
138
|
]
|
|
@@ -247,8 +243,7 @@ module DecisionAgent
|
|
|
247
243
|
|
|
248
244
|
status 201
|
|
249
245
|
version.to_json
|
|
250
|
-
|
|
251
|
-
rescue => e
|
|
246
|
+
rescue StandardError => e
|
|
252
247
|
status 500
|
|
253
248
|
{ error: e.message }.to_json
|
|
254
249
|
end
|
|
@@ -265,8 +260,7 @@ module DecisionAgent
|
|
|
265
260
|
versions = version_manager.get_versions(rule_id: rule_id, limit: limit)
|
|
266
261
|
|
|
267
262
|
versions.to_json
|
|
268
|
-
|
|
269
|
-
rescue => e
|
|
263
|
+
rescue StandardError => e
|
|
270
264
|
status 500
|
|
271
265
|
{ error: e.message }.to_json
|
|
272
266
|
end
|
|
@@ -281,8 +275,7 @@ module DecisionAgent
|
|
|
281
275
|
history = version_manager.get_history(rule_id: rule_id)
|
|
282
276
|
|
|
283
277
|
history.to_json
|
|
284
|
-
|
|
285
|
-
rescue => e
|
|
278
|
+
rescue StandardError => e
|
|
286
279
|
status 500
|
|
287
280
|
{ error: e.message }.to_json
|
|
288
281
|
end
|
|
@@ -302,8 +295,7 @@ module DecisionAgent
|
|
|
302
295
|
status 404
|
|
303
296
|
{ error: "Version not found" }.to_json
|
|
304
297
|
end
|
|
305
|
-
|
|
306
|
-
rescue => e
|
|
298
|
+
rescue StandardError => e
|
|
307
299
|
status 500
|
|
308
300
|
{ error: e.message }.to_json
|
|
309
301
|
end
|
|
@@ -325,8 +317,7 @@ module DecisionAgent
|
|
|
325
317
|
)
|
|
326
318
|
|
|
327
319
|
version.to_json
|
|
328
|
-
|
|
329
|
-
rescue => e
|
|
320
|
+
rescue StandardError => e
|
|
330
321
|
status 500
|
|
331
322
|
{ error: e.message }.to_json
|
|
332
323
|
end
|
|
@@ -351,8 +342,7 @@ module DecisionAgent
|
|
|
351
342
|
status 404
|
|
352
343
|
{ error: "One or both versions not found" }.to_json
|
|
353
344
|
end
|
|
354
|
-
|
|
355
|
-
rescue => e
|
|
345
|
+
rescue StandardError => e
|
|
356
346
|
status 500
|
|
357
347
|
{ error: e.message }.to_json
|
|
358
348
|
end
|
|
@@ -369,16 +359,13 @@ module DecisionAgent
|
|
|
369
359
|
|
|
370
360
|
status 200
|
|
371
361
|
{ success: true, message: "Version deleted successfully" }.to_json
|
|
372
|
-
|
|
373
362
|
rescue DecisionAgent::NotFoundError => e
|
|
374
363
|
status 404
|
|
375
364
|
{ error: e.message }.to_json
|
|
376
|
-
|
|
377
365
|
rescue DecisionAgent::ValidationError => e
|
|
378
366
|
status 422
|
|
379
367
|
{ error: e.message }.to_json
|
|
380
|
-
|
|
381
|
-
rescue => e
|
|
368
|
+
rescue StandardError => e
|
|
382
369
|
status 500
|
|
383
370
|
{ error: e.message }.to_json
|
|
384
371
|
end
|
|
@@ -409,12 +396,20 @@ module DecisionAgent
|
|
|
409
396
|
errors.empty? ? [error_message] : errors
|
|
410
397
|
end
|
|
411
398
|
|
|
412
|
-
# Class method to start the server
|
|
399
|
+
# Class method to start the server (for CLI usage)
|
|
413
400
|
def self.start!(port: 4567, host: "0.0.0.0")
|
|
414
401
|
set :port, port
|
|
415
402
|
set :bind, host
|
|
416
403
|
run!
|
|
417
404
|
end
|
|
405
|
+
|
|
406
|
+
# Rack interface for mounting in Rails/Rack apps
|
|
407
|
+
# Example:
|
|
408
|
+
# # config/routes.rb
|
|
409
|
+
# mount DecisionAgent::Web::Server, at: "/decision_agent"
|
|
410
|
+
def self.call(env)
|
|
411
|
+
new.call(env)
|
|
412
|
+
end
|
|
418
413
|
end
|
|
419
414
|
end
|
|
420
415
|
end
|
data/lib/decision_agent.rb
CHANGED
|
@@ -2,6 +2,7 @@ require_relative "decision_agent/version"
|
|
|
2
2
|
require_relative "decision_agent/errors"
|
|
3
3
|
require_relative "decision_agent/context"
|
|
4
4
|
require_relative "decision_agent/evaluation"
|
|
5
|
+
require_relative "decision_agent/evaluation_validator"
|
|
5
6
|
require_relative "decision_agent/decision"
|
|
6
7
|
require_relative "decision_agent/agent"
|
|
7
8
|
|
|
@@ -29,5 +30,11 @@ require_relative "decision_agent/versioning/adapter"
|
|
|
29
30
|
require_relative "decision_agent/versioning/file_storage_adapter"
|
|
30
31
|
require_relative "decision_agent/versioning/version_manager"
|
|
31
32
|
|
|
33
|
+
require_relative "decision_agent/monitoring/metrics_collector"
|
|
34
|
+
require_relative "decision_agent/monitoring/prometheus_exporter"
|
|
35
|
+
require_relative "decision_agent/monitoring/alert_manager"
|
|
36
|
+
require_relative "decision_agent/monitoring/monitored_agent"
|
|
37
|
+
# dashboard_server has additional dependencies (faye/websocket) - require it explicitly when needed
|
|
38
|
+
|
|
32
39
|
module DecisionAgent
|
|
33
40
|
end
|
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
require
|
|
2
|
-
require
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
require "rails/generators/migration"
|
|
3
3
|
|
|
4
4
|
module DecisionAgent
|
|
5
5
|
module Generators
|
|
6
6
|
class InstallGenerator < Rails::Generators::Base
|
|
7
7
|
include Rails::Generators::Migration
|
|
8
8
|
|
|
9
|
-
source_root File.expand_path(
|
|
9
|
+
source_root File.expand_path("templates", __dir__)
|
|
10
10
|
|
|
11
11
|
desc "Installs DecisionAgent models and migrations for Rails"
|
|
12
12
|
|
|
@@ -17,8 +17,8 @@ module DecisionAgent
|
|
|
17
17
|
|
|
18
18
|
def copy_migration
|
|
19
19
|
migration_template "migration.rb",
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
"db/migrate/create_decision_agent_tables.rb",
|
|
21
|
+
migration_version: migration_version
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
def copy_models
|
|
@@ -5,7 +5,7 @@ class CreateDecisionAgentTables < ActiveRecord::Migration[7.0]
|
|
|
5
5
|
t.string :rule_id, null: false, index: { unique: true }
|
|
6
6
|
t.string :ruleset, null: false
|
|
7
7
|
t.text :description
|
|
8
|
-
t.string :status, default:
|
|
8
|
+
t.string :status, default: "active"
|
|
9
9
|
t.timestamps
|
|
10
10
|
end
|
|
11
11
|
|
|
@@ -13,14 +13,25 @@ class CreateDecisionAgentTables < ActiveRecord::Migration[7.0]
|
|
|
13
13
|
create_table :rule_versions do |t|
|
|
14
14
|
t.string :rule_id, null: false, index: true
|
|
15
15
|
t.integer :version_number, null: false
|
|
16
|
-
t.text :content, null: false
|
|
17
|
-
t.string :created_by, null: false, default:
|
|
16
|
+
t.text :content, null: false # JSON rule definition
|
|
17
|
+
t.string :created_by, null: false, default: "system"
|
|
18
18
|
t.text :changelog
|
|
19
|
-
t.string :status, null: false, default:
|
|
19
|
+
t.string :status, null: false, default: "draft" # draft, active, archived
|
|
20
20
|
t.timestamps
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
# ✅ CRITICAL: Unique constraint prevents duplicate version numbers per rule
|
|
24
|
+
# This protects against race conditions in concurrent version creation
|
|
25
|
+
add_index :rule_versions, %i[rule_id version_number], unique: true
|
|
26
|
+
|
|
27
|
+
# Index for efficient queries by rule_id and status
|
|
28
|
+
add_index :rule_versions, %i[rule_id status]
|
|
29
|
+
|
|
30
|
+
# Optional: Partial unique index for PostgreSQL to enforce one active version per rule
|
|
31
|
+
# Uncomment if using PostgreSQL:
|
|
32
|
+
# add_index :rule_versions, [:rule_id, :status],
|
|
33
|
+
# unique: true,
|
|
34
|
+
# where: "status = 'active'",
|
|
35
|
+
# name: 'index_rule_versions_one_active_per_rule'
|
|
25
36
|
end
|
|
26
37
|
end
|
|
@@ -5,12 +5,12 @@ class Rule < ApplicationRecord
|
|
|
5
5
|
validates :ruleset, presence: true
|
|
6
6
|
validates :status, inclusion: { in: %w[active inactive archived] }
|
|
7
7
|
|
|
8
|
-
scope :active, -> { where(status:
|
|
8
|
+
scope :active, -> { where(status: "active") }
|
|
9
9
|
scope :by_ruleset, ->(ruleset) { where(ruleset: ruleset) }
|
|
10
10
|
|
|
11
11
|
# Get the active version for this rule
|
|
12
12
|
def active_version
|
|
13
|
-
rule_versions.find_by(status:
|
|
13
|
+
rule_versions.find_by(status: "active")
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
# Get all versions ordered by version number
|
|
@@ -19,7 +19,7 @@ class Rule < ApplicationRecord
|
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
# Create a new version
|
|
22
|
-
def create_version(content:, created_by:
|
|
22
|
+
def create_version(content:, created_by: "system", changelog: nil)
|
|
23
23
|
DecisionAgent::Versioning::VersionManager.new.save_version(
|
|
24
24
|
rule_id: rule_id,
|
|
25
25
|
rule_content: content,
|
|
@@ -7,7 +7,7 @@ class RuleVersion < ApplicationRecord
|
|
|
7
7
|
validates :status, inclusion: { in: %w[draft active archived] }
|
|
8
8
|
validates :created_by, presence: true
|
|
9
9
|
|
|
10
|
-
scope :active, -> { where(status:
|
|
10
|
+
scope :active, -> { where(status: "active") }
|
|
11
11
|
scope :for_rule, ->(rule_id) { where(rule_id: rule_id).order(version_number: :desc) }
|
|
12
12
|
scope :latest, -> { order(version_number: :desc).limit(1) }
|
|
13
13
|
|
|
@@ -29,12 +29,15 @@ class RuleVersion < ApplicationRecord
|
|
|
29
29
|
def activate!
|
|
30
30
|
transaction do
|
|
31
31
|
# Deactivate all other versions for this rule
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
# Use update! instead of update_all to trigger validations
|
|
33
|
+
self.class.where(rule_id: rule_id, status: "active")
|
|
34
|
+
.where.not(id: id)
|
|
35
|
+
.find_each do |v|
|
|
36
|
+
v.update!(status: "archived")
|
|
37
|
+
end
|
|
35
38
|
|
|
36
39
|
# Activate this version
|
|
37
|
-
update!(status:
|
|
40
|
+
update!(status: "active")
|
|
38
41
|
end
|
|
39
42
|
end
|
|
40
43
|
|
|
@@ -51,9 +54,12 @@ class RuleVersion < ApplicationRecord
|
|
|
51
54
|
def set_next_version_number
|
|
52
55
|
return if version_number.present?
|
|
53
56
|
|
|
57
|
+
# Use pessimistic locking to prevent race conditions when calculating version numbers
|
|
58
|
+
# Lock the last version record to ensure only one thread can read and increment at a time
|
|
54
59
|
last_version = self.class.where(rule_id: rule_id)
|
|
55
|
-
|
|
56
|
-
|
|
60
|
+
.order(version_number: :desc)
|
|
61
|
+
.lock
|
|
62
|
+
.first
|
|
57
63
|
|
|
58
64
|
self.version_number = last_version ? last_version.version_number + 1 : 1
|
|
59
65
|
end
|