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.
- checksums.yaml +4 -4
- data/LICENSE.txt +0 -0
- data/README.md +64 -108
- data/lib/decision_agent/ab_testing/ab_test.rb +5 -1
- data/lib/decision_agent/ab_testing/ab_test_assignment.rb +2 -0
- data/lib/decision_agent/ab_testing/ab_test_manager.rb +2 -0
- data/lib/decision_agent/ab_testing/ab_testing_agent.rb +2 -0
- data/lib/decision_agent/ab_testing/storage/activerecord_adapter.rb +2 -16
- data/lib/decision_agent/ab_testing/storage/adapter.rb +2 -0
- data/lib/decision_agent/ab_testing/storage/memory_adapter.rb +2 -0
- data/lib/decision_agent/agent.rb +49 -51
- data/lib/decision_agent/audit/adapter.rb +2 -0
- data/lib/decision_agent/audit/logger_adapter.rb +2 -0
- data/lib/decision_agent/audit/null_adapter.rb +2 -0
- data/lib/decision_agent/auth/access_audit_logger.rb +2 -0
- data/lib/decision_agent/auth/authenticator.rb +2 -0
- data/lib/decision_agent/auth/password_reset_manager.rb +2 -0
- data/lib/decision_agent/auth/password_reset_token.rb +2 -0
- data/lib/decision_agent/auth/permission.rb +2 -0
- data/lib/decision_agent/auth/permission_checker.rb +2 -0
- data/lib/decision_agent/auth/rbac_adapter.rb +2 -0
- data/lib/decision_agent/auth/rbac_config.rb +2 -0
- data/lib/decision_agent/auth/role.rb +2 -0
- data/lib/decision_agent/auth/session.rb +2 -0
- data/lib/decision_agent/auth/session_manager.rb +2 -0
- data/lib/decision_agent/auth/user.rb +2 -0
- data/lib/decision_agent/context.rb +13 -0
- data/lib/decision_agent/decision.rb +11 -2
- data/lib/decision_agent/dmn/adapter.rb +2 -0
- data/lib/decision_agent/dmn/cache.rb +2 -2
- data/lib/decision_agent/dmn/decision_graph.rb +7 -7
- data/lib/decision_agent/dmn/decision_tree.rb +16 -8
- data/lib/decision_agent/dmn/errors.rb +2 -0
- data/lib/decision_agent/dmn/exporter.rb +43 -2
- data/lib/decision_agent/dmn/feel/evaluator.rb +102 -112
- data/lib/decision_agent/dmn/feel/functions.rb +2 -0
- data/lib/decision_agent/dmn/feel/parser.rb +2 -0
- data/lib/decision_agent/dmn/feel/simple_parser.rb +98 -77
- data/lib/decision_agent/dmn/feel/transformer.rb +56 -102
- data/lib/decision_agent/dmn/feel/types.rb +2 -0
- data/lib/decision_agent/dmn/importer.rb +2 -0
- data/lib/decision_agent/dmn/model.rb +2 -4
- data/lib/decision_agent/dmn/parser.rb +2 -0
- data/lib/decision_agent/dmn/testing.rb +3 -6
- data/lib/decision_agent/dmn/validator.rb +8 -10
- data/lib/decision_agent/dmn/versioning.rb +41 -15
- data/lib/decision_agent/dmn/visualizer.rb +7 -6
- data/lib/decision_agent/dsl/condition_evaluator.rb +197 -1473
- data/lib/decision_agent/dsl/helpers/cache_helpers.rb +82 -0
- data/lib/decision_agent/dsl/helpers/comparison_helpers.rb +98 -0
- data/lib/decision_agent/dsl/helpers/date_helpers.rb +91 -0
- data/lib/decision_agent/dsl/helpers/geospatial_helpers.rb +85 -0
- data/lib/decision_agent/dsl/helpers/operator_evaluation_helpers.rb +160 -0
- data/lib/decision_agent/dsl/helpers/parameter_parsing_helpers.rb +206 -0
- data/lib/decision_agent/dsl/helpers/template_helpers.rb +39 -0
- data/lib/decision_agent/dsl/helpers/utility_helpers.rb +45 -0
- data/lib/decision_agent/dsl/operators/base.rb +70 -0
- data/lib/decision_agent/dsl/operators/basic_comparison_operators.rb +80 -0
- data/lib/decision_agent/dsl/operators/collection_operators.rb +60 -0
- data/lib/decision_agent/dsl/operators/date_arithmetic_operators.rb +206 -0
- data/lib/decision_agent/dsl/operators/date_time_operators.rb +47 -0
- data/lib/decision_agent/dsl/operators/duration_operators.rb +149 -0
- data/lib/decision_agent/dsl/operators/financial_operators.rb +237 -0
- data/lib/decision_agent/dsl/operators/geospatial_operators.rb +106 -0
- data/lib/decision_agent/dsl/operators/mathematical_operators.rb +234 -0
- data/lib/decision_agent/dsl/operators/moving_window_operators.rb +135 -0
- data/lib/decision_agent/dsl/operators/numeric_operators.rb +120 -0
- data/lib/decision_agent/dsl/operators/rate_operators.rb +65 -0
- data/lib/decision_agent/dsl/operators/statistical_aggregations.rb +187 -0
- data/lib/decision_agent/dsl/operators/string_aggregations.rb +84 -0
- data/lib/decision_agent/dsl/operators/string_operators.rb +72 -0
- data/lib/decision_agent/dsl/operators/time_component_operators.rb +72 -0
- data/lib/decision_agent/dsl/rule_parser.rb +2 -0
- data/lib/decision_agent/dsl/schema_validator.rb +9 -24
- data/lib/decision_agent/errors.rb +2 -0
- data/lib/decision_agent/evaluation.rb +14 -2
- data/lib/decision_agent/evaluation_validator.rb +0 -0
- data/lib/decision_agent/evaluators/base.rb +2 -0
- data/lib/decision_agent/evaluators/dmn_evaluator.rb +2 -0
- data/lib/decision_agent/evaluators/json_rule_evaluator.rb +28 -41
- data/lib/decision_agent/evaluators/static_evaluator.rb +2 -0
- data/lib/decision_agent/explainability/condition_trace.rb +2 -0
- data/lib/decision_agent/explainability/explainability_result.rb +2 -4
- data/lib/decision_agent/explainability/rule_trace.rb +2 -0
- data/lib/decision_agent/explainability/trace_collector.rb +2 -0
- data/lib/decision_agent/monitoring/alert_manager.rb +2 -15
- data/lib/decision_agent/monitoring/dashboard/public/dashboard.css +0 -0
- data/lib/decision_agent/monitoring/dashboard/public/dashboard.js +0 -0
- data/lib/decision_agent/monitoring/dashboard/public/index.html +0 -0
- data/lib/decision_agent/monitoring/dashboard_server.rb +383 -250
- data/lib/decision_agent/monitoring/metrics_collector.rb +2 -0
- data/lib/decision_agent/monitoring/monitored_agent.rb +2 -0
- data/lib/decision_agent/monitoring/prometheus_exporter.rb +3 -1
- data/lib/decision_agent/monitoring/storage/activerecord_adapter.rb +0 -0
- data/lib/decision_agent/monitoring/storage/base_adapter.rb +0 -0
- data/lib/decision_agent/monitoring/storage/memory_adapter.rb +1 -1
- data/lib/decision_agent/replay/replay.rb +4 -1
- data/lib/decision_agent/scoring/base.rb +2 -0
- data/lib/decision_agent/scoring/consensus.rb +2 -0
- data/lib/decision_agent/scoring/max_weight.rb +2 -0
- data/lib/decision_agent/scoring/threshold.rb +2 -0
- data/lib/decision_agent/scoring/weighted_average.rb +2 -0
- data/lib/decision_agent/simulation/errors.rb +2 -0
- data/lib/decision_agent/simulation/impact_analyzer.rb +3 -3
- data/lib/decision_agent/simulation/monte_carlo_simulator.rb +11 -10
- data/lib/decision_agent/simulation/replay_engine.rb +3 -3
- data/lib/decision_agent/simulation/scenario_engine.rb +3 -1
- data/lib/decision_agent/simulation/scenario_library.rb +2 -0
- data/lib/decision_agent/simulation/shadow_test_engine.rb +3 -16
- data/lib/decision_agent/simulation/what_if_analyzer.rb +17 -13
- data/lib/decision_agent/simulation.rb +2 -0
- data/lib/decision_agent/testing/batch_test_importer.rb +6 -6
- data/lib/decision_agent/testing/batch_test_runner.rb +5 -4
- data/lib/decision_agent/testing/test_coverage_analyzer.rb +2 -0
- data/lib/decision_agent/testing/test_result_comparator.rb +56 -63
- data/lib/decision_agent/testing/test_scenario.rb +2 -0
- data/lib/decision_agent/version.rb +3 -1
- data/lib/decision_agent/versioning/activerecord_adapter.rb +159 -47
- data/lib/decision_agent/versioning/adapter.rb +42 -0
- data/lib/decision_agent/versioning/file_storage_adapter.rb +96 -13
- data/lib/decision_agent/versioning/version_manager.rb +49 -2
- data/lib/decision_agent/web/dmn_editor/serialization.rb +74 -0
- data/lib/decision_agent/web/dmn_editor/xml_builder.rb +107 -0
- data/lib/decision_agent/web/dmn_editor.rb +8 -73
- data/lib/decision_agent/web/middleware/auth_middleware.rb +2 -0
- data/lib/decision_agent/web/middleware/permission_middleware.rb +3 -1
- data/lib/decision_agent/web/public/app.js +67 -26
- data/lib/decision_agent/web/public/batch_testing.html +80 -6
- data/lib/decision_agent/web/public/dmn-editor.css +0 -0
- data/lib/decision_agent/web/public/dmn-editor.html +2 -2
- data/lib/decision_agent/web/public/dmn-editor.js +79 -8
- data/lib/decision_agent/web/public/index.html +20 -3
- data/lib/decision_agent/web/public/login.html +1 -1
- data/lib/decision_agent/web/public/sample_batch.csv +11 -0
- data/lib/decision_agent/web/public/sample_impact.csv +11 -0
- data/lib/decision_agent/web/public/sample_replay.csv +11 -0
- data/lib/decision_agent/web/public/sample_rules.json +118 -0
- data/lib/decision_agent/web/public/sample_shadow.csv +11 -0
- data/lib/decision_agent/web/public/sample_whatif.csv +11 -0
- data/lib/decision_agent/web/public/simulation.html +23 -7
- data/lib/decision_agent/web/public/simulation_impact.html +37 -20
- data/lib/decision_agent/web/public/simulation_replay.html +19 -23
- data/lib/decision_agent/web/public/simulation_shadow.html +36 -21
- data/lib/decision_agent/web/public/simulation_whatif.html +38 -21
- data/lib/decision_agent/web/public/styles.css +0 -0
- data/lib/decision_agent/web/public/users.html +1 -1
- data/lib/decision_agent/web/rack_helpers.rb +106 -0
- data/lib/decision_agent/web/rack_request_helpers.rb +196 -0
- data/lib/decision_agent/web/server.rb +2038 -1851
- data/lib/decision_agent.rb +3 -43
- data/lib/generators/decision_agent/install/install_generator.rb +2 -0
- data/lib/generators/decision_agent/install/templates/README +0 -0
- data/lib/generators/decision_agent/install/templates/ab_test_assignment_model.rb +2 -0
- data/lib/generators/decision_agent/install/templates/ab_test_model.rb +2 -0
- data/lib/generators/decision_agent/install/templates/ab_testing_migration.rb +2 -0
- data/lib/generators/decision_agent/install/templates/ab_testing_tasks.rake +0 -0
- data/lib/generators/decision_agent/install/templates/decision_agent_tasks.rake +0 -0
- data/lib/generators/decision_agent/install/templates/decision_log.rb +0 -0
- data/lib/generators/decision_agent/install/templates/error_metric.rb +0 -0
- data/lib/generators/decision_agent/install/templates/evaluation_metric.rb +0 -0
- data/lib/generators/decision_agent/install/templates/migration.rb +16 -0
- data/lib/generators/decision_agent/install/templates/monitoring_migration.rb +0 -2
- data/lib/generators/decision_agent/install/templates/performance_metric.rb +0 -0
- data/lib/generators/decision_agent/install/templates/rule.rb +2 -0
- data/lib/generators/decision_agent/install/templates/rule_version.rb +2 -0
- data/lib/generators/decision_agent/install/templates/rule_version_tag.rb +23 -0
- data/lib/generators/decision_agent/install/templates/versioning_migration.rb +44 -0
- data/lib/generators/decision_agent/monitoring_migration/monitoring_migration_generator.rb +67 -0
- data/lib/generators/decision_agent/versioning_migration/versioning_migration_generator.rb +57 -0
- metadata +66 -25
- data/lib/decision_agent/data_enrichment/cache/memory_adapter.rb +0 -86
- data/lib/decision_agent/data_enrichment/cache_adapter.rb +0 -49
- data/lib/decision_agent/data_enrichment/circuit_breaker.rb +0 -135
- data/lib/decision_agent/data_enrichment/client.rb +0 -220
- data/lib/decision_agent/data_enrichment/config.rb +0 -78
- 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
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
#
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
rule_id: rule_id,
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"))
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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"))
|
|
259
|
-
|
|
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
|