decision_agent 0.1.1 → 0.1.2

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.
data/bin/decision_agent CHANGED
@@ -29,6 +29,11 @@ def print_help
29
29
  end
30
30
 
31
31
  def start_web_ui(port = 4567)
32
+ # Ruby 4.0 compatibility: Puma expects Bundler::ORIGINAL_ENV which was removed
33
+ if defined?(Bundler) && !Bundler.const_defined?(:ORIGINAL_ENV)
34
+ Bundler.const_set(:ORIGINAL_ENV, ENV.to_h.dup)
35
+ end
36
+
32
37
  puts "🎯 Starting DecisionAgent Rule Builder..."
33
38
  puts "📍 Server: http://localhost:#{port}"
34
39
  puts "⚡️ Press Ctrl+C to stop"
@@ -59,4 +59,16 @@ module DecisionAgent
59
59
  super("Weight must be between 0.0 and 1.0, got: #{weight}")
60
60
  end
61
61
  end
62
+
63
+ class NotFoundError < Error
64
+ def initialize(message = "Resource not found")
65
+ super(message)
66
+ end
67
+ end
68
+
69
+ class ValidationError < Error
70
+ def initialize(message = "Validation failed")
71
+ super(message)
72
+ end
73
+ end
62
74
  end
@@ -1,3 +1,3 @@
1
1
  module DecisionAgent
2
- VERSION = "0.1.1"
2
+ VERSION = "0.1.2"
3
3
  end
