decision_agent 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +234 -919
- data/bin/decision_agent +5 -5
- 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 +21 -6
- 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 +141 -0
- data/lib/decision_agent/versioning/adapter.rb +100 -0
- data/lib/decision_agent/versioning/file_storage_adapter.rb +290 -0
- data/lib/decision_agent/versioning/version_manager.rb +127 -0
- data/lib/decision_agent/web/public/app.js +318 -0
- data/lib/decision_agent/web/public/index.html +56 -1
- data/lib/decision_agent/web/public/styles.css +219 -0
- data/lib/decision_agent/web/server.rb +169 -9
- data/lib/decision_agent.rb +11 -0
- data/lib/generators/decision_agent/install/install_generator.rb +40 -0
- data/lib/generators/decision_agent/install/templates/README +47 -0
- data/lib/generators/decision_agent/install/templates/migration.rb +37 -0
- data/lib/generators/decision_agent/install/templates/rule.rb +30 -0
- data/lib/generators/decision_agent/install/templates/rule_version.rb +66 -0
- 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 +777 -0
- data/spec/web_ui_rack_spec.rb +135 -0
- metadata +84 -11
|
@@ -12,7 +12,7 @@ module DecisionAgent
|
|
|
12
12
|
# Enable CORS for API calls
|
|
13
13
|
before do
|
|
14
14
|
headers["Access-Control-Allow-Origin"] = "*"
|
|
15
|
-
headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"
|
|
15
|
+
headers["Access-Control-Allow-Methods"] = "GET, POST, DELETE, OPTIONS"
|
|
16
16
|
headers["Access-Control-Allow-Headers"] = "Content-Type"
|
|
17
17
|
end
|
|
18
18
|
|
|
@@ -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
|
]
|
|
@@ -223,8 +219,164 @@ module DecisionAgent
|
|
|
223
219
|
{ status: "ok", version: DecisionAgent::VERSION }.to_json
|
|
224
220
|
end
|
|
225
221
|
|
|
222
|
+
# Versioning API endpoints
|
|
223
|
+
|
|
224
|
+
# Create a new version
|
|
225
|
+
post "/api/versions" do
|
|
226
|
+
content_type :json
|
|
227
|
+
|
|
228
|
+
begin
|
|
229
|
+
request_body = request.body.read
|
|
230
|
+
data = JSON.parse(request_body)
|
|
231
|
+
|
|
232
|
+
rule_id = data["rule_id"]
|
|
233
|
+
rule_content = data["content"]
|
|
234
|
+
created_by = data["created_by"] || "system"
|
|
235
|
+
changelog = data["changelog"]
|
|
236
|
+
|
|
237
|
+
version = version_manager.save_version(
|
|
238
|
+
rule_id: rule_id,
|
|
239
|
+
rule_content: rule_content,
|
|
240
|
+
created_by: created_by,
|
|
241
|
+
changelog: changelog
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
status 201
|
|
245
|
+
version.to_json
|
|
246
|
+
rescue StandardError => e
|
|
247
|
+
status 500
|
|
248
|
+
{ error: e.message }.to_json
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# List all versions for a rule
|
|
253
|
+
get "/api/rules/:rule_id/versions" do
|
|
254
|
+
content_type :json
|
|
255
|
+
|
|
256
|
+
begin
|
|
257
|
+
rule_id = params[:rule_id]
|
|
258
|
+
limit = params[:limit]&.to_i
|
|
259
|
+
|
|
260
|
+
versions = version_manager.get_versions(rule_id: rule_id, limit: limit)
|
|
261
|
+
|
|
262
|
+
versions.to_json
|
|
263
|
+
rescue StandardError => e
|
|
264
|
+
status 500
|
|
265
|
+
{ error: e.message }.to_json
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# Get version history with metadata
|
|
270
|
+
get "/api/rules/:rule_id/history" do
|
|
271
|
+
content_type :json
|
|
272
|
+
|
|
273
|
+
begin
|
|
274
|
+
rule_id = params[:rule_id]
|
|
275
|
+
history = version_manager.get_history(rule_id: rule_id)
|
|
276
|
+
|
|
277
|
+
history.to_json
|
|
278
|
+
rescue StandardError => e
|
|
279
|
+
status 500
|
|
280
|
+
{ error: e.message }.to_json
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Get a specific version
|
|
285
|
+
get "/api/versions/:version_id" do
|
|
286
|
+
content_type :json
|
|
287
|
+
|
|
288
|
+
begin
|
|
289
|
+
version_id = params[:version_id]
|
|
290
|
+
version = version_manager.get_version(version_id: version_id)
|
|
291
|
+
|
|
292
|
+
if version
|
|
293
|
+
version.to_json
|
|
294
|
+
else
|
|
295
|
+
status 404
|
|
296
|
+
{ error: "Version not found" }.to_json
|
|
297
|
+
end
|
|
298
|
+
rescue StandardError => e
|
|
299
|
+
status 500
|
|
300
|
+
{ error: e.message }.to_json
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# Activate a version (rollback)
|
|
305
|
+
post "/api/versions/:version_id/activate" do
|
|
306
|
+
content_type :json
|
|
307
|
+
|
|
308
|
+
begin
|
|
309
|
+
version_id = params[:version_id]
|
|
310
|
+
request_body = request.body.read
|
|
311
|
+
data = request_body.empty? ? {} : JSON.parse(request_body)
|
|
312
|
+
performed_by = data["performed_by"] || "system"
|
|
313
|
+
|
|
314
|
+
version = version_manager.rollback(
|
|
315
|
+
version_id: version_id,
|
|
316
|
+
performed_by: performed_by
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
version.to_json
|
|
320
|
+
rescue StandardError => e
|
|
321
|
+
status 500
|
|
322
|
+
{ error: e.message }.to_json
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# Compare two versions
|
|
327
|
+
get "/api/versions/:version_id_1/compare/:version_id_2" do
|
|
328
|
+
content_type :json
|
|
329
|
+
|
|
330
|
+
begin
|
|
331
|
+
version_id_1 = params[:version_id_1]
|
|
332
|
+
version_id_2 = params[:version_id_2]
|
|
333
|
+
|
|
334
|
+
comparison = version_manager.compare(
|
|
335
|
+
version_id_1: version_id_1,
|
|
336
|
+
version_id_2: version_id_2
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
if comparison
|
|
340
|
+
comparison.to_json
|
|
341
|
+
else
|
|
342
|
+
status 404
|
|
343
|
+
{ error: "One or both versions not found" }.to_json
|
|
344
|
+
end
|
|
345
|
+
rescue StandardError => e
|
|
346
|
+
status 500
|
|
347
|
+
{ error: e.message }.to_json
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# Delete a version
|
|
352
|
+
delete "/api/versions/:version_id" do
|
|
353
|
+
content_type :json
|
|
354
|
+
|
|
355
|
+
begin
|
|
356
|
+
version_id = params[:version_id]
|
|
357
|
+
|
|
358
|
+
version_manager.delete_version(version_id: version_id)
|
|
359
|
+
|
|
360
|
+
status 200
|
|
361
|
+
{ success: true, message: "Version deleted successfully" }.to_json
|
|
362
|
+
rescue DecisionAgent::NotFoundError => e
|
|
363
|
+
status 404
|
|
364
|
+
{ error: e.message }.to_json
|
|
365
|
+
rescue DecisionAgent::ValidationError => e
|
|
366
|
+
status 422
|
|
367
|
+
{ error: e.message }.to_json
|
|
368
|
+
rescue StandardError => e
|
|
369
|
+
status 500
|
|
370
|
+
{ error: e.message }.to_json
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
|
|
226
374
|
private
|
|
227
375
|
|
|
376
|
+
def version_manager
|
|
377
|
+
@version_manager ||= DecisionAgent::Versioning::VersionManager.new
|
|
378
|
+
end
|
|
379
|
+
|
|
228
380
|
def parse_validation_errors(error_message)
|
|
229
381
|
# Extract individual errors from the formatted error message
|
|
230
382
|
errors = []
|
|
@@ -244,12 +396,20 @@ module DecisionAgent
|
|
|
244
396
|
errors.empty? ? [error_message] : errors
|
|
245
397
|
end
|
|
246
398
|
|
|
247
|
-
# Class method to start the server
|
|
399
|
+
# Class method to start the server (for CLI usage)
|
|
248
400
|
def self.start!(port: 4567, host: "0.0.0.0")
|
|
249
401
|
set :port, port
|
|
250
402
|
set :bind, host
|
|
251
403
|
run!
|
|
252
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
|
|
253
413
|
end
|
|
254
414
|
end
|
|
255
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
|
|
|
@@ -25,5 +26,15 @@ require_relative "decision_agent/audit/logger_adapter"
|
|
|
25
26
|
|
|
26
27
|
require_relative "decision_agent/replay/replay"
|
|
27
28
|
|
|
29
|
+
require_relative "decision_agent/versioning/adapter"
|
|
30
|
+
require_relative "decision_agent/versioning/file_storage_adapter"
|
|
31
|
+
require_relative "decision_agent/versioning/version_manager"
|
|
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
|
+
|
|
28
39
|
module DecisionAgent
|
|
29
40
|
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
require "rails/generators/migration"
|
|
3
|
+
|
|
4
|
+
module DecisionAgent
|
|
5
|
+
module Generators
|
|
6
|
+
class InstallGenerator < Rails::Generators::Base
|
|
7
|
+
include Rails::Generators::Migration
|
|
8
|
+
|
|
9
|
+
source_root File.expand_path("templates", __dir__)
|
|
10
|
+
|
|
11
|
+
desc "Installs DecisionAgent models and migrations for Rails"
|
|
12
|
+
|
|
13
|
+
def self.next_migration_number(dirname)
|
|
14
|
+
next_migration_number = current_migration_number(dirname) + 1
|
|
15
|
+
ActiveRecord::Migration.next_migration_number(next_migration_number)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def copy_migration
|
|
19
|
+
migration_template "migration.rb",
|
|
20
|
+
"db/migrate/create_decision_agent_tables.rb",
|
|
21
|
+
migration_version: migration_version
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def copy_models
|
|
25
|
+
copy_file "rule.rb", "app/models/rule.rb"
|
|
26
|
+
copy_file "rule_version.rb", "app/models/rule_version.rb"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def show_readme
|
|
30
|
+
readme "README" if behavior == :invoke
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def migration_version
|
|
36
|
+
"[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
===============================================================================
|
|
2
|
+
|
|
3
|
+
DecisionAgent has been installed!
|
|
4
|
+
|
|
5
|
+
Next steps:
|
|
6
|
+
|
|
7
|
+
1. Run the migrations:
|
|
8
|
+
|
|
9
|
+
rails db:migrate
|
|
10
|
+
|
|
11
|
+
2. The following models have been created:
|
|
12
|
+
- Rule (app/models/rule.rb)
|
|
13
|
+
- RuleVersion (app/models/rule_version.rb)
|
|
14
|
+
|
|
15
|
+
3. Start using the versioning system:
|
|
16
|
+
|
|
17
|
+
# Create a rule with a version
|
|
18
|
+
rule = Rule.create!(
|
|
19
|
+
rule_id: 'approval_rule_001',
|
|
20
|
+
ruleset: 'approval',
|
|
21
|
+
description: 'Approval decision rules'
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
# Create a version
|
|
25
|
+
rule.create_version(
|
|
26
|
+
content: { /* your rule JSON */ },
|
|
27
|
+
created_by: 'admin',
|
|
28
|
+
changelog: 'Initial version'
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# Or use the VersionManager directly
|
|
32
|
+
manager = DecisionAgent::Versioning::VersionManager.new
|
|
33
|
+
manager.save_version(
|
|
34
|
+
rule_id: 'approval_rule_001',
|
|
35
|
+
rule_content: { /* your rule JSON */ },
|
|
36
|
+
created_by: 'admin'
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
4. Optional: Mount the DecisionAgent routes in config/routes.rb:
|
|
40
|
+
|
|
41
|
+
# This is planned for a future release
|
|
42
|
+
# mount DecisionAgent::Engine => '/decision_agent'
|
|
43
|
+
|
|
44
|
+
For more information, visit:
|
|
45
|
+
https://github.com/samaswin87/decision_agent
|
|
46
|
+
|
|
47
|
+
===============================================================================
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
class CreateDecisionAgentTables < ActiveRecord::Migration[7.0]
|
|
2
|
+
def change
|
|
3
|
+
# Rules table
|
|
4
|
+
create_table :rules do |t|
|
|
5
|
+
t.string :rule_id, null: false, index: { unique: true }
|
|
6
|
+
t.string :ruleset, null: false
|
|
7
|
+
t.text :description
|
|
8
|
+
t.string :status, default: "active"
|
|
9
|
+
t.timestamps
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Rule versions table
|
|
13
|
+
create_table :rule_versions do |t|
|
|
14
|
+
t.string :rule_id, null: false, index: true
|
|
15
|
+
t.integer :version_number, null: false
|
|
16
|
+
t.text :content, null: false # JSON rule definition
|
|
17
|
+
t.string :created_by, null: false, default: "system"
|
|
18
|
+
t.text :changelog
|
|
19
|
+
t.string :status, null: false, default: "draft" # draft, active, archived
|
|
20
|
+
t.timestamps
|
|
21
|
+
end
|
|
22
|
+
|
|
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'
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
class Rule < ApplicationRecord
|
|
2
|
+
has_many :rule_versions, primary_key: :rule_id, foreign_key: :rule_id, dependent: :destroy
|
|
3
|
+
|
|
4
|
+
validates :rule_id, presence: true, uniqueness: true
|
|
5
|
+
validates :ruleset, presence: true
|
|
6
|
+
validates :status, inclusion: { in: %w[active inactive archived] }
|
|
7
|
+
|
|
8
|
+
scope :active, -> { where(status: "active") }
|
|
9
|
+
scope :by_ruleset, ->(ruleset) { where(ruleset: ruleset) }
|
|
10
|
+
|
|
11
|
+
# Get the active version for this rule
|
|
12
|
+
def active_version
|
|
13
|
+
rule_versions.find_by(status: "active")
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Get all versions ordered by version number
|
|
17
|
+
def versions
|
|
18
|
+
rule_versions.order(version_number: :desc)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Create a new version
|
|
22
|
+
def create_version(content:, created_by: "system", changelog: nil)
|
|
23
|
+
DecisionAgent::Versioning::VersionManager.new.save_version(
|
|
24
|
+
rule_id: rule_id,
|
|
25
|
+
rule_content: content,
|
|
26
|
+
created_by: created_by,
|
|
27
|
+
changelog: changelog
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
class RuleVersion < ApplicationRecord
|
|
2
|
+
belongs_to :rule, primary_key: :rule_id, foreign_key: :rule_id, optional: true
|
|
3
|
+
|
|
4
|
+
validates :rule_id, presence: true
|
|
5
|
+
validates :version_number, presence: true, uniqueness: { scope: :rule_id }
|
|
6
|
+
validates :content, presence: true
|
|
7
|
+
validates :status, inclusion: { in: %w[draft active archived] }
|
|
8
|
+
validates :created_by, presence: true
|
|
9
|
+
|
|
10
|
+
scope :active, -> { where(status: "active") }
|
|
11
|
+
scope :for_rule, ->(rule_id) { where(rule_id: rule_id).order(version_number: :desc) }
|
|
12
|
+
scope :latest, -> { order(version_number: :desc).limit(1) }
|
|
13
|
+
|
|
14
|
+
before_create :set_next_version_number
|
|
15
|
+
|
|
16
|
+
# Parse the JSON content
|
|
17
|
+
def parsed_content
|
|
18
|
+
JSON.parse(content, symbolize_names: true)
|
|
19
|
+
rescue JSON::ParserError
|
|
20
|
+
{}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Set content from a hash
|
|
24
|
+
def content_hash=(hash)
|
|
25
|
+
self.content = hash.to_json
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Activate this version (deactivates others)
|
|
29
|
+
def activate!
|
|
30
|
+
transaction do
|
|
31
|
+
# Deactivate all other versions for this rule
|
|
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
|
|
38
|
+
|
|
39
|
+
# Activate this version
|
|
40
|
+
update!(status: "active")
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Compare with another version
|
|
45
|
+
def compare_with(other_version)
|
|
46
|
+
DecisionAgent::Versioning::VersionManager.new.compare(
|
|
47
|
+
version_id_1: id,
|
|
48
|
+
version_id_2: other_version.id
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def set_next_version_number
|
|
55
|
+
return if version_number.present?
|
|
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
|
|
59
|
+
last_version = self.class.where(rule_id: rule_id)
|
|
60
|
+
.order(version_number: :desc)
|
|
61
|
+
.lock
|
|
62
|
+
.first
|
|
63
|
+
|
|
64
|
+
self.version_number = last_version ? last_version.version_number + 1 : 1
|
|
65
|
+
end
|
|
66
|
+
end
|