decision_agent 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +234 -919
  3. data/bin/decision_agent +5 -5
  4. data/lib/decision_agent/agent.rb +19 -26
  5. data/lib/decision_agent/audit/null_adapter.rb +1 -2
  6. data/lib/decision_agent/decision.rb +3 -1
  7. data/lib/decision_agent/dsl/condition_evaluator.rb +4 -3
  8. data/lib/decision_agent/dsl/rule_parser.rb +4 -6
  9. data/lib/decision_agent/dsl/schema_validator.rb +27 -31
  10. data/lib/decision_agent/errors.rb +21 -6
  11. data/lib/decision_agent/evaluation.rb +3 -1
  12. data/lib/decision_agent/evaluation_validator.rb +78 -0
  13. data/lib/decision_agent/evaluators/json_rule_evaluator.rb +26 -0
  14. data/lib/decision_agent/evaluators/static_evaluator.rb +2 -6
  15. data/lib/decision_agent/monitoring/alert_manager.rb +282 -0
  16. data/lib/decision_agent/monitoring/dashboard/public/dashboard.css +381 -0
  17. data/lib/decision_agent/monitoring/dashboard/public/dashboard.js +471 -0
  18. data/lib/decision_agent/monitoring/dashboard/public/index.html +161 -0
  19. data/lib/decision_agent/monitoring/dashboard_server.rb +340 -0
  20. data/lib/decision_agent/monitoring/metrics_collector.rb +278 -0
  21. data/lib/decision_agent/monitoring/monitored_agent.rb +71 -0
  22. data/lib/decision_agent/monitoring/prometheus_exporter.rb +247 -0
  23. data/lib/decision_agent/replay/replay.rb +12 -22
  24. data/lib/decision_agent/scoring/base.rb +1 -1
  25. data/lib/decision_agent/scoring/consensus.rb +5 -5
  26. data/lib/decision_agent/scoring/weighted_average.rb +1 -1
  27. data/lib/decision_agent/version.rb +1 -1
  28. data/lib/decision_agent/versioning/activerecord_adapter.rb +141 -0
  29. data/lib/decision_agent/versioning/adapter.rb +100 -0
  30. data/lib/decision_agent/versioning/file_storage_adapter.rb +290 -0
  31. data/lib/decision_agent/versioning/version_manager.rb +127 -0
  32. data/lib/decision_agent/web/public/app.js +318 -0
  33. data/lib/decision_agent/web/public/index.html +56 -1
  34. data/lib/decision_agent/web/public/styles.css +219 -0
  35. data/lib/decision_agent/web/server.rb +169 -9
  36. data/lib/decision_agent.rb +11 -0
  37. data/lib/generators/decision_agent/install/install_generator.rb +40 -0
  38. data/lib/generators/decision_agent/install/templates/README +47 -0
  39. data/lib/generators/decision_agent/install/templates/migration.rb +37 -0
  40. data/lib/generators/decision_agent/install/templates/rule.rb +30 -0
  41. data/lib/generators/decision_agent/install/templates/rule_version.rb +66 -0
  42. data/spec/activerecord_thread_safety_spec.rb +553 -0
  43. data/spec/agent_spec.rb +13 -13
  44. data/spec/api_contract_spec.rb +16 -16
  45. data/spec/audit_adapters_spec.rb +3 -3
  46. data/spec/comprehensive_edge_cases_spec.rb +86 -86
  47. data/spec/dsl_validation_spec.rb +83 -83
  48. data/spec/edge_cases_spec.rb +23 -23
  49. data/spec/examples/feedback_aware_evaluator_spec.rb +7 -7
  50. data/spec/examples.txt +548 -0
  51. data/spec/issue_verification_spec.rb +685 -0
  52. data/spec/json_rule_evaluator_spec.rb +15 -15
  53. data/spec/monitoring/alert_manager_spec.rb +378 -0
  54. data/spec/monitoring/metrics_collector_spec.rb +281 -0
  55. data/spec/monitoring/monitored_agent_spec.rb +222 -0
  56. data/spec/monitoring/prometheus_exporter_spec.rb +242 -0
  57. data/spec/replay_edge_cases_spec.rb +58 -58
  58. data/spec/replay_spec.rb +11 -11
  59. data/spec/rfc8785_canonicalization_spec.rb +215 -0
  60. data/spec/scoring_spec.rb +1 -1
  61. data/spec/spec_helper.rb +9 -0
  62. data/spec/thread_safety_spec.rb +482 -0
  63. data/spec/thread_safety_spec.rb.broken +878 -0
  64. data/spec/versioning_spec.rb +777 -0
  65. data/spec/web_ui_rack_spec.rb +135 -0
  66. metadata +84 -11
@@ -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: 10000 },
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
@@ -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