@@ -0,0 +1,105 @@
1
+ require_relative "adapter"
2
+
3
+ module DecisionAgent
4
+ module Versioning
5
+ # ActiveRecord-based version storage adapter for Rails applications
6
+ # Requires ActiveRecord models to be set up in the Rails app
7
+ class ActiveRecordAdapter < Adapter
8
+ def initialize
9
+ unless defined?(ActiveRecord)
10
+ raise DecisionAgent::ConfigurationError,
11
+ "ActiveRecord is not available. Please ensure Rails/ActiveRecord is loaded."
12
+ end
13
+ end
14
+
15
+ def create_version(rule_id:, content:, metadata: {})
16
+ # Get the next version number for this rule
17
+ last_version = rule_version_class.where(rule_id: rule_id)
18
+ .order(version_number: :desc)
19
+ .first
20
+ next_version_number = last_version ? last_version.version_number + 1 : 1
21
+
22
+ # Deactivate previous active versions
23
+ rule_version_class.where(rule_id: rule_id, status: "active")
24
+ .update_all(status: "archived")
25
+
26
+ # Create new version
27
+ version = rule_version_class.create!(
28
+ rule_id: rule_id,
29
+ version_number: next_version_number,
30
+ content: content.to_json,
31
+ created_by: metadata[:created_by] || "system",
32
+ changelog: metadata[:changelog] || "Version #{next_version_number}",
33
+ status: metadata[:status] || "active"
34
+ )
35
+
36
+ serialize_version(version)
37
+ end
38
+
39
+ def list_versions(rule_id:, limit: nil)
40
+ query = rule_version_class.where(rule_id: rule_id)
41
+ .order(version_number: :desc)
42
+ query = query.limit(limit) if limit
43
+
44
+ query.map { |v| serialize_version(v) }
45
+ end
46
+
47
+ def get_version(version_id:)
48
+ version = rule_version_class.find_by(id: version_id)
49
+ version ? serialize_version(version) : nil
50
+ end
51
+
52
+ def get_version_by_number(rule_id:, version_number:)
53
+ version = rule_version_class.find_by(
54
+ rule_id: rule_id,
55
+ version_number: version_number
56
+ )
57
+ version ? serialize_version(version) : nil
58
+ end
59
+
60
+ def get_active_version(rule_id:)
61
+ version = rule_version_class.find_by(rule_id: rule_id, status: "active")
62
+ version ? serialize_version(version) : nil
63
+ end
64
+
65
+ def activate_version(version_id:)
66
+ version = rule_version_class.find(version_id)
67
+
68
+ # Deactivate all other versions for this rule
69
+ rule_version_class.where(rule_id: version.rule_id, status: "active")
70
+ .where.not(id: version_id)
71
+ .update_all(status: "archived")
72
+
73
+ # Activate this version
74
+ version.update!(status: "active")
75
+
76
+ serialize_version(version)
77
+ end
78
+
79
+ private
80
+
81
+ def rule_version_class
82
+ # Look for the RuleVersion model in the main app
83
+ if defined?(::RuleVersion)
84
+ ::RuleVersion
85
+ else
86
+ raise DecisionAgent::ConfigurationError,
87
+ "RuleVersion model not found. Please run the generator to create it."
88
+ end
89
+ end
90
+
91
+ def serialize_version(version)
92
+ {
93
+ id: version.id,
94
+ rule_id: version.rule_id,
95
+ version_number: version.version_number,
96
+ content: JSON.parse(version.content),
97
+ created_by: version.created_by,
98
+ created_at: version.created_at,
99
+ changelog: version.changelog,
100
+ status: version.status
101
+ }
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,102 @@
1
+ module DecisionAgent
2
+ module Versioning
3
+ # Abstract base class for version storage adapters
4
+ # Allows framework-agnostic versioning with pluggable storage backends
5
+ class Adapter
6
+ # Create a new version for a rule
7
+ # @param rule_id [String] Unique identifier for the rule
8
+ # @param content [Hash] Rule definition as a hash
9
+ # @param metadata [Hash] Additional metadata (created_by, changelog, etc.)
10
+ # @return [Hash] The created version
11
+ def create_version(rule_id:, content:, metadata: {})
12
+ raise NotImplementedError, "#{self.class} must implement #create_version"
13
+ end
14
+
15
+ # List all versions for a specific rule
16
+ # @param rule_id [String] The rule identifier
17
+ # @param limit [Integer, nil] Optional limit for number of versions
18
+ # @return [Array<Hash>] Array of version hashes
19
+ def list_versions(rule_id:, limit: nil)
20
+ raise NotImplementedError, "#{self.class} must implement #list_versions"
21
+ end
22
+
23
+ # Get a specific version by ID
24
+ # @param version_id [String, Integer] The version identifier
25
+ # @return [Hash, nil] The version hash or nil if not found
26
+ def get_version(version_id:)
27
+ raise NotImplementedError, "#{self.class} must implement #get_version"
28
+ end
29
+
30
+ # Get a specific version by rule_id and version_number
31
+ # @param rule_id [String] The rule identifier
32
+ # @param version_number [Integer] The version number
33
+ # @return [Hash, nil] The version hash or nil if not found
34
+ def get_version_by_number(rule_id:, version_number:)
35
+ raise NotImplementedError, "#{self.class} must implement #get_version_by_number"
36
+ end
37
+
38
+ # Get the active version for a rule
39
+ # @param rule_id [String] The rule identifier
40
+ # @return [Hash, nil] The active version or nil
41
+ def get_active_version(rule_id:)
42
+ raise NotImplementedError, "#{self.class} must implement #get_active_version"
43
+ end
44
+
45
+ # Activate a specific version
46
+ # @param version_id [String, Integer] The version to activate
47
+ # @return [Hash] The activated version
48
+ def activate_version(version_id:)
49
+ raise NotImplementedError, "#{self.class} must implement #activate_version"
50
+ end
51
+
52
+ # Compare two versions
53
+ # @param version_id_1 [String, Integer] First version ID
54
+ # @param version_id_2 [String, Integer] Second version ID
55
+ # @return [Hash] Comparison result with differences
56
+ def compare_versions(version_id_1:, version_id_2:)
57
+ v1 = get_version(version_id: version_id_1)
58
+ v2 = get_version(version_id: version_id_2)
59
+
60
+ return nil if v1.nil? || v2.nil?
61
+
62
+ {
63
+ version_1: v1,
64
+ version_2: v2,
65
+ differences: calculate_diff(v1[:content], v2[:content])
66
+ }
67
+ end
68
+
69
+ # Delete a specific version
70
+ # @param version_id [String, Integer] The version to delete
71
+ # @return [Boolean] True if deleted successfully
72
+ def delete_version(version_id:)
73
+ raise NotImplementedError, "#{self.class} must implement #delete_version"
74
+ end
75
+
76
+ private
77
+
78
+ # Calculate differences between two content hashes
79
+ # @param content1 [Hash] First content
80
+ # @param content2 [Hash] Second content
81
+ # @return [Hash] Differences
82
+ def calculate_diff(content1, content2)
83
+ {
84
+ added: content2.to_a - content1.to_a,
85
+ removed: content1.to_a - content2.to_a,
86
+ changed: detect_changes(content1, content2)
87
+ }
88
+ end
89
+
90
+ def detect_changes(hash1, hash2)
91
+ changes = {}
92
+ hash1.each do |key, value1|
93
+ value2 = hash2[key]
94
+ if value1 != value2 && !value2.nil?
95
+ changes[key] = { old: value1, new: value2 }
96
+ end
97
+ end
98
+ changes
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,182 @@
1
+ require_relative "adapter"
2
+ require "json"
3
+ require "fileutils"
4
+
5
+ module DecisionAgent
6
+ module Versioning
7
+ # File-based version storage adapter for non-Rails applications
8
+ # Stores versions as JSON files in a directory structure
9
+ class FileStorageAdapter < Adapter
10
+ attr_reader :storage_path
11
+
12
+ # Initialize with a storage directory
13
+ # @param storage_path [String] Path to store version files (default: ./versions)
14
+ def initialize(storage_path: "./versions")
15
+ @storage_path = storage_path
16
+ @mutex = Mutex.new
17
+ FileUtils.mkdir_p(@storage_path)
18
+ end
19
+
20
+ def create_version(rule_id:, content:, metadata: {})
21
+ @mutex.synchronize do
22
+ create_version_unsafe(rule_id: rule_id, content: content, metadata: metadata)
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def create_version_unsafe(rule_id:, content:, metadata: {})
29
+ # Get the next version number
30
+ versions = list_versions(rule_id: rule_id)
31
+ next_version_number = versions.empty? ? 1 : versions.first[:version_number] + 1
32
+
33
+ # Deactivate previous active versions
34
+ versions.each do |v|
35
+ if v[:status] == "active"
36
+ update_version_status(v[:id], "archived")
37
+ end
38
+ end
39
+
40
+ # Create version data
41
+ version_id = generate_version_id(rule_id, next_version_number)
42
+ version = {
43
+ id: version_id,
44
+ rule_id: rule_id,
45
+ version_number: next_version_number,
46
+ content: content,
47
+ created_by: metadata[:created_by] || "system",
48
+ created_at: Time.now.utc.iso8601,
49
+ changelog: metadata[:changelog] || "Version #{next_version_number}",
50
+ status: metadata[:status] || "active"
51
+ }
52
+
53
+ # Write to file
54
+ write_version_file(version)
55
+
56
+ version
57
+ end
58
+
59
+ public
60
+
61
+ def list_versions(rule_id:, limit: nil)
62
+ versions = []
63
+ rule_dir = File.join(@storage_path, sanitize_filename(rule_id))
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)
69
+ end
70
+
71
+ versions.sort_by! { |v| -v[:version_number] }
72
+ limit ? versions.take(limit) : versions
73
+ end
74
+
75
+ def get_version(version_id:)
76
+ all_versions.find { |v| v[:id] == version_id }
77
+ end
78
+
79
+ def get_version_by_number(rule_id:, version_number:)
80
+ versions = list_versions(rule_id: rule_id)
81
+ versions.find { |v| v[:version_number] == version_number }
82
+ end
83
+
84
+ def get_active_version(rule_id:)
85
+ versions = list_versions(rule_id: rule_id)
86
+ versions.find { |v| v[:status] == "active" }
87
+ end
88
+
89
+ def activate_version(version_id:)
90
+ @mutex.synchronize do
91
+ version = get_version(version_id: version_id)
92
+ raise DecisionAgent::NotFoundError, "Version not found: #{version_id}" unless version
93
+
94
+ # Deactivate all other versions for this rule
95
+ list_versions(rule_id: version[:rule_id]).each do |v|
96
+ if v[:id] != version_id && v[:status] == "active"
97
+ update_version_status(v[:id], "archived")
98
+ end
99
+ end
100
+
101
+ # Activate this version
102
+ version[:status] = "active"
103
+ write_version_file(version)
104
+
105
+ version
106
+ end
107
+ end
108
+
109
+ def delete_version(version_id:)
110
+ @mutex.synchronize do
111
+ version = get_version(version_id: version_id)
112
+ raise DecisionAgent::NotFoundError, "Version not found: #{version_id}" unless version
113
+
114
+ # Prevent deletion of active versions
115
+ if version[:status] == "active"
116
+ raise DecisionAgent::ValidationError, "Cannot delete active version. Please activate another version first."
117
+ end
118
+
119
+ # Delete the file
120
+ rule_dir = File.join(@storage_path, sanitize_filename(version[:rule_id]))
121
+ filename = "#{version[:version_number]}.json"
122
+ filepath = File.join(rule_dir, filename)
123
+
124
+ if File.exist?(filepath)
125
+ File.delete(filepath)
126
+ true
127
+ else
128
+ false
129
+ end
130
+ end
131
+ end
132
+
133
+ private
134
+
135
+ def all_versions
136
+ versions = []
137
+ return versions unless Dir.exist?(@storage_path)
138
+
139
+ Dir.glob(File.join(@storage_path, "*", "*.json")).each do |file|
140
+ versions << JSON.parse(File.read(file), symbolize_names: true)
141
+ end
142
+
143
+ versions
144
+ end
145
+
146
+ def update_version_status(version_id, status)
147
+ version = get_version(version_id: version_id)
148
+ return unless version
149
+
150
+ version[:status] = status
151
+ write_version_file(version)
152
+ end
153
+
154
+ def write_version_file(version)
155
+ rule_dir = File.join(@storage_path, sanitize_filename(version[:rule_id]))
156
+ FileUtils.mkdir_p(rule_dir)
157
+
158
+ filename = "#{version[:version_number]}.json"
159
+ filepath = File.join(rule_dir, filename)
160
+
161
+ # Use atomic write to prevent race conditions during concurrent access
162
+ # Write to temp file first, then atomically rename
163
+ temp_file = "#{filepath}.tmp.#{Process.pid}.#{Thread.current.object_id}"
164
+ begin
165
+ File.write(temp_file, JSON.pretty_generate(version))
166
+ File.rename(temp_file, filepath)
167
+ ensure
168
+ # Clean up temp file if rename failed
169
+ File.delete(temp_file) if File.exist?(temp_file)
170
+ end
171
+ end
172
+
173
+ def generate_version_id(rule_id, version_number)
174
+ "#{rule_id}_v#{version_number}"
175
+ end
176
+
177
+ def sanitize_filename(name)
178
+ name.to_s.gsub(/[^a-zA-Z0-9_-]/, "_")
179
+ end
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,135 @@
1
+ module DecisionAgent
2
+ module Versioning
3
+ # High-level service for managing rule versions
4
+ # Provides a framework-agnostic API using pluggable storage adapters
5
+ class VersionManager
6
+ attr_reader :adapter
7
+
8
+ # Initialize with a storage adapter
9
+ # @param adapter [Adapter] The storage adapter to use
10
+ def initialize(adapter: nil)
11
+ @adapter = adapter || default_adapter
12
+ end
13
+
14
+ # Save a new version of a rule
15
+ # @param rule_id [String] Unique identifier for the rule
16
+ # @param rule_content [Hash] The rule definition
17
+ # @param created_by [String] User who created this version
18
+ # @param changelog [String] Description of changes
19
+ # @return [Hash] The created version
20
+ def save_version(rule_id:, rule_content:, created_by: "system", changelog: nil)
21
+ validate_rule_content!(rule_content)
22
+
23
+ metadata = {
24
+ created_by: created_by,
25
+ changelog: changelog || generate_default_changelog(rule_id)
26
+ }
27
+
28
+ @adapter.create_version(
29
+ rule_id: rule_id,
30
+ content: rule_content,
31
+ metadata: metadata
32
+ )
33
+ end
34
+
35
+ # Get all versions for a rule
36
+ # @param rule_id [String] The rule identifier
37
+ # @param limit [Integer, nil] Optional limit
38
+ # @return [Array<Hash>] Array of versions
39
+ def get_versions(rule_id:, limit: nil)
40
+ @adapter.list_versions(rule_id: rule_id, limit: limit)
41
+ end
42
+
43
+ # Get a specific version
44
+ # @param version_id [String, Integer] The version identifier
45
+ # @return [Hash, nil] The version or nil
46
+ def get_version(version_id:)
47
+ @adapter.get_version(version_id: version_id)
48
+ end
49
+
50
+ # Get the currently active version for a rule
51
+ # @param rule_id [String] The rule identifier
52
+ # @return [Hash, nil] The active version or nil
53
+ def get_active_version(rule_id:)
54
+ @adapter.get_active_version(rule_id: rule_id)
55
+ end
56
+
57
+ # Rollback to a previous version (activate it)
58
+ # @param version_id [String, Integer] The version to rollback to
59
+ # @param performed_by [String] User performing the rollback
60
+ # @return [Hash] The activated version
61
+ def rollback(version_id:, performed_by: "system")
62
+ version = @adapter.activate_version(version_id: version_id)
63
+
64
+ # Create an audit trail of the rollback
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
73
+ end
74
+
75
+ # Compare two versions
76
+ # @param version_id_1 [String, Integer] First version
77
+ # @param version_id_2 [String, Integer] Second version
78
+ # @return [Hash] Comparison result
79
+ def compare(version_id_1:, version_id_2:)
80
+ @adapter.compare_versions(
81
+ version_id_1: version_id_1,
82
+ version_id_2: version_id_2
83
+ )
84
+ end
85
+
86
+ # Get version history with metadata
87
+ # @param rule_id [String] The rule identifier
88
+ # @return [Hash] History with statistics
89
+ def get_history(rule_id:)
90
+ versions = get_versions(rule_id: rule_id)
91
+
92
+ {
93
+ rule_id: rule_id,
94
+ total_versions: versions.length,
95
+ active_version: get_active_version(rule_id: rule_id),
96
+ versions: versions,
97
+ created_at: versions.last&.dig(:created_at),
98
+ updated_at: versions.first&.dig(:created_at)
99
+ }
100
+ end
101
+
102
+ # Delete a specific version
103
+ # @param version_id [String, Integer] The version to delete
104
+ # @return [Boolean] True if deleted successfully
105
+ def delete_version(version_id:)
106
+ @adapter.delete_version(version_id: version_id)
107
+ end
108
+
109
+ private
110
+
111
+ def default_adapter
112
+ # Auto-detect the best adapter based on available frameworks
113
+ if defined?(ActiveRecord) && defined?(::RuleVersion)
114
+ require_relative "activerecord_adapter"
115
+ ActiveRecordAdapter.new
116
+ else
117
+ require_relative "file_storage_adapter"
118
+ FileStorageAdapter.new
119
+ end
120
+ end
121
+
122
+ def validate_rule_content!(content)
123
+ raise DecisionAgent::ValidationError, "Rule content cannot be nil" if content.nil?
124
+ raise DecisionAgent::ValidationError, "Rule content must be a Hash" unless content.is_a?(Hash)
125
+ raise DecisionAgent::ValidationError, "Rule content cannot be empty" if content.empty?
126
+ end
127
+
128
+ def generate_default_changelog(rule_id)
129
+ versions = get_versions(rule_id: rule_id)
130
+ version_num = versions.empty? ? 1 : versions.first[:version_number] + 1
131
+ "Version #{version_num}"
132
+ end
133
+ end
134
+ end
135
+ end