decision_agent 0.3.0 → 1.1.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/README.md +234 -14
- 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 -13
- 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 +78 -9
- 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 +14 -0
- data/lib/decision_agent/decision.rb +113 -4
- 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 +2 -0
- data/lib/decision_agent/dmn/feel/evaluator.rb +130 -114
- 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 -2
- data/lib/decision_agent/dmn/validator.rb +5 -3
- data/lib/decision_agent/dmn/visualizer.rb +7 -6
- data/lib/decision_agent/dsl/condition_evaluator.rb +242 -1375
- 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 +37 -14
- data/lib/decision_agent/errors.rb +2 -0
- data/lib/decision_agent/evaluation.rb +14 -2
- data/lib/decision_agent/evaluators/base.rb +2 -0
- data/lib/decision_agent/evaluators/dmn_evaluator.rb +108 -19
- data/lib/decision_agent/evaluators/json_rule_evaluator.rb +56 -11
- data/lib/decision_agent/evaluators/static_evaluator.rb +2 -0
- data/lib/decision_agent/explainability/condition_trace.rb +85 -0
- data/lib/decision_agent/explainability/explainability_result.rb +50 -0
- data/lib/decision_agent/explainability/rule_trace.rb +41 -0
- data/lib/decision_agent/explainability/trace_collector.rb +26 -0
- data/lib/decision_agent/monitoring/alert_manager.rb +7 -16
- 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/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 +20 -0
- data/lib/decision_agent/simulation/impact_analyzer.rb +500 -0
- data/lib/decision_agent/simulation/monte_carlo_simulator.rb +638 -0
- data/lib/decision_agent/simulation/replay_engine.rb +488 -0
- data/lib/decision_agent/simulation/scenario_engine.rb +320 -0
- data/lib/decision_agent/simulation/scenario_library.rb +165 -0
- data/lib/decision_agent/simulation/shadow_test_engine.rb +274 -0
- data/lib/decision_agent/simulation/what_if_analyzer.rb +1008 -0
- data/lib/decision_agent/simulation.rb +19 -0
- data/lib/decision_agent/testing/batch_test_importer.rb +6 -2
- data/lib/decision_agent/testing/batch_test_runner.rb +5 -2
- data/lib/decision_agent/testing/test_coverage_analyzer.rb +2 -0
- data/lib/decision_agent/testing/test_result_comparator.rb +2 -0
- 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 +108 -43
- data/lib/decision_agent/versioning/adapter.rb +9 -0
- data/lib/decision_agent/versioning/file_storage_adapter.rb +19 -6
- data/lib/decision_agent/versioning/version_manager.rb +9 -0
- 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 -67
- 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 +186 -26
- data/lib/decision_agent/web/public/batch_testing.html +80 -6
- data/lib/decision_agent/web/public/dmn-editor.html +2 -2
- data/lib/decision_agent/web/public/dmn-editor.js +74 -8
- data/lib/decision_agent/web/public/index.html +69 -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 +146 -0
- data/lib/decision_agent/web/public/simulation_impact.html +495 -0
- data/lib/decision_agent/web/public/simulation_replay.html +547 -0
- data/lib/decision_agent/web/public/simulation_shadow.html +561 -0
- data/lib/decision_agent/web/public/simulation_whatif.html +549 -0
- data/lib/decision_agent/web/public/styles.css +65 -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 +2126 -1374
- data/lib/decision_agent.rb +19 -1
- data/lib/generators/decision_agent/install/install_generator.rb +2 -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/migration.rb +2 -0
- data/lib/generators/decision_agent/install/templates/rule.rb +2 -0
- data/lib/generators/decision_agent/install/templates/rule_version.rb +2 -0
- metadata +103 -89
- data/spec/ab_testing/ab_test_assignment_spec.rb +0 -253
- data/spec/ab_testing/ab_test_manager_spec.rb +0 -612
- data/spec/ab_testing/ab_test_spec.rb +0 -270
- data/spec/ab_testing/ab_testing_agent_spec.rb +0 -655
- data/spec/ab_testing/storage/adapter_spec.rb +0 -64
- data/spec/ab_testing/storage/memory_adapter_spec.rb +0 -485
- data/spec/activerecord_thread_safety_spec.rb +0 -553
- data/spec/advanced_operators_spec.rb +0 -3150
- data/spec/agent_spec.rb +0 -289
- data/spec/api_contract_spec.rb +0 -430
- data/spec/audit_adapters_spec.rb +0 -92
- data/spec/auth/access_audit_logger_spec.rb +0 -394
- data/spec/auth/authenticator_spec.rb +0 -112
- data/spec/auth/password_reset_spec.rb +0 -294
- data/spec/auth/permission_checker_spec.rb +0 -207
- data/spec/auth/permission_spec.rb +0 -73
- data/spec/auth/rbac_adapter_spec.rb +0 -778
- data/spec/auth/rbac_config_spec.rb +0 -82
- data/spec/auth/role_spec.rb +0 -51
- data/spec/auth/session_manager_spec.rb +0 -172
- data/spec/auth/session_spec.rb +0 -112
- data/spec/auth/user_spec.rb +0 -130
- data/spec/comprehensive_edge_cases_spec.rb +0 -1777
- data/spec/context_spec.rb +0 -127
- data/spec/decision_agent_spec.rb +0 -96
- data/spec/decision_spec.rb +0 -423
- data/spec/dmn/decision_graph_spec.rb +0 -282
- data/spec/dmn/decision_tree_spec.rb +0 -203
- data/spec/dmn/feel/errors_spec.rb +0 -18
- data/spec/dmn/feel/functions_spec.rb +0 -400
- data/spec/dmn/feel/simple_parser_spec.rb +0 -274
- data/spec/dmn/feel/types_spec.rb +0 -176
- data/spec/dmn/feel_parser_spec.rb +0 -489
- data/spec/dmn/hit_policy_spec.rb +0 -202
- data/spec/dmn/integration_spec.rb +0 -226
- data/spec/dsl/condition_evaluator_spec.rb +0 -774
- data/spec/dsl_validation_spec.rb +0 -648
- data/spec/edge_cases_spec.rb +0 -353
- data/spec/evaluation_spec.rb +0 -364
- data/spec/evaluation_validator_spec.rb +0 -165
- data/spec/examples/feedback_aware_evaluator_spec.rb +0 -460
- data/spec/examples.txt +0 -1909
- data/spec/fixtures/dmn/complex_decision.dmn +0 -81
- data/spec/fixtures/dmn/invalid_structure.dmn +0 -31
- data/spec/fixtures/dmn/simple_decision.dmn +0 -40
- data/spec/issue_verification_spec.rb +0 -759
- data/spec/json_rule_evaluator_spec.rb +0 -587
- data/spec/monitoring/alert_manager_spec.rb +0 -378
- data/spec/monitoring/metrics_collector_spec.rb +0 -501
- data/spec/monitoring/monitored_agent_spec.rb +0 -225
- data/spec/monitoring/prometheus_exporter_spec.rb +0 -242
- data/spec/monitoring/storage/activerecord_adapter_spec.rb +0 -498
- data/spec/monitoring/storage/base_adapter_spec.rb +0 -61
- data/spec/monitoring/storage/memory_adapter_spec.rb +0 -247
- data/spec/performance_optimizations_spec.rb +0 -493
- data/spec/replay_edge_cases_spec.rb +0 -699
- data/spec/replay_spec.rb +0 -210
- data/spec/rfc8785_canonicalization_spec.rb +0 -215
- data/spec/scoring_spec.rb +0 -225
- data/spec/spec_helper.rb +0 -60
- data/spec/testing/batch_test_importer_spec.rb +0 -693
- data/spec/testing/batch_test_runner_spec.rb +0 -307
- data/spec/testing/test_coverage_analyzer_spec.rb +0 -292
- data/spec/testing/test_result_comparator_spec.rb +0 -392
- data/spec/testing/test_scenario_spec.rb +0 -113
- data/spec/thread_safety_spec.rb +0 -490
- data/spec/thread_safety_spec.rb.broken +0 -878
- data/spec/versioning/adapter_spec.rb +0 -156
- data/spec/versioning_spec.rb +0 -1030
- data/spec/web/middleware/auth_middleware_spec.rb +0 -133
- data/spec/web/middleware/permission_middleware_spec.rb +0 -247
- data/spec/web_ui_rack_spec.rb +0 -2134
|
@@ -1,7 +1,13 @@
|
|
|
1
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rack"
|
|
4
|
+
require "rack/static"
|
|
5
|
+
require "rack/file"
|
|
2
6
|
require "json"
|
|
3
7
|
require "securerandom"
|
|
4
8
|
require "tempfile"
|
|
9
|
+
require_relative "rack_helpers"
|
|
10
|
+
require_relative "rack_request_helpers"
|
|
5
11
|
|
|
6
12
|
# Ensure testing classes are loaded
|
|
7
13
|
require_relative "../testing/test_scenario"
|
|
@@ -15,6 +21,7 @@ require_relative "../agent"
|
|
|
15
21
|
# DMN components
|
|
16
22
|
require_relative "../dmn/importer"
|
|
17
23
|
require_relative "../dmn/exporter"
|
|
24
|
+
require_relative "dmn_editor"
|
|
18
25
|
|
|
19
26
|
# Auth components
|
|
20
27
|
require_relative "../auth/user"
|
|
@@ -28,1717 +35,2462 @@ require_relative "../auth/access_audit_logger"
|
|
|
28
35
|
require_relative "middleware/auth_middleware"
|
|
29
36
|
require_relative "middleware/permission_middleware"
|
|
30
37
|
|
|
38
|
+
# Simulation components
|
|
39
|
+
require_relative "../simulation/replay_engine"
|
|
40
|
+
require_relative "../simulation/what_if_analyzer"
|
|
41
|
+
require_relative "../simulation/impact_analyzer"
|
|
42
|
+
require_relative "../simulation/shadow_test_engine"
|
|
43
|
+
require_relative "../simulation/scenario_engine"
|
|
44
|
+
require_relative "../simulation/scenario_library"
|
|
45
|
+
require_relative "../versioning/version_manager"
|
|
46
|
+
|
|
31
47
|
module DecisionAgent
|
|
32
48
|
module Web
|
|
33
49
|
# rubocop:disable Metrics/ClassLength
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
50
|
+
# Framework-agnostic Rack application - works with any Rack-compatible server
|
|
51
|
+
class Server
|
|
52
|
+
include RackHelpers
|
|
53
|
+
include RackRequestHelpers
|
|
54
|
+
|
|
55
|
+
PUBLIC_FOLDER = File.expand_path("public", __dir__)
|
|
56
|
+
VIEWS_FOLDER = File.expand_path("views", __dir__)
|
|
57
|
+
|
|
58
|
+
@public_folder = PUBLIC_FOLDER
|
|
59
|
+
@views_folder = VIEWS_FOLDER
|
|
60
|
+
@bind = "0.0.0.0"
|
|
61
|
+
@port = 4567
|
|
39
62
|
|
|
40
63
|
# In-memory storage for batch test runs
|
|
41
64
|
@batch_test_storage = {}
|
|
42
65
|
@batch_test_storage_mutex = Mutex.new
|
|
43
66
|
|
|
67
|
+
# In-memory storage for simulation runs
|
|
68
|
+
@simulation_storage = {}
|
|
69
|
+
@simulation_storage_mutex = Mutex.new
|
|
70
|
+
|
|
44
71
|
# Auth components
|
|
45
72
|
@authenticator = nil
|
|
46
73
|
@permission_checker = nil
|
|
47
74
|
@access_audit_logger = nil
|
|
75
|
+
@auth_mutex = Mutex.new
|
|
48
76
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
def self.batch_test_storage_mutex
|
|
54
|
-
@batch_test_storage_mutex ||= Mutex.new
|
|
55
|
-
end
|
|
77
|
+
# Router instance (initialized lazily)
|
|
78
|
+
@router = nil
|
|
56
79
|
|
|
57
80
|
class << self
|
|
58
|
-
|
|
81
|
+
attr_accessor :public_folder, :views_folder, :bind, :port
|
|
82
|
+
attr_reader :batch_test_storage, :batch_test_storage_mutex, :simulation_storage, :simulation_storage_mutex
|
|
83
|
+
attr_writer :authenticator, :permission_checker, :access_audit_logger
|
|
84
|
+
|
|
85
|
+
alias simulation_storage simulation_storage if method_defined?(:simulation_storage)
|
|
59
86
|
end
|
|
60
87
|
|
|
61
88
|
def self.authenticator
|
|
62
|
-
@authenticator
|
|
63
|
-
end
|
|
89
|
+
return @authenticator if @authenticator
|
|
64
90
|
|
|
65
|
-
|
|
66
|
-
|
|
91
|
+
@auth_mutex.synchronize do
|
|
92
|
+
@authenticator ||= Auth::Authenticator.new
|
|
93
|
+
end
|
|
67
94
|
end
|
|
68
95
|
|
|
69
96
|
def self.permission_checker
|
|
70
|
-
@permission_checker
|
|
71
|
-
end
|
|
97
|
+
return @permission_checker if @permission_checker
|
|
72
98
|
|
|
73
|
-
|
|
74
|
-
|
|
99
|
+
@auth_mutex.synchronize do
|
|
100
|
+
@permission_checker ||= Auth::PermissionChecker.new(adapter: DecisionAgent.rbac_config.adapter)
|
|
101
|
+
end
|
|
75
102
|
end
|
|
76
103
|
|
|
77
104
|
def self.access_audit_logger
|
|
78
|
-
@access_audit_logger
|
|
79
|
-
end
|
|
105
|
+
return @access_audit_logger if @access_audit_logger
|
|
80
106
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
headers["Access-Control-Allow-Origin"] = "*"
|
|
84
|
-
headers["Access-Control-Allow-Methods"] = "GET, POST, DELETE, OPTIONS"
|
|
85
|
-
headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization"
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
# Auth middleware - extract user from token
|
|
89
|
-
before do
|
|
90
|
-
token = extract_token
|
|
91
|
-
if token
|
|
92
|
-
auth_result = self.class.authenticator.authenticate(token)
|
|
93
|
-
if auth_result
|
|
94
|
-
@current_user = auth_result[:user]
|
|
95
|
-
@current_session = auth_result[:session]
|
|
96
|
-
end
|
|
107
|
+
@auth_mutex.synchronize do
|
|
108
|
+
@access_audit_logger ||= Auth::AccessAuditLogger.new
|
|
97
109
|
end
|
|
98
110
|
end
|
|
99
111
|
|
|
100
|
-
#
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
end
|
|
112
|
+
# Initialize router and define routes
|
|
113
|
+
def self.router
|
|
114
|
+
return @router if @router
|
|
104
115
|
|
|
105
|
-
|
|
106
|
-
get "/" do
|
|
107
|
-
# Read the HTML file
|
|
108
|
-
html_file = File.join(settings.public_folder, "index.html")
|
|
109
|
-
unless File.exist?(html_file)
|
|
110
|
-
status 404
|
|
111
|
-
return "Index page not found"
|
|
112
|
-
end
|
|
116
|
+
@router = Router.new
|
|
113
117
|
|
|
114
|
-
|
|
118
|
+
# Enable CORS for API calls
|
|
119
|
+
@router.before do |ctx|
|
|
120
|
+
ctx.headers["Access-Control-Allow-Origin"] = "*"
|
|
121
|
+
ctx.headers["Access-Control-Allow-Methods"] = "GET, POST, DELETE, OPTIONS, PUT"
|
|
122
|
+
ctx.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization"
|
|
123
|
+
end
|
|
115
124
|
|
|
116
|
-
#
|
|
117
|
-
|
|
118
|
-
|
|
125
|
+
# Auth middleware - extract user from token
|
|
126
|
+
@router.before do |ctx|
|
|
127
|
+
token = Server.extract_token_from_context(ctx)
|
|
128
|
+
if token
|
|
129
|
+
auth_result = Server.authenticator.authenticate(token)
|
|
130
|
+
if auth_result
|
|
131
|
+
ctx.current_user = auth_result[:user]
|
|
132
|
+
ctx.current_session = auth_result[:session]
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
119
136
|
|
|
120
|
-
#
|
|
121
|
-
|
|
122
|
-
html_content = if html_content.include?("<base")
|
|
123
|
-
# Replace existing base tag
|
|
124
|
-
html_content.sub(/<base[^>]*>/, base_tag)
|
|
125
|
-
else
|
|
126
|
-
# Insert base tag after <head>
|
|
127
|
-
html_content.sub("<head>", "<head>\n #{base_tag}")
|
|
128
|
-
end
|
|
137
|
+
# Define all routes
|
|
138
|
+
define_routes(@router)
|
|
129
139
|
|
|
130
|
-
|
|
131
|
-
html_content
|
|
132
|
-
rescue StandardError => e
|
|
133
|
-
status 500
|
|
134
|
-
content_type "text/html"
|
|
135
|
-
"Error loading page: #{e.message}"
|
|
140
|
+
@router
|
|
136
141
|
end
|
|
137
142
|
|
|
138
|
-
#
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
send_file File.join(settings.public_folder, "styles.css")
|
|
143
|
+
# Rack call method - entry point for Rack requests
|
|
144
|
+
def self.call(env)
|
|
145
|
+
new.call(env)
|
|
142
146
|
end
|
|
143
147
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
+
def call(env)
|
|
149
|
+
# Try to serve static files first
|
|
150
|
+
path = env["PATH_INFO"] || "/"
|
|
151
|
+
static_file = serve_static_file(path, env)
|
|
152
|
+
return static_file if static_file
|
|
148
153
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
154
|
+
# Route the request
|
|
155
|
+
route_match = self.class.router.match(env)
|
|
156
|
+
return [404, { "Content-Type" => "application/json" }, [{ error: "Not Found", path: path }.to_json]] unless route_match
|
|
152
157
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
request_body = request.body.read
|
|
156
|
-
data = JSON.parse(request_body)
|
|
157
|
-
|
|
158
|
-
# Validate using DecisionAgent's SchemaValidator
|
|
159
|
-
DecisionAgent::Dsl::SchemaValidator.validate!(data)
|
|
160
|
-
|
|
161
|
-
# If validation passes
|
|
162
|
-
{
|
|
163
|
-
valid: true,
|
|
164
|
-
message: "Rules are valid!"
|
|
165
|
-
}.to_json
|
|
166
|
-
rescue JSON::ParserError => e
|
|
167
|
-
status 400
|
|
168
|
-
{
|
|
169
|
-
valid: false,
|
|
170
|
-
errors: ["Invalid JSON: #{e.message}"]
|
|
171
|
-
}.to_json
|
|
172
|
-
rescue DecisionAgent::InvalidRuleDslError => e
|
|
173
|
-
# Validation failed
|
|
174
|
-
status 422
|
|
175
|
-
{
|
|
176
|
-
valid: false,
|
|
177
|
-
errors: parse_validation_errors(e.message)
|
|
178
|
-
}.to_json
|
|
179
|
-
rescue StandardError => e
|
|
180
|
-
# Unexpected error
|
|
181
|
-
status 500
|
|
182
|
-
{
|
|
183
|
-
valid: false,
|
|
184
|
-
errors: ["Server error: #{e.message}"]
|
|
185
|
-
}.to_json
|
|
186
|
-
end
|
|
187
|
-
end
|
|
158
|
+
# Create request context with route params
|
|
159
|
+
ctx = RequestContext.new(env, route_match[:params] || {})
|
|
188
160
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
161
|
+
# Run before filters
|
|
162
|
+
route_match[:before_filters].each do |filter|
|
|
163
|
+
filter.call(ctx)
|
|
164
|
+
return ctx.to_rack_response if ctx.halted?
|
|
165
|
+
end
|
|
192
166
|
|
|
167
|
+
# Execute route handler
|
|
193
168
|
begin
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
rules_json = data["rules"]
|
|
198
|
-
context = data["context"] || {}
|
|
199
|
-
|
|
200
|
-
# Create evaluator
|
|
201
|
-
evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules_json)
|
|
202
|
-
|
|
203
|
-
# Evaluate
|
|
204
|
-
result = evaluator.evaluate(DecisionAgent::Context.new(context))
|
|
205
|
-
|
|
206
|
-
if result
|
|
207
|
-
{
|
|
208
|
-
success: true,
|
|
209
|
-
decision: result.decision,
|
|
210
|
-
weight: result.weight,
|
|
211
|
-
reason: result.reason,
|
|
212
|
-
evaluator_name: result.evaluator_name,
|
|
213
|
-
metadata: result.metadata
|
|
214
|
-
}.to_json
|
|
215
|
-
else
|
|
216
|
-
{
|
|
217
|
-
success: true,
|
|
218
|
-
decision: nil,
|
|
219
|
-
message: "No rules matched the given context"
|
|
220
|
-
}.to_json
|
|
221
|
-
end
|
|
169
|
+
route_match[:handler].call(ctx)
|
|
170
|
+
ctx.to_rack_response
|
|
222
171
|
rescue StandardError => e
|
|
223
|
-
|
|
224
|
-
{
|
|
225
|
-
success: false,
|
|
226
|
-
error: e.message
|
|
227
|
-
}.to_json
|
|
172
|
+
[500, { "Content-Type" => "application/json" }, [{ error: e.message }.to_json]]
|
|
228
173
|
end
|
|
229
174
|
end
|
|
230
175
|
|
|
231
|
-
|
|
232
|
-
get "/api/examples" do
|
|
233
|
-
content_type :json
|
|
234
|
-
|
|
235
|
-
examples = [
|
|
236
|
-
{
|
|
237
|
-
name: "Approval Workflow",
|
|
238
|
-
description: "Basic approval rules for requests",
|
|
239
|
-
rules: {
|
|
240
|
-
version: "1.0",
|
|
241
|
-
ruleset: "approval_workflow",
|
|
242
|
-
rules: [
|
|
243
|
-
{
|
|
244
|
-
id: "admin_auto_approve",
|
|
245
|
-
if: { field: "user.role", op: "eq", value: "admin" },
|
|
246
|
-
then: { decision: "approve", weight: 0.95, reason: "Admin user" }
|
|
247
|
-
},
|
|
248
|
-
{
|
|
249
|
-
id: "low_amount_approve",
|
|
250
|
-
if: { field: "amount", op: "lt", value: 1000 },
|
|
251
|
-
then: { decision: "approve", weight: 0.8, reason: "Low amount" }
|
|
252
|
-
},
|
|
253
|
-
{
|
|
254
|
-
id: "high_amount_review",
|
|
255
|
-
if: { field: "amount", op: "gte", value: 10_000 },
|
|
256
|
-
then: { decision: "manual_review", weight: 0.9, reason: "High amount requires review" }
|
|
257
|
-
}
|
|
258
|
-
]
|
|
259
|
-
}
|
|
260
|
-
},
|
|
261
|
-
{
|
|
262
|
-
name: "User Access Control",
|
|
263
|
-
description: "Role-based access control rules",
|
|
264
|
-
rules: {
|
|
265
|
-
version: "1.0",
|
|
266
|
-
ruleset: "access_control",
|
|
267
|
-
rules: [
|
|
268
|
-
{
|
|
269
|
-
id: "admin_full_access",
|
|
270
|
-
if: {
|
|
271
|
-
all: [
|
|
272
|
-
{ field: "user.role", op: "eq", value: "admin" },
|
|
273
|
-
{ field: "user.active", op: "eq", value: true }
|
|
274
|
-
]
|
|
275
|
-
},
|
|
276
|
-
then: { decision: "allow", weight: 1.0, reason: "Active admin user" }
|
|
277
|
-
},
|
|
278
|
-
{
|
|
279
|
-
id: "guest_read_only",
|
|
280
|
-
if: {
|
|
281
|
-
all: [
|
|
282
|
-
{ field: "user.role", op: "eq", value: "guest" },
|
|
283
|
-
{ field: "action", op: "eq", value: "read" }
|
|
284
|
-
]
|
|
285
|
-
},
|
|
286
|
-
then: { decision: "allow", weight: 0.7, reason: "Guest read access" }
|
|
287
|
-
},
|
|
288
|
-
{
|
|
289
|
-
id: "inactive_user_deny",
|
|
290
|
-
if: { field: "user.active", op: "eq", value: false },
|
|
291
|
-
then: { decision: "deny", weight: 1.0, reason: "Inactive user account" }
|
|
292
|
-
}
|
|
293
|
-
]
|
|
294
|
-
}
|
|
295
|
-
},
|
|
296
|
-
{
|
|
297
|
-
name: "Content Moderation",
|
|
298
|
-
description: "Automatic content moderation rules",
|
|
299
|
-
rules: {
|
|
300
|
-
version: "1.0",
|
|
301
|
-
ruleset: "content_moderation",
|
|
302
|
-
rules: [
|
|
303
|
-
{
|
|
304
|
-
id: "verified_user_approve",
|
|
305
|
-
if: {
|
|
306
|
-
all: [
|
|
307
|
-
{ field: "author.verified", op: "eq", value: true },
|
|
308
|
-
{ field: "content_length", op: "lt", value: 5000 }
|
|
309
|
-
]
|
|
310
|
-
},
|
|
311
|
-
then: { decision: "approve", weight: 0.85, reason: "Verified author with reasonable length" }
|
|
312
|
-
},
|
|
313
|
-
{
|
|
314
|
-
id: "missing_content_reject",
|
|
315
|
-
if: {
|
|
316
|
-
any: [
|
|
317
|
-
{ field: "content", op: "blank" },
|
|
318
|
-
{ field: "content_length", op: "eq", value: 0 }
|
|
319
|
-
]
|
|
320
|
-
},
|
|
321
|
-
then: { decision: "reject", weight: 1.0, reason: "Empty content" }
|
|
322
|
-
},
|
|
323
|
-
{
|
|
324
|
-
id: "flagged_content_review",
|
|
325
|
-
if: { field: "flags", op: "present" },
|
|
326
|
-
then: { decision: "manual_review", weight: 0.9, reason: "Content has been flagged" }
|
|
327
|
-
}
|
|
328
|
-
]
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
]
|
|
176
|
+
private
|
|
332
177
|
|
|
333
|
-
|
|
334
|
-
|
|
178
|
+
def serve_static_file(path, _env)
|
|
179
|
+
# Serve static files from public folder
|
|
180
|
+
static_paths = ["/styles.css", "/app.js", "/index.html", "/batch_testing.html", "/simulation.html", "/login.html", "/users.html",
|
|
181
|
+
"/dmn-editor.html"]
|
|
182
|
+
static_extensions = [".css", ".js", ".html", ".svg", ".png", ".jpg", ".gif", ".json", ".xml", ".csv", ".xlsx"]
|
|
183
|
+
|
|
184
|
+
return nil unless static_paths.include?(path) || static_extensions.any? { |ext| path.end_with?(ext) }
|
|
185
|
+
|
|
186
|
+
# Remove leading slash for file path
|
|
187
|
+
file_name = path.start_with?("/") ? path[1..] : path
|
|
188
|
+
file_path = File.join(self.class.public_folder || PUBLIC_FOLDER, file_name)
|
|
189
|
+
return nil unless File.exist?(file_path) && File.file?(file_path)
|
|
190
|
+
|
|
191
|
+
ext = File.extname(file_path).downcase
|
|
192
|
+
mime_types = {
|
|
193
|
+
".css" => "text/css",
|
|
194
|
+
".js" => "application/javascript",
|
|
195
|
+
".html" => "text/html",
|
|
196
|
+
".json" => "application/json",
|
|
197
|
+
".xml" => "application/xml",
|
|
198
|
+
".svg" => "image/svg+xml",
|
|
199
|
+
".png" => "image/png",
|
|
200
|
+
".jpg" => "image/jpeg",
|
|
201
|
+
".jpeg" => "image/jpeg",
|
|
202
|
+
".gif" => "image/gif",
|
|
203
|
+
".csv" => "text/csv",
|
|
204
|
+
".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
content_type = mime_types[ext] || "application/octet-stream"
|
|
208
|
+
[200, { "Content-Type" => content_type }, [File.read(file_path)]]
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def self.extract_token_from_context(ctx)
|
|
212
|
+
# Check Authorization header: Bearer <token>
|
|
213
|
+
auth_header = ctx.request.get_header("HTTP_AUTHORIZATION")
|
|
214
|
+
return auth_header[7..] if auth_header&.start_with?("Bearer ")
|
|
215
|
+
|
|
216
|
+
# Check session cookie
|
|
217
|
+
cookie_token = ctx.cookies["decision_agent_session"]
|
|
218
|
+
return cookie_token if cookie_token
|
|
335
219
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
content_type :json
|
|
339
|
-
{ status: "ok", version: DecisionAgent::VERSION }.to_json
|
|
220
|
+
# Check query parameter
|
|
221
|
+
ctx.params["token"] || ctx.params[:token]
|
|
340
222
|
end
|
|
341
223
|
|
|
342
|
-
#
|
|
224
|
+
# Helper methods for routes - work with RequestContext
|
|
225
|
+
def self.extract_token(ctx)
|
|
226
|
+
extract_token_from_context(ctx)
|
|
227
|
+
end
|
|
343
228
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
content_type :json
|
|
229
|
+
def self.require_authentication!(ctx)
|
|
230
|
+
return if ctx.current_user
|
|
347
231
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
232
|
+
ctx.content_type "application/json"
|
|
233
|
+
ctx.halt(401, { error: "Authentication required" }.to_json)
|
|
234
|
+
end
|
|
351
235
|
|
|
352
|
-
|
|
353
|
-
|
|
236
|
+
def self.require_permission!(ctx, permission, resource = nil)
|
|
237
|
+
# Skip all permission checks if disabled via environment variable
|
|
238
|
+
return true if permissions_disabled?
|
|
354
239
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
end
|
|
240
|
+
# Require authentication only if permissions are enabled
|
|
241
|
+
require_authentication!(ctx)
|
|
242
|
+
return if ctx.halted?
|
|
359
243
|
|
|
360
|
-
|
|
244
|
+
checker = Server.permission_checker
|
|
245
|
+
granted = checker.can?(ctx.current_user, permission, resource)
|
|
361
246
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
247
|
+
unless granted
|
|
248
|
+
# Log the permission denial
|
|
249
|
+
begin
|
|
250
|
+
user_id = checker.user_id(ctx.current_user)
|
|
251
|
+
Server.access_audit_logger.log_permission_check(
|
|
252
|
+
user_id: user_id,
|
|
253
|
+
permission: permission,
|
|
254
|
+
resource_type: resource&.class&.name,
|
|
255
|
+
resource_id: resource&.id,
|
|
256
|
+
granted: false
|
|
369
257
|
)
|
|
370
|
-
|
|
371
|
-
|
|
258
|
+
rescue StandardError => e
|
|
259
|
+
# If logging fails, continue with permission denial
|
|
260
|
+
warn "[DecisionAgent] Failed to log permission denial: #{e.message}"
|
|
372
261
|
end
|
|
262
|
+
ctx.content_type "application/json"
|
|
263
|
+
ctx.halt(403, { error: "Permission denied: #{permission}" }.to_json)
|
|
264
|
+
end
|
|
373
265
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
user_id:
|
|
379
|
-
|
|
380
|
-
|
|
266
|
+
# Log successful permission check
|
|
267
|
+
begin
|
|
268
|
+
user_id = checker.user_id(ctx.current_user)
|
|
269
|
+
Server.access_audit_logger.log_permission_check(
|
|
270
|
+
user_id: user_id,
|
|
271
|
+
permission: permission,
|
|
272
|
+
resource_type: resource&.class&.name,
|
|
273
|
+
resource_id: resource&.id,
|
|
274
|
+
granted: true
|
|
381
275
|
)
|
|
382
|
-
|
|
383
|
-
{
|
|
384
|
-
token: session.token,
|
|
385
|
-
user: user.to_h,
|
|
386
|
-
expires_at: session.expires_at.iso8601
|
|
387
|
-
}.to_json
|
|
388
|
-
rescue JSON::ParserError
|
|
389
|
-
status 400
|
|
390
|
-
{ error: "Invalid JSON" }.to_json
|
|
391
276
|
rescue StandardError => e
|
|
392
|
-
|
|
393
|
-
|
|
277
|
+
# If logging fails, continue - permission was granted
|
|
278
|
+
warn "[DecisionAgent] Failed to log permission grant: #{e.message}"
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def self.permissions_disabled?
|
|
283
|
+
# Check explicit environment variable first
|
|
284
|
+
disable_flag = ENV.fetch("DISABLE_WEBUI_PERMISSIONS", nil)
|
|
285
|
+
if disable_flag
|
|
286
|
+
normalized = disable_flag.to_s.strip.downcase
|
|
287
|
+
return true if %w[true 1 yes].include?(normalized)
|
|
288
|
+
return false if %w[false 0 no].include?(normalized)
|
|
394
289
|
end
|
|
290
|
+
|
|
291
|
+
# Auto-disable in development environments if not explicitly set
|
|
292
|
+
env = ENV["RACK_ENV"] || ENV["RAILS_ENV"] || "development"
|
|
293
|
+
env == "development"
|
|
395
294
|
end
|
|
396
295
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
296
|
+
def self.parse_validation_errors(error_message)
|
|
297
|
+
# Extract individual errors from the formatted error message
|
|
298
|
+
errors = []
|
|
400
299
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
if token
|
|
404
|
-
self.class.authenticator.logout(token)
|
|
405
|
-
if @current_user
|
|
406
|
-
checker = self.class.permission_checker
|
|
407
|
-
self.class.access_audit_logger.log_authentication(
|
|
408
|
-
"logout",
|
|
409
|
-
user_id: checker.user_id(@current_user),
|
|
410
|
-
email: checker.user_email(@current_user),
|
|
411
|
-
success: true
|
|
412
|
-
)
|
|
413
|
-
end
|
|
414
|
-
end
|
|
300
|
+
# The error message is formatted with numbered errors
|
|
301
|
+
lines = error_message.split("\n")
|
|
415
302
|
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
303
|
+
lines.each do |line|
|
|
304
|
+
# Match lines like " 1. Error message"
|
|
305
|
+
if line.match?(/^\s*\d+\.\s+/)
|
|
306
|
+
error = line.gsub(/^\s*\d+\.\s+/, "").strip
|
|
307
|
+
errors << error unless error.empty?
|
|
308
|
+
end
|
|
420
309
|
end
|
|
310
|
+
|
|
311
|
+
# If no errors were parsed, return the full message
|
|
312
|
+
errors.empty? ? [error_message] : errors
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def self.version_manager
|
|
316
|
+
@version_manager ||= DecisionAgent::Versioning::VersionManager.new
|
|
421
317
|
end
|
|
422
318
|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
319
|
+
def self.dmn_editor
|
|
320
|
+
@dmn_editor ||= DecisionAgent::Web::DmnEditor.new
|
|
321
|
+
end
|
|
426
322
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
status
|
|
431
|
-
|
|
323
|
+
# Serve an HTML file with <base> tag injection for subpath mounting
|
|
324
|
+
def self.serve_html_with_base_tag(ctx, html_file, not_found_message = "Page not found")
|
|
325
|
+
unless File.exist?(html_file)
|
|
326
|
+
ctx.status(404)
|
|
327
|
+
ctx.body(not_found_message)
|
|
328
|
+
return
|
|
432
329
|
end
|
|
330
|
+
|
|
331
|
+
html_content = File.read(html_file, encoding: "UTF-8")
|
|
332
|
+
|
|
333
|
+
# Determine the base path from the request
|
|
334
|
+
base_path = ctx.script_name.empty? ? "./" : "#{ctx.script_name}/"
|
|
335
|
+
|
|
336
|
+
# Inject or update base tag
|
|
337
|
+
base_tag = "<base href=\"#{base_path}\">"
|
|
338
|
+
html_content = if html_content.include?("<base")
|
|
339
|
+
html_content.sub(/<base[^>]*>/, base_tag)
|
|
340
|
+
else
|
|
341
|
+
html_content.sub("<head>", "<head>\n #{base_tag}")
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
ctx.content_type "text/html"
|
|
345
|
+
ctx.body(html_content)
|
|
433
346
|
end
|
|
434
347
|
|
|
435
|
-
#
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
348
|
+
# Define all routes (will be populated below)
|
|
349
|
+
def self.define_routes(router)
|
|
350
|
+
# OPTIONS handler for CORS preflight
|
|
351
|
+
router.options "*" do |ctx|
|
|
352
|
+
ctx.status(200)
|
|
353
|
+
ctx.body("")
|
|
354
|
+
end
|
|
439
355
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
356
|
+
# Main page - serve the rule builder UI
|
|
357
|
+
router.get "/" do |ctx|
|
|
358
|
+
html_file = File.join(Server.public_folder, "index.html")
|
|
359
|
+
Server.serve_html_with_base_tag(ctx, html_file, "Index page not found")
|
|
360
|
+
rescue StandardError => e
|
|
361
|
+
ctx.status(500)
|
|
362
|
+
ctx.content_type "text/html"
|
|
363
|
+
ctx.body("Error loading page: #{e.message}")
|
|
446
364
|
end
|
|
447
365
|
|
|
448
|
-
|
|
449
|
-
|
|
366
|
+
# Serve static assets explicitly
|
|
367
|
+
router.get "/styles.css" do |ctx|
|
|
368
|
+
ctx.content_type "text/css"
|
|
369
|
+
css_file = File.join(Server.public_folder, "styles.css")
|
|
370
|
+
ctx.send_file(css_file) if File.exist?(css_file)
|
|
371
|
+
end
|
|
450
372
|
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
373
|
+
router.get "/app.js" do |ctx|
|
|
374
|
+
ctx.content_type "application/javascript"
|
|
375
|
+
js_file = File.join(Server.public_folder, "app.js")
|
|
376
|
+
ctx.send_file(js_file) if File.exist?(js_file)
|
|
377
|
+
end
|
|
455
378
|
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
379
|
+
# API: Validate rules
|
|
380
|
+
router.post "/api/validate" do |ctx|
|
|
381
|
+
ctx.content_type "application/json"
|
|
459
382
|
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
383
|
+
begin
|
|
384
|
+
# Parse request body
|
|
385
|
+
request_body = RackRequestHelpers.read_body(ctx.env)
|
|
386
|
+
data = JSON.parse(request_body)
|
|
463
387
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
388
|
+
# Validate using DecisionAgent's SchemaValidator
|
|
389
|
+
DecisionAgent::Dsl::SchemaValidator.validate!(data)
|
|
390
|
+
|
|
391
|
+
# If validation passes
|
|
392
|
+
ctx.json({
|
|
393
|
+
valid: true,
|
|
394
|
+
message: "Rules are valid!"
|
|
395
|
+
})
|
|
396
|
+
rescue JSON::ParserError => e
|
|
397
|
+
ctx.status(400)
|
|
398
|
+
ctx.json({
|
|
399
|
+
valid: false,
|
|
400
|
+
errors: ["Invalid JSON: #{e.message}"]
|
|
401
|
+
})
|
|
402
|
+
rescue DecisionAgent::InvalidRuleDslError => e
|
|
403
|
+
# Validation failed
|
|
404
|
+
ctx.status(422)
|
|
405
|
+
ctx.json({
|
|
406
|
+
valid: false,
|
|
407
|
+
errors: Server.parse_validation_errors(e.message)
|
|
408
|
+
})
|
|
409
|
+
rescue StandardError => e
|
|
410
|
+
# Unexpected error
|
|
411
|
+
ctx.status(500)
|
|
412
|
+
ctx.json({
|
|
413
|
+
valid: false,
|
|
414
|
+
errors: ["Server error: #{e.message}"]
|
|
415
|
+
})
|
|
467
416
|
end
|
|
417
|
+
end
|
|
468
418
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
419
|
+
# API: Test rule evaluation (optional feature)
|
|
420
|
+
router.post "/api/evaluate" do |ctx|
|
|
421
|
+
ctx.content_type "application/json"
|
|
422
|
+
|
|
423
|
+
begin
|
|
424
|
+
request_body = RackRequestHelpers.read_body(ctx.env)
|
|
425
|
+
data = JSON.parse(request_body)
|
|
426
|
+
|
|
427
|
+
rules_json = data["rules"]
|
|
428
|
+
context = data["context"] || {}
|
|
429
|
+
|
|
430
|
+
# Create evaluator
|
|
431
|
+
evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules_json)
|
|
432
|
+
|
|
433
|
+
# Evaluate
|
|
434
|
+
result = evaluator.evaluate(DecisionAgent::Context.new(context))
|
|
435
|
+
|
|
436
|
+
if result
|
|
437
|
+
# Get explainability data from metadata if available
|
|
438
|
+
explainability = result.metadata[:explainability] if result.metadata.is_a?(Hash)
|
|
439
|
+
|
|
440
|
+
# Structure response as explainability by default
|
|
441
|
+
# This makes explainability the primary format for decision results
|
|
442
|
+
response = if explainability
|
|
443
|
+
{
|
|
444
|
+
success: true,
|
|
445
|
+
decision: explainability[:decision] || result.decision,
|
|
446
|
+
because: explainability[:because] || [],
|
|
447
|
+
failed_conditions: explainability[:failed_conditions] || [],
|
|
448
|
+
# Include additional metadata for completeness
|
|
449
|
+
confidence: result.weight,
|
|
450
|
+
reason: result.reason,
|
|
451
|
+
evaluator_name: result.evaluator_name,
|
|
452
|
+
# Full explainability data (includes rule_traces in verbose mode)
|
|
453
|
+
explainability: explainability
|
|
454
|
+
}
|
|
455
|
+
else
|
|
456
|
+
# Fallback if explainability is not available
|
|
457
|
+
{
|
|
458
|
+
success: true,
|
|
459
|
+
decision: result.decision,
|
|
460
|
+
because: [],
|
|
461
|
+
failed_conditions: [],
|
|
462
|
+
confidence: result.weight,
|
|
463
|
+
reason: result.reason,
|
|
464
|
+
evaluator_name: result.evaluator_name,
|
|
465
|
+
explainability: {
|
|
466
|
+
decision: result.decision,
|
|
467
|
+
because: [],
|
|
468
|
+
failed_conditions: []
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
ctx.json(response)
|
|
474
|
+
else
|
|
475
|
+
ctx.json({
|
|
476
|
+
success: true,
|
|
477
|
+
decision: nil,
|
|
478
|
+
because: [],
|
|
479
|
+
failed_conditions: [],
|
|
480
|
+
message: "No rules matched the given context",
|
|
481
|
+
explainability: {
|
|
482
|
+
decision: nil,
|
|
483
|
+
because: [],
|
|
484
|
+
failed_conditions: []
|
|
485
|
+
}
|
|
486
|
+
})
|
|
474
487
|
end
|
|
488
|
+
rescue StandardError => e
|
|
489
|
+
ctx.status(500)
|
|
490
|
+
ctx.json({
|
|
491
|
+
success: false,
|
|
492
|
+
error: e.message
|
|
493
|
+
})
|
|
475
494
|
end
|
|
495
|
+
end
|
|
476
496
|
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
roles: roles
|
|
481
|
-
)
|
|
497
|
+
# API: Get example rules
|
|
498
|
+
router.get "/api/examples" do |ctx|
|
|
499
|
+
ctx.content_type "application/json"
|
|
482
500
|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
501
|
+
examples = [
|
|
502
|
+
{
|
|
503
|
+
name: "Approval Workflow",
|
|
504
|
+
description: "Basic approval rules for requests",
|
|
505
|
+
rules: {
|
|
506
|
+
version: "1.0",
|
|
507
|
+
ruleset: "approval_workflow",
|
|
508
|
+
rules: [
|
|
509
|
+
{
|
|
510
|
+
id: "admin_auto_approve",
|
|
511
|
+
if: { field: "user.role", op: "eq", value: "admin" },
|
|
512
|
+
then: { decision: "approve", weight: 0.95, reason: "Admin user" }
|
|
513
|
+
},
|
|
514
|
+
{
|
|
515
|
+
id: "low_amount_approve",
|
|
516
|
+
if: { field: "amount", op: "lt", value: 1000 },
|
|
517
|
+
then: { decision: "approve", weight: 0.8, reason: "Low amount" }
|
|
518
|
+
},
|
|
519
|
+
{
|
|
520
|
+
id: "high_amount_review",
|
|
521
|
+
if: { field: "amount", op: "gte", value: 10_000 },
|
|
522
|
+
then: { decision: "manual_review", weight: 0.9, reason: "High amount requires review" }
|
|
523
|
+
}
|
|
524
|
+
]
|
|
525
|
+
}
|
|
526
|
+
},
|
|
527
|
+
{
|
|
528
|
+
name: "User Access Control",
|
|
529
|
+
description: "Role-based access control rules",
|
|
530
|
+
rules: {
|
|
531
|
+
version: "1.0",
|
|
532
|
+
ruleset: "access_control",
|
|
533
|
+
rules: [
|
|
534
|
+
{
|
|
535
|
+
id: "admin_full_access",
|
|
536
|
+
if: {
|
|
537
|
+
all: [
|
|
538
|
+
{ field: "user.role", op: "eq", value: "admin" },
|
|
539
|
+
{ field: "user.active", op: "eq", value: true }
|
|
540
|
+
]
|
|
541
|
+
},
|
|
542
|
+
then: { decision: "allow", weight: 1.0, reason: "Active admin user" }
|
|
543
|
+
},
|
|
544
|
+
{
|
|
545
|
+
id: "guest_read_only",
|
|
546
|
+
if: {
|
|
547
|
+
all: [
|
|
548
|
+
{ field: "user.role", op: "eq", value: "guest" },
|
|
549
|
+
{ field: "action", op: "eq", value: "read" }
|
|
550
|
+
]
|
|
551
|
+
},
|
|
552
|
+
then: { decision: "allow", weight: 0.7, reason: "Guest read access" }
|
|
553
|
+
},
|
|
554
|
+
{
|
|
555
|
+
id: "inactive_user_deny",
|
|
556
|
+
if: { field: "user.active", op: "eq", value: false },
|
|
557
|
+
then: { decision: "deny", weight: 1.0, reason: "Inactive user account" }
|
|
558
|
+
}
|
|
559
|
+
]
|
|
560
|
+
}
|
|
561
|
+
},
|
|
562
|
+
{
|
|
563
|
+
name: "Content Moderation",
|
|
564
|
+
description: "Automatic content moderation rules",
|
|
565
|
+
rules: {
|
|
566
|
+
version: "1.0",
|
|
567
|
+
ruleset: "content_moderation",
|
|
568
|
+
rules: [
|
|
569
|
+
{
|
|
570
|
+
id: "verified_user_approve",
|
|
571
|
+
if: {
|
|
572
|
+
all: [
|
|
573
|
+
{ field: "author.verified", op: "eq", value: true },
|
|
574
|
+
{ field: "content_length", op: "lt", value: 5000 }
|
|
575
|
+
]
|
|
576
|
+
},
|
|
577
|
+
then: { decision: "approve", weight: 0.85, reason: "Verified author with reasonable length" }
|
|
578
|
+
},
|
|
579
|
+
{
|
|
580
|
+
id: "missing_content_reject",
|
|
581
|
+
if: {
|
|
582
|
+
any: [
|
|
583
|
+
{ field: "content", op: "blank" },
|
|
584
|
+
{ field: "content_length", op: "eq", value: 0 }
|
|
585
|
+
]
|
|
586
|
+
},
|
|
587
|
+
then: { decision: "reject", weight: 1.0, reason: "Empty content" }
|
|
588
|
+
},
|
|
589
|
+
{
|
|
590
|
+
id: "flagged_content_review",
|
|
591
|
+
if: { field: "flags", op: "present" },
|
|
592
|
+
then: { decision: "manual_review", weight: 0.9, reason: "Content has been flagged" }
|
|
593
|
+
}
|
|
594
|
+
]
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
]
|
|
491
598
|
|
|
492
|
-
|
|
493
|
-
user.to_h.to_json
|
|
494
|
-
rescue JSON::ParserError
|
|
495
|
-
status 400
|
|
496
|
-
{ error: "Invalid JSON" }.to_json
|
|
497
|
-
rescue StandardError => e
|
|
498
|
-
status 500
|
|
499
|
-
{ error: e.message }.to_json
|
|
599
|
+
ctx.json(examples)
|
|
500
600
|
end
|
|
501
|
-
end
|
|
502
601
|
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
602
|
+
# Health check
|
|
603
|
+
router.get "/health" do |ctx|
|
|
604
|
+
ctx.content_type "application/json"
|
|
605
|
+
ctx.json({ status: "ok", version: DecisionAgent::VERSION })
|
|
606
|
+
end
|
|
507
607
|
|
|
508
|
-
|
|
509
|
-
users.to_json
|
|
510
|
-
end
|
|
608
|
+
# Authentication API endpoints
|
|
511
609
|
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
require_permission!(:manage_users)
|
|
610
|
+
# POST /api/auth/login - User login
|
|
611
|
+
router.post "/api/auth/login" do |ctx|
|
|
612
|
+
ctx.content_type "application/json"
|
|
516
613
|
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
data = JSON.parse(request_body)
|
|
614
|
+
begin
|
|
615
|
+
request_body = RackRequestHelpers.read_body(ctx.env)
|
|
616
|
+
data = JSON.parse(request_body)
|
|
521
617
|
|
|
522
|
-
|
|
618
|
+
email = data["email"]
|
|
619
|
+
password = data["password"]
|
|
523
620
|
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
621
|
+
unless email && password
|
|
622
|
+
ctx.status(400)
|
|
623
|
+
ctx.json({ error: "Email and password are required" })
|
|
624
|
+
next
|
|
625
|
+
end
|
|
528
626
|
|
|
529
|
-
|
|
530
|
-
status 400
|
|
531
|
-
return { error: "Invalid role: #{role}" }.to_json
|
|
532
|
-
end
|
|
627
|
+
session = Server.authenticator.login(email, password)
|
|
533
628
|
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
629
|
+
unless session
|
|
630
|
+
Server.access_audit_logger.log_authentication(
|
|
631
|
+
"login",
|
|
632
|
+
user_id: nil,
|
|
633
|
+
email: email,
|
|
634
|
+
success: false,
|
|
635
|
+
reason: "Invalid credentials"
|
|
636
|
+
)
|
|
637
|
+
ctx.status(401)
|
|
638
|
+
ctx.json({ error: "Invalid email or password" })
|
|
639
|
+
next
|
|
640
|
+
end
|
|
539
641
|
|
|
540
|
-
|
|
642
|
+
user = Server.authenticator.find_user(session.user_id)
|
|
541
643
|
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
success: true
|
|
549
|
-
)
|
|
644
|
+
Server.access_audit_logger.log_authentication(
|
|
645
|
+
"login",
|
|
646
|
+
user_id: user.id,
|
|
647
|
+
email: user.email,
|
|
648
|
+
success: true
|
|
649
|
+
)
|
|
550
650
|
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
651
|
+
ctx.json({
|
|
652
|
+
token: session.token,
|
|
653
|
+
user: user.to_h,
|
|
654
|
+
expires_at: session.expires_at.iso8601
|
|
655
|
+
})
|
|
656
|
+
rescue JSON::ParserError
|
|
657
|
+
ctx.status(400)
|
|
658
|
+
ctx.json({ error: "Invalid JSON" })
|
|
659
|
+
rescue StandardError => e
|
|
660
|
+
ctx.status(500)
|
|
661
|
+
ctx.json({ error: e.message })
|
|
662
|
+
end
|
|
558
663
|
end
|
|
559
|
-
end
|
|
560
664
|
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
require_permission!(:manage_users)
|
|
665
|
+
# POST /api/auth/logout - User logout
|
|
666
|
+
router.post "/api/auth/logout" do |ctx|
|
|
667
|
+
ctx.content_type "application/json"
|
|
565
668
|
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
669
|
+
begin
|
|
670
|
+
token = Server.extract_token(ctx)
|
|
671
|
+
if token
|
|
672
|
+
Server.authenticator.logout(token)
|
|
673
|
+
if ctx.current_user
|
|
674
|
+
checker = Server.permission_checker
|
|
675
|
+
Server.access_audit_logger.log_authentication(
|
|
676
|
+
"logout",
|
|
677
|
+
user_id: checker.user_id(ctx.current_user),
|
|
678
|
+
email: checker.user_email(ctx.current_user),
|
|
679
|
+
success: true
|
|
680
|
+
)
|
|
681
|
+
end
|
|
682
|
+
end
|
|
569
683
|
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
status
|
|
573
|
-
|
|
684
|
+
ctx.json({ success: true, message: "Logged out successfully" })
|
|
685
|
+
rescue StandardError => e
|
|
686
|
+
ctx.status(500)
|
|
687
|
+
ctx.json({ error: e.message })
|
|
574
688
|
end
|
|
689
|
+
end
|
|
575
690
|
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
self.class.access_audit_logger.log_access(
|
|
580
|
-
user_id: checker.user_id(@current_user),
|
|
581
|
-
action: "remove_role",
|
|
582
|
-
resource_type: "user",
|
|
583
|
-
resource_id: user.id,
|
|
584
|
-
success: true
|
|
585
|
-
)
|
|
691
|
+
# GET /api/auth/me - Current user info
|
|
692
|
+
router.get "/api/auth/me" do |ctx|
|
|
693
|
+
ctx.content_type "application/json"
|
|
586
694
|
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
695
|
+
if ctx.current_user
|
|
696
|
+
ctx.json(ctx.current_user.to_h)
|
|
697
|
+
else
|
|
698
|
+
ctx.status(401)
|
|
699
|
+
ctx.json({ error: "Not authenticated" })
|
|
700
|
+
end
|
|
591
701
|
end
|
|
592
|
-
end
|
|
593
|
-
|
|
594
|
-
# GET /api/auth/audit - Query access audit logs
|
|
595
|
-
get "/api/auth/audit" do
|
|
596
|
-
content_type :json
|
|
597
|
-
require_permission!(:audit)
|
|
598
702
|
|
|
599
|
-
|
|
600
|
-
|
|
703
|
+
# GET /api/auth/roles - List all roles
|
|
704
|
+
router.get "/api/auth/roles" do |ctx|
|
|
705
|
+
ctx.content_type "application/json"
|
|
706
|
+
Server.require_permission!(ctx, :read)
|
|
707
|
+
next if ctx.halted?
|
|
601
708
|
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
709
|
+
roles = Auth::Role.all.map do |role|
|
|
710
|
+
{
|
|
711
|
+
id: role.to_s,
|
|
712
|
+
name: Auth::Role.name_for(role),
|
|
713
|
+
permissions: Auth::Role.permissions_for(role).map(&:to_s)
|
|
714
|
+
}
|
|
715
|
+
end
|
|
607
716
|
|
|
608
|
-
|
|
609
|
-
logs.to_json
|
|
610
|
-
rescue StandardError => e
|
|
611
|
-
status 500
|
|
612
|
-
{ error: e.message }.to_json
|
|
717
|
+
ctx.json(roles)
|
|
613
718
|
end
|
|
614
|
-
end
|
|
615
719
|
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
720
|
+
# POST /api/auth/users - Create user (admin only)
|
|
721
|
+
router.post "/api/auth/users" do |ctx|
|
|
722
|
+
ctx.content_type "application/json"
|
|
723
|
+
Server.require_permission!(ctx, :manage_users)
|
|
724
|
+
next if ctx.halted?
|
|
619
725
|
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
726
|
+
begin
|
|
727
|
+
request_body = RackRequestHelpers.read_body(ctx.env)
|
|
728
|
+
data = JSON.parse(request_body)
|
|
623
729
|
|
|
624
|
-
|
|
730
|
+
email = data["email"]
|
|
731
|
+
password = data["password"]
|
|
732
|
+
roles = data["roles"] || []
|
|
625
733
|
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
734
|
+
unless email && password
|
|
735
|
+
ctx.status(400)
|
|
736
|
+
ctx.json({ error: "Email and password are required" })
|
|
737
|
+
next
|
|
738
|
+
end
|
|
739
|
+
|
|
740
|
+
# Validate roles
|
|
741
|
+
invalid_role = nil
|
|
742
|
+
roles.each do |role|
|
|
743
|
+
unless Auth::Role.exists?(role)
|
|
744
|
+
invalid_role = role
|
|
745
|
+
break
|
|
746
|
+
end
|
|
747
|
+
end
|
|
630
748
|
|
|
631
|
-
|
|
749
|
+
if invalid_role
|
|
750
|
+
ctx.status(400)
|
|
751
|
+
ctx.json({ error: "Invalid role: #{invalid_role}" })
|
|
752
|
+
next
|
|
753
|
+
end
|
|
632
754
|
|
|
633
|
-
|
|
634
|
-
# In production, you would send the token via email
|
|
635
|
-
if token
|
|
636
|
-
self.class.access_audit_logger.log_authentication(
|
|
637
|
-
"password_reset_request",
|
|
638
|
-
user_id: token.user_id,
|
|
755
|
+
user = Server.authenticator.create_user(
|
|
639
756
|
email: email,
|
|
640
|
-
|
|
757
|
+
password: password,
|
|
758
|
+
roles: roles
|
|
641
759
|
)
|
|
642
760
|
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
else
|
|
651
|
-
# Log failed attempt (but don't reveal if user exists)
|
|
652
|
-
self.class.access_audit_logger.log_authentication(
|
|
653
|
-
"password_reset_request",
|
|
654
|
-
user_id: nil,
|
|
655
|
-
email: email,
|
|
656
|
-
success: false,
|
|
657
|
-
reason: "User not found or inactive"
|
|
761
|
+
checker = Server.permission_checker
|
|
762
|
+
Server.access_audit_logger.log_access(
|
|
763
|
+
user_id: checker.user_id(ctx.current_user),
|
|
764
|
+
action: "create_user",
|
|
765
|
+
resource_type: "user",
|
|
766
|
+
resource_id: user.id,
|
|
767
|
+
success: true
|
|
658
768
|
)
|
|
659
769
|
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
770
|
+
ctx.status(201)
|
|
771
|
+
ctx.json(user.to_h)
|
|
772
|
+
rescue JSON::ParserError
|
|
773
|
+
ctx.status(400)
|
|
774
|
+
ctx.json({ error: "Invalid JSON" })
|
|
775
|
+
rescue StandardError => e
|
|
776
|
+
ctx.status(500)
|
|
777
|
+
ctx.json({ error: e.message })
|
|
664
778
|
end
|
|
665
|
-
rescue JSON::ParserError
|
|
666
|
-
status 400
|
|
667
|
-
{ error: "Invalid JSON" }.to_json
|
|
668
|
-
rescue StandardError => e
|
|
669
|
-
status 500
|
|
670
|
-
{ error: e.message }.to_json
|
|
671
779
|
end
|
|
672
|
-
end
|
|
673
780
|
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
781
|
+
# GET /api/auth/users - List users (admin only)
|
|
782
|
+
router.get "/api/auth/users" do |ctx|
|
|
783
|
+
ctx.content_type "application/json"
|
|
784
|
+
Server.require_permission!(ctx, :manage_users)
|
|
785
|
+
next if ctx.halted?
|
|
677
786
|
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
token = data["token"]
|
|
683
|
-
new_password = data["password"]
|
|
684
|
-
|
|
685
|
-
unless token && new_password
|
|
686
|
-
status 400
|
|
687
|
-
return { error: "Token and password are required" }.to_json
|
|
688
|
-
end
|
|
787
|
+
users = Server.authenticator.user_store.all.map(&:to_h)
|
|
788
|
+
ctx.json(users)
|
|
789
|
+
end
|
|
689
790
|
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
791
|
+
# POST /api/auth/users/:id/roles - Assign role to user (admin only)
|
|
792
|
+
router.post "/api/auth/users/:id/roles" do |ctx|
|
|
793
|
+
ctx.content_type "application/json"
|
|
794
|
+
Server.require_permission!(ctx, :manage_users)
|
|
795
|
+
next if ctx.halted?
|
|
694
796
|
|
|
695
|
-
|
|
797
|
+
begin
|
|
798
|
+
user_id = ctx.params[:id] || ctx.params["id"]
|
|
799
|
+
request_body = RackRequestHelpers.read_body(ctx.env)
|
|
800
|
+
data = JSON.parse(request_body)
|
|
696
801
|
|
|
697
|
-
|
|
698
|
-
status 400
|
|
699
|
-
return { error: "Invalid or expired reset token" }.to_json
|
|
700
|
-
end
|
|
802
|
+
role = data["role"]
|
|
701
803
|
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
)
|
|
804
|
+
unless role
|
|
805
|
+
ctx.status(400)
|
|
806
|
+
ctx.json({ error: "Role is required" })
|
|
807
|
+
next
|
|
808
|
+
end
|
|
708
809
|
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
status 400
|
|
715
|
-
{ error: "Invalid JSON" }.to_json
|
|
716
|
-
rescue StandardError => e
|
|
717
|
-
status 500
|
|
718
|
-
{ error: e.message }.to_json
|
|
719
|
-
end
|
|
720
|
-
end
|
|
810
|
+
unless Auth::Role.exists?(role)
|
|
811
|
+
ctx.status(400)
|
|
812
|
+
ctx.json({ error: "Invalid role: #{role}" })
|
|
813
|
+
next
|
|
814
|
+
end
|
|
721
815
|
|
|
722
|
-
|
|
816
|
+
user = Server.authenticator.find_user(user_id)
|
|
817
|
+
unless user
|
|
818
|
+
ctx.status(404)
|
|
819
|
+
ctx.json({ error: "User not found" })
|
|
820
|
+
next
|
|
821
|
+
end
|
|
723
822
|
|
|
724
|
-
|
|
725
|
-
post "/api/versions" do
|
|
726
|
-
content_type :json
|
|
727
|
-
require_permission!(:write)
|
|
823
|
+
user.assign_role(role)
|
|
728
824
|
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
version = version_manager.save_version(
|
|
739
|
-
rule_id: rule_id,
|
|
740
|
-
rule_content: rule_content,
|
|
741
|
-
created_by: created_by,
|
|
742
|
-
changelog: changelog
|
|
743
|
-
)
|
|
825
|
+
checker = Server.permission_checker
|
|
826
|
+
Server.access_audit_logger.log_access(
|
|
827
|
+
user_id: checker.user_id(ctx.current_user),
|
|
828
|
+
action: "assign_role",
|
|
829
|
+
resource_type: "user",
|
|
830
|
+
resource_id: user.id,
|
|
831
|
+
success: true
|
|
832
|
+
)
|
|
744
833
|
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
834
|
+
ctx.json(user.to_h)
|
|
835
|
+
rescue JSON::ParserError
|
|
836
|
+
ctx.status(400)
|
|
837
|
+
ctx.json({ error: "Invalid JSON" })
|
|
838
|
+
rescue StandardError => e
|
|
839
|
+
ctx.status(500)
|
|
840
|
+
ctx.json({ error: e.message })
|
|
841
|
+
end
|
|
750
842
|
end
|
|
751
|
-
end
|
|
752
843
|
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
844
|
+
# DELETE /api/auth/users/:id/roles/:role - Remove role from user (admin only)
|
|
845
|
+
router.delete "/api/auth/users/:id/roles/:role" do |ctx|
|
|
846
|
+
ctx.content_type "application/json"
|
|
847
|
+
Server.require_permission!(ctx, :manage_users)
|
|
848
|
+
next if ctx.halted?
|
|
757
849
|
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
850
|
+
begin
|
|
851
|
+
user_id = ctx.params[:id] || ctx.params["id"]
|
|
852
|
+
role = ctx.params[:role] || ctx.params["role"]
|
|
853
|
+
|
|
854
|
+
user = Server.authenticator.find_user(user_id)
|
|
855
|
+
unless user
|
|
856
|
+
ctx.status(404)
|
|
857
|
+
ctx.json({ error: "User not found" })
|
|
858
|
+
next
|
|
859
|
+
end
|
|
761
860
|
|
|
762
|
-
|
|
861
|
+
user.remove_role(role)
|
|
763
862
|
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
863
|
+
checker = Server.permission_checker
|
|
864
|
+
Server.access_audit_logger.log_access(
|
|
865
|
+
user_id: checker.user_id(ctx.current_user),
|
|
866
|
+
action: "remove_role",
|
|
867
|
+
resource_type: "user",
|
|
868
|
+
resource_id: user.id,
|
|
869
|
+
success: true
|
|
870
|
+
)
|
|
871
|
+
|
|
872
|
+
ctx.json(user.to_h)
|
|
873
|
+
rescue StandardError => e
|
|
874
|
+
ctx.status(500)
|
|
875
|
+
ctx.json({ error: e.message })
|
|
876
|
+
end
|
|
768
877
|
end
|
|
769
|
-
end
|
|
770
878
|
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
879
|
+
# GET /api/auth/audit - Query access audit logs
|
|
880
|
+
router.get "/api/auth/audit" do |ctx|
|
|
881
|
+
ctx.content_type "application/json"
|
|
882
|
+
Server.require_permission!(ctx, :audit)
|
|
883
|
+
next if ctx.halted?
|
|
775
884
|
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
885
|
+
begin
|
|
886
|
+
filters = {}
|
|
887
|
+
|
|
888
|
+
filters[:user_id] = ctx.params[:user_id] || ctx.params["user_id"] if ctx.params[:user_id] || ctx.params["user_id"]
|
|
889
|
+
filters[:event_type] = ctx.params[:event_type] || ctx.params["event_type"] if ctx.params[:event_type] || ctx.params["event_type"]
|
|
890
|
+
filters[:start_time] = ctx.params[:start_time] || ctx.params["start_time"] if ctx.params[:start_time] || ctx.params["start_time"]
|
|
891
|
+
filters[:end_time] = ctx.params[:end_time] || ctx.params["end_time"] if ctx.params[:end_time] || ctx.params["end_time"]
|
|
892
|
+
filters[:limit] = (ctx.params[:limit] || ctx.params["limit"])&.to_i if ctx.params[:limit] || ctx.params["limit"]
|
|
893
|
+
|
|
894
|
+
logs = Server.access_audit_logger.query(filters)
|
|
895
|
+
ctx.json(logs)
|
|
896
|
+
rescue StandardError => e
|
|
897
|
+
ctx.status(500)
|
|
898
|
+
ctx.json({ error: e.message })
|
|
899
|
+
end
|
|
900
|
+
end
|
|
779
901
|
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
902
|
+
# POST /api/auth/password/reset-request - Request password reset
|
|
903
|
+
router.post "/api/auth/password/reset-request" do |ctx|
|
|
904
|
+
ctx.content_type "application/json"
|
|
905
|
+
|
|
906
|
+
begin
|
|
907
|
+
request_body = RackRequestHelpers.read_body(ctx.env)
|
|
908
|
+
data = JSON.parse(request_body)
|
|
909
|
+
|
|
910
|
+
email = data["email"]
|
|
911
|
+
|
|
912
|
+
unless email
|
|
913
|
+
ctx.status(400)
|
|
914
|
+
ctx.json({ error: "Email is required" })
|
|
915
|
+
next
|
|
916
|
+
end
|
|
917
|
+
|
|
918
|
+
token = Server.authenticator.request_password_reset(email)
|
|
919
|
+
|
|
920
|
+
# For security, we always return success even if user doesn't exist
|
|
921
|
+
# In production, you would send the token via email
|
|
922
|
+
if token
|
|
923
|
+
Server.access_audit_logger.log_authentication(
|
|
924
|
+
"password_reset_request",
|
|
925
|
+
user_id: token.user_id,
|
|
926
|
+
email: email,
|
|
927
|
+
success: true
|
|
928
|
+
)
|
|
929
|
+
|
|
930
|
+
ctx.json({
|
|
931
|
+
success: true,
|
|
932
|
+
message: "If the email exists, a password reset token has been generated",
|
|
933
|
+
# In production, remove this token from response and send via email
|
|
934
|
+
token: token.token,
|
|
935
|
+
expires_at: token.expires_at.iso8601
|
|
936
|
+
})
|
|
937
|
+
else
|
|
938
|
+
# Log failed attempt (but don't reveal if user exists)
|
|
939
|
+
Server.access_audit_logger.log_authentication(
|
|
940
|
+
"password_reset_request",
|
|
941
|
+
user_id: nil,
|
|
942
|
+
email: email,
|
|
943
|
+
success: false,
|
|
944
|
+
reason: "User not found or inactive"
|
|
945
|
+
)
|
|
946
|
+
|
|
947
|
+
ctx.json({
|
|
948
|
+
success: true,
|
|
949
|
+
message: "If the email exists, a password reset token has been generated"
|
|
950
|
+
})
|
|
951
|
+
end
|
|
952
|
+
rescue JSON::ParserError
|
|
953
|
+
ctx.status(400)
|
|
954
|
+
ctx.json({ error: "Invalid JSON" })
|
|
955
|
+
rescue StandardError => e
|
|
956
|
+
ctx.status(500)
|
|
957
|
+
ctx.json({ error: e.message })
|
|
958
|
+
end
|
|
784
959
|
end
|
|
785
|
-
end
|
|
786
960
|
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
require_permission!(:read)
|
|
961
|
+
# POST /api/auth/password/reset - Reset password with token
|
|
962
|
+
router.post "/api/auth/password/reset" do |ctx|
|
|
963
|
+
ctx.content_type "application/json"
|
|
791
964
|
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
965
|
+
begin
|
|
966
|
+
request_body = RackRequestHelpers.read_body(ctx.env)
|
|
967
|
+
data = JSON.parse(request_body)
|
|
795
968
|
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
969
|
+
token = data["token"]
|
|
970
|
+
new_password = data["password"]
|
|
971
|
+
|
|
972
|
+
unless token && new_password
|
|
973
|
+
ctx.status(400)
|
|
974
|
+
ctx.json({ error: "Token and password are required" })
|
|
975
|
+
next
|
|
976
|
+
end
|
|
977
|
+
|
|
978
|
+
unless new_password.length >= 8
|
|
979
|
+
ctx.status(400)
|
|
980
|
+
ctx.json({ error: "Password must be at least 8 characters long" })
|
|
981
|
+
next
|
|
982
|
+
end
|
|
983
|
+
|
|
984
|
+
user = Server.authenticator.reset_password(token, new_password)
|
|
985
|
+
|
|
986
|
+
unless user
|
|
987
|
+
ctx.status(400)
|
|
988
|
+
ctx.json({ error: "Invalid or expired reset token" })
|
|
989
|
+
next
|
|
990
|
+
end
|
|
991
|
+
|
|
992
|
+
Server.access_audit_logger.log_authentication(
|
|
993
|
+
"password_reset",
|
|
994
|
+
user_id: user.id,
|
|
995
|
+
email: user.email,
|
|
996
|
+
success: true
|
|
997
|
+
)
|
|
998
|
+
|
|
999
|
+
ctx.json({
|
|
1000
|
+
success: true,
|
|
1001
|
+
message: "Password has been reset successfully"
|
|
1002
|
+
})
|
|
1003
|
+
rescue JSON::ParserError
|
|
1004
|
+
ctx.status(400)
|
|
1005
|
+
ctx.json({ error: "Invalid JSON" })
|
|
1006
|
+
rescue StandardError => e
|
|
1007
|
+
ctx.status(500)
|
|
1008
|
+
ctx.json({ error: e.message })
|
|
801
1009
|
end
|
|
802
|
-
rescue StandardError => e
|
|
803
|
-
status 500
|
|
804
|
-
{ error: e.message }.to_json
|
|
805
1010
|
end
|
|
806
|
-
end
|
|
807
1011
|
|
|
808
|
-
|
|
809
|
-
post "/api/versions/:version_id/activate" do
|
|
810
|
-
content_type :json
|
|
811
|
-
require_permission!(:deploy)
|
|
1012
|
+
# Versioning API endpoints
|
|
812
1013
|
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
version = version_manager.rollback(
|
|
820
|
-
version_id: version_id,
|
|
821
|
-
performed_by: performed_by
|
|
822
|
-
)
|
|
1014
|
+
# Create a new version
|
|
1015
|
+
router.post "/api/versions" do |ctx|
|
|
1016
|
+
ctx.content_type "application/json"
|
|
1017
|
+
Server.require_permission!(ctx, :write)
|
|
1018
|
+
next if ctx.halted?
|
|
823
1019
|
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
1020
|
+
begin
|
|
1021
|
+
request_body = RackRequestHelpers.read_body(ctx.env)
|
|
1022
|
+
data = JSON.parse(request_body)
|
|
1023
|
+
|
|
1024
|
+
rule_id = data["rule_id"]
|
|
1025
|
+
rule_content = data["content"]
|
|
1026
|
+
created_by = data["created_by"] || (ctx.current_user&.email || "system")
|
|
1027
|
+
changelog = data["changelog"]
|
|
1028
|
+
|
|
1029
|
+
version = Server.version_manager.save_version(
|
|
1030
|
+
rule_id: rule_id,
|
|
1031
|
+
rule_content: rule_content,
|
|
1032
|
+
created_by: created_by,
|
|
1033
|
+
changelog: changelog
|
|
1034
|
+
)
|
|
1035
|
+
|
|
1036
|
+
ctx.status(201)
|
|
1037
|
+
ctx.json(version)
|
|
1038
|
+
rescue StandardError => e
|
|
1039
|
+
ctx.status(500)
|
|
1040
|
+
ctx.json({ error: e.message })
|
|
1041
|
+
end
|
|
828
1042
|
end
|
|
829
|
-
end
|
|
830
1043
|
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
1044
|
+
# List all versions for a rule
|
|
1045
|
+
router.get "/api/rules/:rule_id/versions" do |ctx|
|
|
1046
|
+
ctx.content_type "application/json"
|
|
1047
|
+
Server.require_permission!(ctx, :read)
|
|
1048
|
+
next if ctx.halted?
|
|
835
1049
|
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
1050
|
+
begin
|
|
1051
|
+
rule_id = ctx.params[:rule_id] || ctx.params["rule_id"]
|
|
1052
|
+
limit = (ctx.params[:limit] || ctx.params["limit"])&.to_i
|
|
839
1053
|
|
|
840
|
-
|
|
841
|
-
version_id_1: version_id_1,
|
|
842
|
-
version_id_2: version_id_2
|
|
843
|
-
)
|
|
1054
|
+
versions = Server.version_manager.get_versions(rule_id: rule_id, limit: limit)
|
|
844
1055
|
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
{ error: "One or both versions not found" }.to_json
|
|
1056
|
+
ctx.json(versions)
|
|
1057
|
+
rescue StandardError => e
|
|
1058
|
+
ctx.status(500)
|
|
1059
|
+
ctx.json({ error: e.message })
|
|
850
1060
|
end
|
|
851
|
-
rescue StandardError => e
|
|
852
|
-
status 500
|
|
853
|
-
{ error: e.message }.to_json
|
|
854
1061
|
end
|
|
855
|
-
end
|
|
856
1062
|
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
1063
|
+
# Get version history with metadata
|
|
1064
|
+
router.get "/api/rules/:rule_id/history" do |ctx|
|
|
1065
|
+
ctx.content_type "application/json"
|
|
1066
|
+
Server.require_permission!(ctx, :read)
|
|
1067
|
+
next if ctx.halted?
|
|
860
1068
|
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
1069
|
+
begin
|
|
1070
|
+
rule_id = ctx.params[:rule_id] || ctx.params["rule_id"]
|
|
1071
|
+
history = Server.version_manager.get_history(rule_id: rule_id)
|
|
864
1072
|
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
status
|
|
868
|
-
|
|
1073
|
+
ctx.json(history)
|
|
1074
|
+
rescue StandardError => e
|
|
1075
|
+
ctx.status(500)
|
|
1076
|
+
ctx.json({ error: e.message })
|
|
869
1077
|
end
|
|
1078
|
+
end
|
|
870
1079
|
|
|
871
|
-
|
|
1080
|
+
# Get a specific version
|
|
1081
|
+
router.get "/api/versions/:version_id" do |ctx|
|
|
1082
|
+
ctx.content_type "application/json"
|
|
1083
|
+
Server.require_permission!(ctx, :read)
|
|
1084
|
+
next if ctx.halted?
|
|
872
1085
|
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
1086
|
+
begin
|
|
1087
|
+
version_id = ctx.params[:version_id] || ctx.params["version_id"]
|
|
1088
|
+
version = Server.version_manager.get_version(version_id: version_id)
|
|
1089
|
+
|
|
1090
|
+
if version
|
|
1091
|
+
ctx.json(version)
|
|
1092
|
+
else
|
|
1093
|
+
ctx.status(404)
|
|
1094
|
+
ctx.json({ error: "Version not found" })
|
|
1095
|
+
end
|
|
1096
|
+
rescue StandardError => e
|
|
1097
|
+
ctx.status(500)
|
|
1098
|
+
ctx.json({ error: e.message })
|
|
879
1099
|
end
|
|
880
|
-
rescue DecisionAgent::NotFoundError => e
|
|
881
|
-
status 404
|
|
882
|
-
{ error: e.message }.to_json
|
|
883
|
-
rescue DecisionAgent::ValidationError => e
|
|
884
|
-
status 422
|
|
885
|
-
{ error: e.message }.to_json
|
|
886
|
-
rescue StandardError
|
|
887
|
-
# Log the error for debugging but return a safe response
|
|
888
|
-
# In production, you might want to log this to a proper logger
|
|
889
|
-
status 500
|
|
890
|
-
{ error: "Internal server error" }.to_json
|
|
891
1100
|
end
|
|
892
|
-
end
|
|
893
1101
|
|
|
894
|
-
|
|
1102
|
+
# Activate a version (rollback)
|
|
1103
|
+
router.post "/api/versions/:version_id/activate" do |ctx|
|
|
1104
|
+
ctx.content_type "application/json"
|
|
1105
|
+
Server.require_permission!(ctx, :deploy)
|
|
1106
|
+
next if ctx.halted?
|
|
895
1107
|
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
1108
|
+
begin
|
|
1109
|
+
version_id = ctx.params[:version_id] || ctx.params["version_id"]
|
|
1110
|
+
request_body = RackRequestHelpers.read_body(ctx.env)
|
|
1111
|
+
data = request_body.empty? ? {} : JSON.parse(request_body)
|
|
1112
|
+
performed_by = data["performed_by"] || (ctx.current_user&.email || "system")
|
|
1113
|
+
|
|
1114
|
+
version = Server.version_manager.rollback(
|
|
1115
|
+
version_id: version_id,
|
|
1116
|
+
performed_by: performed_by
|
|
1117
|
+
)
|
|
899
1118
|
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
status
|
|
903
|
-
|
|
1119
|
+
ctx.json(version)
|
|
1120
|
+
rescue StandardError => e
|
|
1121
|
+
ctx.status(500)
|
|
1122
|
+
ctx.json({ error: e.message })
|
|
904
1123
|
end
|
|
1124
|
+
end
|
|
905
1125
|
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
1126
|
+
# Compare two versions
|
|
1127
|
+
router.get "/api/versions/:version_id_1/compare/:version_id_2" do |ctx|
|
|
1128
|
+
ctx.content_type "application/json"
|
|
1129
|
+
Server.require_permission!(ctx, :read)
|
|
1130
|
+
next if ctx.halted?
|
|
909
1131
|
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
temp_file.write(uploaded_file.read)
|
|
914
|
-
temp_file.rewind
|
|
1132
|
+
begin
|
|
1133
|
+
version_id_1 = ctx.params[:version_id_1] || ctx.params["version_id_1"]
|
|
1134
|
+
version_id_2 = ctx.params[:version_id_2] || ctx.params["version_id_2"]
|
|
915
1135
|
|
|
916
|
-
|
|
917
|
-
|
|
1136
|
+
comparison = Server.version_manager.compare(
|
|
1137
|
+
version_id_1: version_id_1,
|
|
1138
|
+
version_id_2: version_id_2
|
|
1139
|
+
)
|
|
918
1140
|
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
1141
|
+
if comparison
|
|
1142
|
+
ctx.json(comparison)
|
|
1143
|
+
else
|
|
1144
|
+
ctx.status(404)
|
|
1145
|
+
ctx.json({ error: "One or both versions not found" })
|
|
1146
|
+
end
|
|
1147
|
+
rescue StandardError => e
|
|
1148
|
+
ctx.status(500)
|
|
1149
|
+
ctx.json({ error: e.message })
|
|
1150
|
+
end
|
|
1151
|
+
end
|
|
924
1152
|
|
|
925
|
-
|
|
926
|
-
|
|
1153
|
+
# Delete a version
|
|
1154
|
+
router.delete "/api/versions/:version_id" do |ctx|
|
|
1155
|
+
ctx.content_type "application/json"
|
|
927
1156
|
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
return { error: "Import failed: #{importer.errors.join('; ')}" }.to_json
|
|
932
|
-
end
|
|
1157
|
+
begin
|
|
1158
|
+
Server.require_permission!(ctx, :delete)
|
|
1159
|
+
next if ctx.halted?
|
|
933
1160
|
|
|
934
|
-
|
|
935
|
-
# to indicate partial failure
|
|
936
|
-
if importer.errors.any?
|
|
937
|
-
status 422
|
|
938
|
-
return {
|
|
939
|
-
error: "Import completed with errors: #{importer.errors.join('; ')}",
|
|
940
|
-
test_id: nil,
|
|
941
|
-
scenarios_count: scenarios.size,
|
|
942
|
-
errors: importer.errors,
|
|
943
|
-
warnings: importer.warnings
|
|
944
|
-
}.to_json
|
|
945
|
-
end
|
|
1161
|
+
version_id = ctx.params[:version_id] || ctx.params["version_id"]
|
|
946
1162
|
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
status: "imported",
|
|
954
|
-
created_at: Time.now.utc.iso8601,
|
|
955
|
-
results: nil,
|
|
956
|
-
coverage: nil
|
|
957
|
-
}
|
|
958
|
-
end
|
|
1163
|
+
# Ensure version_id is present
|
|
1164
|
+
unless version_id
|
|
1165
|
+
ctx.status(400)
|
|
1166
|
+
ctx.json({ error: "Version ID is required" })
|
|
1167
|
+
next
|
|
1168
|
+
end
|
|
959
1169
|
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
1170
|
+
result = Server.version_manager.delete_version(version_id: version_id)
|
|
1171
|
+
|
|
1172
|
+
if result == false
|
|
1173
|
+
ctx.status(404)
|
|
1174
|
+
ctx.json({ error: "Version not found" })
|
|
1175
|
+
else
|
|
1176
|
+
ctx.status(200)
|
|
1177
|
+
ctx.json({ success: true, message: "Version deleted successfully" })
|
|
1178
|
+
end
|
|
1179
|
+
rescue DecisionAgent::NotFoundError => e
|
|
1180
|
+
ctx.status(404)
|
|
1181
|
+
ctx.json({ error: e.message })
|
|
1182
|
+
rescue DecisionAgent::ValidationError => e
|
|
1183
|
+
ctx.status(422)
|
|
1184
|
+
ctx.json({ error: e.message })
|
|
1185
|
+
rescue StandardError => e
|
|
1186
|
+
# Log the error for debugging but return a safe response
|
|
1187
|
+
warn "[DecisionAgent] Version API error: #{e.message}"
|
|
1188
|
+
ctx.status(500)
|
|
1189
|
+
ctx.json({ error: "Internal server error" })
|
|
1190
|
+
end
|
|
973
1191
|
end
|
|
974
|
-
end
|
|
975
1192
|
|
|
976
|
-
|
|
977
|
-
post "/api/testing/batch/run" do
|
|
978
|
-
content_type :json
|
|
1193
|
+
# Batch Testing API Endpoints
|
|
979
1194
|
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
1195
|
+
# POST /api/testing/batch/import - Upload CSV/Excel file
|
|
1196
|
+
router.post "/api/testing/batch/import" do |ctx|
|
|
1197
|
+
ctx.content_type "application/json"
|
|
983
1198
|
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
1199
|
+
begin
|
|
1200
|
+
# Handle file upload from multipart form data
|
|
1201
|
+
file_param = ctx.params[:file] || ctx.params["file"]
|
|
987
1202
|
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
1203
|
+
unless file_param && (file_param[:tempfile] || file_param["tempfile"])
|
|
1204
|
+
ctx.status(400)
|
|
1205
|
+
ctx.json({ error: "No file uploaded" })
|
|
1206
|
+
next
|
|
1207
|
+
end
|
|
992
1208
|
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
1209
|
+
uploaded_file = file_param[:tempfile] || file_param["tempfile"]
|
|
1210
|
+
filename = file_param[:filename] || file_param["filename"] || "uploaded_file"
|
|
1211
|
+
file_extension = File.extname(filename).downcase
|
|
1212
|
+
|
|
1213
|
+
# Create temporary file
|
|
1214
|
+
temp_file = Tempfile.new(["batch_test", file_extension])
|
|
1215
|
+
temp_file.binmode
|
|
1216
|
+
temp_file.write(uploaded_file.read)
|
|
1217
|
+
temp_file.rewind
|
|
1218
|
+
|
|
1219
|
+
# Import scenarios based on file type
|
|
1220
|
+
importer = DecisionAgent::Testing::BatchTestImporter.new
|
|
1221
|
+
|
|
1222
|
+
scenarios = if [".xlsx", ".xls"].include?(file_extension)
|
|
1223
|
+
importer.import_excel(temp_file.path)
|
|
1224
|
+
else
|
|
1225
|
+
importer.import_csv(temp_file.path)
|
|
1226
|
+
end
|
|
1227
|
+
|
|
1228
|
+
temp_file.close
|
|
1229
|
+
temp_file.unlink
|
|
1230
|
+
|
|
1231
|
+
# Check for import errors - return error status if there are errors and no scenarios
|
|
1232
|
+
if importer.errors.any? && scenarios.empty?
|
|
1233
|
+
ctx.status(422)
|
|
1234
|
+
ctx.json({ error: "Import failed: #{importer.errors.join('; ')}" })
|
|
1235
|
+
next
|
|
1236
|
+
end
|
|
997
1237
|
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1238
|
+
# If there are errors but some scenarios were created, still return error status
|
|
1239
|
+
if importer.errors.any?
|
|
1240
|
+
ctx.status(422)
|
|
1241
|
+
ctx.json({
|
|
1242
|
+
error: "Import completed with errors: #{importer.errors.join('; ')}",
|
|
1243
|
+
test_id: nil,
|
|
1244
|
+
scenarios_count: scenarios.size,
|
|
1245
|
+
errors: importer.errors,
|
|
1246
|
+
warnings: importer.warnings
|
|
1247
|
+
})
|
|
1248
|
+
next
|
|
1249
|
+
end
|
|
1250
|
+
|
|
1251
|
+
# Store scenarios with a unique ID
|
|
1252
|
+
test_id = SecureRandom.uuid
|
|
1253
|
+
Server.batch_test_storage_mutex.synchronize do
|
|
1254
|
+
Server.batch_test_storage[test_id] = {
|
|
1255
|
+
id: test_id,
|
|
1256
|
+
scenarios: scenarios,
|
|
1257
|
+
status: "imported",
|
|
1258
|
+
created_at: Time.now.utc.iso8601,
|
|
1259
|
+
results: nil,
|
|
1260
|
+
coverage: nil
|
|
1261
|
+
}
|
|
1262
|
+
end
|
|
1003
1263
|
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1264
|
+
ctx.status(201)
|
|
1265
|
+
ctx.json({
|
|
1266
|
+
test_id: test_id,
|
|
1267
|
+
scenarios_count: scenarios.size,
|
|
1268
|
+
errors: importer.errors,
|
|
1269
|
+
warnings: importer.warnings
|
|
1270
|
+
})
|
|
1271
|
+
rescue DecisionAgent::ImportError => e
|
|
1272
|
+
ctx.status(422)
|
|
1273
|
+
ctx.json({ error: e.message, errors: importer&.errors || [] })
|
|
1274
|
+
rescue StandardError => e
|
|
1275
|
+
ctx.status(500)
|
|
1276
|
+
ctx.json({ error: "Failed to import file: #{e.message}" })
|
|
1007
1277
|
end
|
|
1278
|
+
end
|
|
1008
1279
|
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1280
|
+
# POST /api/testing/batch/run - Execute batch test
|
|
1281
|
+
router.post "/api/testing/batch/run" do |ctx|
|
|
1282
|
+
ctx.content_type "application/json"
|
|
1012
1283
|
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
self.class.batch_test_storage[test_id][:started_at] = Time.now.utc.iso8601
|
|
1017
|
-
end
|
|
1284
|
+
begin
|
|
1285
|
+
request_body = RackRequestHelpers.read_body(ctx.env)
|
|
1286
|
+
data = request_body.empty? ? {} : JSON.parse(request_body)
|
|
1018
1287
|
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
test_data[:scenarios],
|
|
1023
|
-
parallel: options.fetch("parallel", true),
|
|
1024
|
-
thread_count: options.fetch("thread_count", 4),
|
|
1025
|
-
checkpoint_file: options["checkpoint_file"]
|
|
1026
|
-
)
|
|
1288
|
+
test_id = data["test_id"] || (ctx.params[:test_id] || ctx.params["test_id"])
|
|
1289
|
+
rules_json = data["rules"]
|
|
1290
|
+
options = data["options"] || {}
|
|
1027
1291
|
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
end
|
|
1292
|
+
unless test_id
|
|
1293
|
+
ctx.status(400)
|
|
1294
|
+
ctx.json({ error: "test_id is required" })
|
|
1295
|
+
next
|
|
1296
|
+
end
|
|
1034
1297
|
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
self.class.batch_test_storage_mutex.synchronize do
|
|
1041
|
-
self.class.batch_test_storage[test_id][:status] = "completed"
|
|
1042
|
-
self.class.batch_test_storage[test_id][:results] = results.map(&:to_h)
|
|
1043
|
-
self.class.batch_test_storage[test_id][:comparison] = comparison
|
|
1044
|
-
self.class.batch_test_storage[test_id][:coverage] = coverage.to_h
|
|
1045
|
-
self.class.batch_test_storage[test_id][:statistics] = runner.statistics
|
|
1046
|
-
self.class.batch_test_storage[test_id][:completed_at] = Time.now.utc.iso8601
|
|
1047
|
-
end
|
|
1298
|
+
unless rules_json
|
|
1299
|
+
ctx.status(400)
|
|
1300
|
+
ctx.json({ error: "rules JSON is required" })
|
|
1301
|
+
next
|
|
1302
|
+
end
|
|
1048
1303
|
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1304
|
+
# Get stored scenarios
|
|
1305
|
+
test_data = nil
|
|
1306
|
+
Server.batch_test_storage_mutex.synchronize do
|
|
1307
|
+
test_data = Server.batch_test_storage[test_id]
|
|
1308
|
+
end
|
|
1309
|
+
|
|
1310
|
+
unless test_data
|
|
1311
|
+
ctx.status(404)
|
|
1312
|
+
ctx.json({ error: "Test not found" })
|
|
1313
|
+
next
|
|
1314
|
+
end
|
|
1315
|
+
|
|
1316
|
+
# Create agent from rules
|
|
1317
|
+
evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules_json)
|
|
1318
|
+
agent = DecisionAgent::Agent.new(evaluators: [evaluator])
|
|
1319
|
+
|
|
1320
|
+
# Update status
|
|
1321
|
+
Server.batch_test_storage_mutex.synchronize do
|
|
1322
|
+
Server.batch_test_storage[test_id][:status] = "running"
|
|
1323
|
+
Server.batch_test_storage[test_id][:started_at] = Time.now.utc.iso8601
|
|
1324
|
+
end
|
|
1325
|
+
|
|
1326
|
+
# Run batch test
|
|
1327
|
+
runner = DecisionAgent::Testing::BatchTestRunner.new(agent)
|
|
1328
|
+
results = runner.run(
|
|
1329
|
+
test_data[:scenarios],
|
|
1330
|
+
parallel: options.fetch("parallel", true),
|
|
1331
|
+
thread_count: options.fetch("thread_count", 4),
|
|
1332
|
+
checkpoint_file: options["checkpoint_file"]
|
|
1333
|
+
)
|
|
1334
|
+
|
|
1335
|
+
# Calculate comparison if expected results exist
|
|
1336
|
+
comparison = nil
|
|
1337
|
+
if test_data[:scenarios].any?(&:expected_result?)
|
|
1338
|
+
comparator = DecisionAgent::Testing::TestResultComparator.new
|
|
1339
|
+
comparison = comparator.compare(results, test_data[:scenarios])
|
|
1340
|
+
end
|
|
1341
|
+
|
|
1342
|
+
# Calculate coverage
|
|
1343
|
+
coverage_analyzer = DecisionAgent::Testing::TestCoverageAnalyzer.new
|
|
1344
|
+
coverage = coverage_analyzer.analyze(results, agent)
|
|
1345
|
+
|
|
1346
|
+
# Store results
|
|
1347
|
+
Server.batch_test_storage_mutex.synchronize do
|
|
1348
|
+
Server.batch_test_storage[test_id][:status] = "completed"
|
|
1349
|
+
Server.batch_test_storage[test_id][:results] = results.map(&:to_h)
|
|
1350
|
+
Server.batch_test_storage[test_id][:comparison] = comparison
|
|
1351
|
+
Server.batch_test_storage[test_id][:coverage] = coverage.to_h
|
|
1352
|
+
Server.batch_test_storage[test_id][:statistics] = runner.statistics
|
|
1353
|
+
Server.batch_test_storage[test_id][:completed_at] = Time.now.utc.iso8601
|
|
1354
|
+
end
|
|
1355
|
+
|
|
1356
|
+
ctx.json({
|
|
1357
|
+
test_id: test_id,
|
|
1358
|
+
status: "completed",
|
|
1359
|
+
results_count: results.size,
|
|
1360
|
+
statistics: runner.statistics,
|
|
1361
|
+
comparison: comparison,
|
|
1362
|
+
coverage: coverage.to_h
|
|
1363
|
+
})
|
|
1364
|
+
rescue StandardError => e
|
|
1365
|
+
# Update status to failed
|
|
1366
|
+
test_id_for_error = test_id || (data && data["test_id"])
|
|
1367
|
+
if test_id_for_error
|
|
1368
|
+
Server.batch_test_storage_mutex.synchronize do
|
|
1369
|
+
if Server.batch_test_storage[test_id_for_error]
|
|
1370
|
+
Server.batch_test_storage[test_id_for_error][:status] = "failed"
|
|
1371
|
+
Server.batch_test_storage[test_id_for_error][:error] = e.message
|
|
1372
|
+
end
|
|
1064
1373
|
end
|
|
1065
1374
|
end
|
|
1066
|
-
end
|
|
1067
1375
|
|
|
1068
|
-
|
|
1069
|
-
|
|
1376
|
+
ctx.status(500)
|
|
1377
|
+
ctx.json({ error: "Batch test execution failed: #{e.message}" })
|
|
1378
|
+
end
|
|
1070
1379
|
end
|
|
1071
|
-
end
|
|
1072
1380
|
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1381
|
+
# GET /api/testing/batch/:id/results - Get batch test results
|
|
1382
|
+
router.get "/api/testing/batch/:id/results" do |ctx|
|
|
1383
|
+
ctx.content_type "application/json"
|
|
1076
1384
|
|
|
1077
|
-
|
|
1078
|
-
|
|
1385
|
+
begin
|
|
1386
|
+
test_id = ctx.params[:id] || ctx.params["id"]
|
|
1079
1387
|
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1388
|
+
test_data = nil
|
|
1389
|
+
Server.batch_test_storage_mutex.synchronize do
|
|
1390
|
+
test_data = Server.batch_test_storage[test_id]
|
|
1391
|
+
end
|
|
1084
1392
|
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1393
|
+
unless test_data
|
|
1394
|
+
ctx.status(404)
|
|
1395
|
+
ctx.json({ error: "Test not found" })
|
|
1396
|
+
next
|
|
1397
|
+
end
|
|
1089
1398
|
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1399
|
+
ctx.json({
|
|
1400
|
+
test_id: test_data[:id],
|
|
1401
|
+
status: test_data[:status],
|
|
1402
|
+
created_at: test_data[:created_at],
|
|
1403
|
+
started_at: test_data[:started_at],
|
|
1404
|
+
completed_at: test_data[:completed_at],
|
|
1405
|
+
scenarios_count: test_data[:scenarios]&.size || 0,
|
|
1406
|
+
results: test_data[:results],
|
|
1407
|
+
comparison: test_data[:comparison],
|
|
1408
|
+
statistics: test_data[:statistics],
|
|
1409
|
+
error: test_data[:error]
|
|
1410
|
+
})
|
|
1411
|
+
rescue StandardError => e
|
|
1412
|
+
ctx.status(500)
|
|
1413
|
+
ctx.json({ error: e.message })
|
|
1414
|
+
end
|
|
1105
1415
|
end
|
|
1106
|
-
end
|
|
1107
1416
|
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1417
|
+
# GET /api/testing/batch/:id/coverage - Get coverage report
|
|
1418
|
+
router.get "/api/testing/batch/:id/coverage" do |ctx|
|
|
1419
|
+
ctx.content_type "application/json"
|
|
1111
1420
|
|
|
1112
|
-
|
|
1113
|
-
|
|
1421
|
+
begin
|
|
1422
|
+
test_id = ctx.params[:id] || ctx.params["id"]
|
|
1114
1423
|
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1424
|
+
test_data = nil
|
|
1425
|
+
Server.batch_test_storage_mutex.synchronize do
|
|
1426
|
+
test_data = Server.batch_test_storage[test_id]
|
|
1427
|
+
end
|
|
1119
1428
|
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1429
|
+
unless test_data
|
|
1430
|
+
ctx.status(404)
|
|
1431
|
+
ctx.json({ error: "Test not found" })
|
|
1432
|
+
next
|
|
1433
|
+
end
|
|
1124
1434
|
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1435
|
+
unless test_data[:coverage]
|
|
1436
|
+
ctx.status(404)
|
|
1437
|
+
ctx.json({ error: "Coverage report not available. Run the batch test first." })
|
|
1438
|
+
next
|
|
1439
|
+
end
|
|
1440
|
+
|
|
1441
|
+
ctx.json({
|
|
1442
|
+
test_id: test_data[:id],
|
|
1443
|
+
coverage: test_data[:coverage]
|
|
1444
|
+
})
|
|
1445
|
+
rescue StandardError => e
|
|
1446
|
+
ctx.status(500)
|
|
1447
|
+
ctx.json({ error: e.message })
|
|
1128
1448
|
end
|
|
1449
|
+
end
|
|
1129
1450
|
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1451
|
+
# GET /testing/batch - Batch testing UI page
|
|
1452
|
+
router.get "/testing/batch" do |ctx|
|
|
1453
|
+
batch_file = File.join(Server.public_folder, "batch_testing.html")
|
|
1454
|
+
Server.serve_html_with_base_tag(ctx, batch_file, "Batch testing page not found")
|
|
1134
1455
|
rescue StandardError => e
|
|
1135
|
-
status
|
|
1136
|
-
|
|
1456
|
+
ctx.status(404)
|
|
1457
|
+
ctx.body("Batch testing page not found: #{e.message}")
|
|
1137
1458
|
end
|
|
1138
|
-
end
|
|
1139
1459
|
|
|
1140
|
-
|
|
1141
|
-
get "/testing/batch" do
|
|
1142
|
-
send_file File.join(settings.public_folder, "batch_testing.html")
|
|
1143
|
-
rescue StandardError
|
|
1144
|
-
status 404
|
|
1145
|
-
"Batch testing page not found"
|
|
1146
|
-
end
|
|
1460
|
+
# Simulation API Endpoints
|
|
1147
1461
|
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
rescue StandardError
|
|
1152
|
-
status 404
|
|
1153
|
-
"Login page not found"
|
|
1154
|
-
end
|
|
1462
|
+
# POST /api/simulation/replay - Historical replay/backtesting
|
|
1463
|
+
router.post "/api/simulation/replay" do |ctx|
|
|
1464
|
+
ctx.content_type "application/json"
|
|
1155
1465
|
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1466
|
+
begin
|
|
1467
|
+
request_body = RackRequestHelpers.read_body(ctx.env)
|
|
1468
|
+
data = request_body.empty? ? {} : JSON.parse(request_body)
|
|
1469
|
+
|
|
1470
|
+
historical_data = data["historical_data"]
|
|
1471
|
+
rule_version = data["rule_version"]
|
|
1472
|
+
compare_with = data["compare_with"]
|
|
1473
|
+
options = data["options"] || {}
|
|
1474
|
+
|
|
1475
|
+
unless historical_data
|
|
1476
|
+
ctx.status(400)
|
|
1477
|
+
ctx.json({ error: "historical_data is required" })
|
|
1478
|
+
next
|
|
1479
|
+
end
|
|
1163
1480
|
|
|
1164
|
-
|
|
1481
|
+
# Get rules for agent creation
|
|
1482
|
+
rules_json = data["rules"]
|
|
1483
|
+
unless rules_json
|
|
1484
|
+
ctx.status(400)
|
|
1485
|
+
ctx.json({ error: "rules JSON is required" })
|
|
1486
|
+
next
|
|
1487
|
+
end
|
|
1165
1488
|
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
status 404
|
|
1171
|
-
"DMN Editor page not found"
|
|
1172
|
-
end
|
|
1489
|
+
# Create agent
|
|
1490
|
+
evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules_json)
|
|
1491
|
+
agent = DecisionAgent::Agent.new(evaluators: [evaluator])
|
|
1492
|
+
version_manager = DecisionAgent::Versioning::VersionManager.new
|
|
1173
1493
|
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1494
|
+
# Create replay engine
|
|
1495
|
+
replay_engine = DecisionAgent::Simulation::ReplayEngine.new(
|
|
1496
|
+
agent: agent,
|
|
1497
|
+
version_manager: version_manager
|
|
1498
|
+
)
|
|
1179
1499
|
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1500
|
+
# Convert historical data if it's a file path (for future file upload support)
|
|
1501
|
+
contexts = if historical_data.is_a?(Array)
|
|
1502
|
+
historical_data
|
|
1503
|
+
else
|
|
1504
|
+
# Assume it's a file path - load it
|
|
1505
|
+
raise ArgumentError, "File not found: #{historical_data}" unless File.exist?(historical_data)
|
|
1506
|
+
|
|
1507
|
+
if historical_data.end_with?(".json")
|
|
1508
|
+
JSON.parse(File.read(historical_data))
|
|
1509
|
+
elsif historical_data.end_with?(".csv")
|
|
1510
|
+
# Simple CSV parsing
|
|
1511
|
+
require "csv"
|
|
1512
|
+
csv_data = CSV.read(historical_data, headers: true)
|
|
1513
|
+
csv_data.map(&:to_h)
|
|
1514
|
+
else
|
|
1515
|
+
raise ArgumentError, "Unsupported file format"
|
|
1516
|
+
end
|
|
1517
|
+
end
|
|
1183
1518
|
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1519
|
+
# Execute replay
|
|
1520
|
+
results = if compare_with
|
|
1521
|
+
replay_engine.replay(
|
|
1522
|
+
historical_data: contexts,
|
|
1523
|
+
rule_version: rule_version,
|
|
1524
|
+
compare_with: compare_with
|
|
1525
|
+
)
|
|
1526
|
+
else
|
|
1527
|
+
replay_engine.replay(
|
|
1528
|
+
historical_data: contexts,
|
|
1529
|
+
rule_version: rule_version,
|
|
1530
|
+
options: options
|
|
1531
|
+
)
|
|
1532
|
+
end
|
|
1187
1533
|
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1534
|
+
# Store results
|
|
1535
|
+
replay_id = SecureRandom.uuid
|
|
1536
|
+
Server.simulation_storage_mutex.synchronize do
|
|
1537
|
+
Server.simulation_storage[replay_id] = {
|
|
1538
|
+
id: replay_id,
|
|
1539
|
+
type: "replay",
|
|
1540
|
+
status: "completed",
|
|
1541
|
+
created_at: Time.now.utc.iso8601,
|
|
1542
|
+
results: results
|
|
1543
|
+
}
|
|
1544
|
+
end
|
|
1192
1545
|
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1546
|
+
ctx.json({
|
|
1547
|
+
replay_id: replay_id,
|
|
1548
|
+
results: results
|
|
1549
|
+
})
|
|
1550
|
+
rescue StandardError => e
|
|
1551
|
+
ctx.status(500)
|
|
1552
|
+
ctx.json({ error: "Replay failed: #{e.message}" })
|
|
1553
|
+
end
|
|
1198
1554
|
end
|
|
1199
|
-
end
|
|
1200
1555
|
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1556
|
+
# POST /api/simulation/whatif - What-if analysis
|
|
1557
|
+
router.post "/api/simulation/whatif" do |ctx|
|
|
1558
|
+
ctx.content_type "application/json"
|
|
1204
1559
|
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
else
|
|
1209
|
-
status 404
|
|
1210
|
-
{ error: "Model not found" }.to_json
|
|
1211
|
-
end
|
|
1212
|
-
end
|
|
1560
|
+
begin
|
|
1561
|
+
request_body = RackRequestHelpers.read_body(ctx.env)
|
|
1562
|
+
data = request_body.empty? ? {} : JSON.parse(request_body)
|
|
1213
1563
|
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1564
|
+
scenarios = data["scenarios"]
|
|
1565
|
+
rule_version = data["rule_version"]
|
|
1566
|
+
options = data["options"] || {}
|
|
1217
1567
|
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1568
|
+
unless scenarios.is_a?(Array)
|
|
1569
|
+
ctx.status(400)
|
|
1570
|
+
ctx.json({ error: "scenarios array is required" })
|
|
1571
|
+
next
|
|
1572
|
+
end
|
|
1221
1573
|
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1574
|
+
# Get rules for agent creation
|
|
1575
|
+
rules_json = data["rules"]
|
|
1576
|
+
unless rules_json
|
|
1577
|
+
ctx.status(400)
|
|
1578
|
+
ctx.json({ error: "rules JSON is required" })
|
|
1579
|
+
next
|
|
1580
|
+
end
|
|
1227
1581
|
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1582
|
+
# Create agent
|
|
1583
|
+
evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules_json)
|
|
1584
|
+
agent = DecisionAgent::Agent.new(evaluators: [evaluator])
|
|
1585
|
+
version_mgr = Server.version_manager
|
|
1586
|
+
|
|
1587
|
+
# Create what-if analyzer
|
|
1588
|
+
analyzer = DecisionAgent::Simulation::WhatIfAnalyzer.new(
|
|
1589
|
+
agent: agent,
|
|
1590
|
+
version_manager: version_mgr
|
|
1591
|
+
)
|
|
1592
|
+
|
|
1593
|
+
# Execute analysis
|
|
1594
|
+
results = analyzer.analyze(
|
|
1595
|
+
scenarios: scenarios,
|
|
1596
|
+
rule_version: rule_version,
|
|
1597
|
+
options: options
|
|
1598
|
+
)
|
|
1599
|
+
|
|
1600
|
+
# Store results
|
|
1601
|
+
analysis_id = SecureRandom.uuid
|
|
1602
|
+
Server.simulation_storage_mutex.synchronize do
|
|
1603
|
+
Server.simulation_storage[analysis_id] = {
|
|
1604
|
+
id: analysis_id,
|
|
1605
|
+
type: "whatif",
|
|
1606
|
+
status: "completed",
|
|
1607
|
+
created_at: Time.now.utc.iso8601,
|
|
1608
|
+
results: results
|
|
1609
|
+
}
|
|
1610
|
+
end
|
|
1611
|
+
|
|
1612
|
+
ctx.json({
|
|
1613
|
+
analysis_id: analysis_id,
|
|
1614
|
+
results: results
|
|
1615
|
+
})
|
|
1616
|
+
rescue StandardError => e
|
|
1617
|
+
ctx.status(500)
|
|
1618
|
+
ctx.json({ error: "What-if analysis failed: #{e.message}" })
|
|
1233
1619
|
end
|
|
1234
|
-
rescue StandardError => e
|
|
1235
|
-
status 500
|
|
1236
|
-
{ error: e.message }.to_json
|
|
1237
1620
|
end
|
|
1238
|
-
end
|
|
1239
1621
|
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1622
|
+
# POST /api/simulation/whatif/sensitivity - Sensitivity analysis
|
|
1623
|
+
router.post "/api/simulation/whatif/sensitivity" do |ctx|
|
|
1624
|
+
ctx.content_type "application/json"
|
|
1243
1625
|
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1626
|
+
begin
|
|
1627
|
+
request_body = RackRequestHelpers.read_body(ctx.env)
|
|
1628
|
+
data = request_body.empty? ? {} : JSON.parse(request_body)
|
|
1247
1629
|
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1630
|
+
base_scenario = data["base_scenario"]
|
|
1631
|
+
variations = data["variations"]
|
|
1632
|
+
rule_version = data["rule_version"]
|
|
1251
1633
|
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
model_id: params[:model_id],
|
|
1258
|
-
decision_id: data["decision_id"],
|
|
1259
|
-
name: data["name"],
|
|
1260
|
-
type: data["type"] || "decision_table"
|
|
1261
|
-
)
|
|
1634
|
+
unless base_scenario && variations
|
|
1635
|
+
ctx.status(400)
|
|
1636
|
+
ctx.json({ error: "base_scenario and variations are required" })
|
|
1637
|
+
next
|
|
1638
|
+
end
|
|
1262
1639
|
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1640
|
+
# Get rules for agent creation
|
|
1641
|
+
rules_json = data["rules"]
|
|
1642
|
+
unless rules_json
|
|
1643
|
+
ctx.status(400)
|
|
1644
|
+
ctx.json({ error: "rules JSON is required" })
|
|
1645
|
+
next
|
|
1646
|
+
end
|
|
1647
|
+
|
|
1648
|
+
# Create agent
|
|
1649
|
+
evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules_json)
|
|
1650
|
+
agent = DecisionAgent::Agent.new(evaluators: [evaluator])
|
|
1651
|
+
version_mgr = Server.version_manager
|
|
1652
|
+
|
|
1653
|
+
# Create what-if analyzer
|
|
1654
|
+
analyzer = DecisionAgent::Simulation::WhatIfAnalyzer.new(
|
|
1655
|
+
agent: agent,
|
|
1656
|
+
version_manager: version_mgr
|
|
1657
|
+
)
|
|
1658
|
+
|
|
1659
|
+
# Execute sensitivity analysis
|
|
1660
|
+
results = analyzer.sensitivity_analysis(
|
|
1661
|
+
base_scenario: base_scenario,
|
|
1662
|
+
variations: variations,
|
|
1663
|
+
rule_version: rule_version
|
|
1664
|
+
)
|
|
1665
|
+
|
|
1666
|
+
ctx.json({ results: results })
|
|
1667
|
+
rescue StandardError => e
|
|
1668
|
+
ctx.status(500)
|
|
1669
|
+
ctx.json({ error: "Sensitivity analysis failed: #{e.message}" })
|
|
1269
1670
|
end
|
|
1270
|
-
rescue StandardError => e
|
|
1271
|
-
status 500
|
|
1272
|
-
{ error: e.message }.to_json
|
|
1273
1671
|
end
|
|
1274
|
-
end
|
|
1275
1672
|
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1673
|
+
# POST /api/simulation/impact - Impact analysis
|
|
1674
|
+
router.post "/api/simulation/impact" do |ctx|
|
|
1675
|
+
ctx.content_type "application/json"
|
|
1279
1676
|
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1677
|
+
begin
|
|
1678
|
+
request_body = RackRequestHelpers.read_body(ctx.env)
|
|
1679
|
+
data = request_body.empty? ? {} : JSON.parse(request_body)
|
|
1680
|
+
|
|
1681
|
+
baseline_version = data["baseline_version"]
|
|
1682
|
+
proposed_version = data["proposed_version"]
|
|
1683
|
+
test_data = data["test_data"]
|
|
1684
|
+
options = data["options"] || {}
|
|
1685
|
+
|
|
1686
|
+
unless baseline_version && proposed_version && test_data
|
|
1687
|
+
ctx.status(400)
|
|
1688
|
+
ctx.json({ error: "baseline_version, proposed_version, and test_data are required" })
|
|
1689
|
+
next
|
|
1690
|
+
end
|
|
1290
1691
|
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1692
|
+
version_mgr = Server.version_manager
|
|
1693
|
+
|
|
1694
|
+
# Create impact analyzer
|
|
1695
|
+
analyzer = DecisionAgent::Simulation::ImpactAnalyzer.new(
|
|
1696
|
+
version_manager: version_mgr
|
|
1697
|
+
)
|
|
1698
|
+
|
|
1699
|
+
# Execute impact analysis
|
|
1700
|
+
results = analyzer.analyze(
|
|
1701
|
+
baseline_version: baseline_version,
|
|
1702
|
+
proposed_version: proposed_version,
|
|
1703
|
+
test_data: test_data,
|
|
1704
|
+
options: options
|
|
1705
|
+
)
|
|
1706
|
+
|
|
1707
|
+
# Store results
|
|
1708
|
+
impact_id = SecureRandom.uuid
|
|
1709
|
+
Server.simulation_storage_mutex.synchronize do
|
|
1710
|
+
Server.simulation_storage[impact_id] = {
|
|
1711
|
+
id: impact_id,
|
|
1712
|
+
type: "impact",
|
|
1713
|
+
status: "completed",
|
|
1714
|
+
created_at: Time.now.utc.iso8601,
|
|
1715
|
+
results: results
|
|
1716
|
+
}
|
|
1717
|
+
end
|
|
1718
|
+
|
|
1719
|
+
ctx.json({
|
|
1720
|
+
impact_id: impact_id,
|
|
1721
|
+
results: results
|
|
1722
|
+
})
|
|
1723
|
+
rescue StandardError => e
|
|
1724
|
+
ctx.status(500)
|
|
1725
|
+
ctx.json({ error: "Impact analysis failed: #{e.message}" })
|
|
1296
1726
|
end
|
|
1297
|
-
rescue StandardError => e
|
|
1298
|
-
status 500
|
|
1299
|
-
{ error: e.message }.to_json
|
|
1300
1727
|
end
|
|
1301
|
-
end
|
|
1302
1728
|
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1729
|
+
# POST /api/simulation/shadow - Shadow testing
|
|
1730
|
+
router.post "/api/simulation/shadow" do |ctx|
|
|
1731
|
+
ctx.content_type "application/json"
|
|
1306
1732
|
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1733
|
+
begin
|
|
1734
|
+
request_body = RackRequestHelpers.read_body(ctx.env)
|
|
1735
|
+
data = request_body.empty? ? {} : JSON.parse(request_body)
|
|
1736
|
+
|
|
1737
|
+
context = data["context"]
|
|
1738
|
+
shadow_version = data["shadow_version"]
|
|
1739
|
+
production_rules = data["production_rules"]
|
|
1740
|
+
shadow_rules = data["shadow_rules"]
|
|
1741
|
+
options = data["options"] || {}
|
|
1742
|
+
|
|
1743
|
+
unless context
|
|
1744
|
+
ctx.status(400)
|
|
1745
|
+
ctx.json({ error: "context is required" })
|
|
1746
|
+
next
|
|
1747
|
+
end
|
|
1311
1748
|
|
|
1312
|
-
|
|
1313
|
-
|
|
1749
|
+
unless (production_rules && shadow_rules) || shadow_version
|
|
1750
|
+
ctx.status(400)
|
|
1751
|
+
ctx.json({ error: "Either (production_rules and shadow_rules) or shadow_version is required" })
|
|
1752
|
+
next
|
|
1753
|
+
end
|
|
1314
1754
|
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1755
|
+
version_mgr = Server.version_manager
|
|
1756
|
+
|
|
1757
|
+
# Create production agent
|
|
1758
|
+
if production_rules
|
|
1759
|
+
prod_evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: production_rules)
|
|
1760
|
+
production_agent = DecisionAgent::Agent.new(evaluators: [prod_evaluator])
|
|
1761
|
+
else
|
|
1762
|
+
# Use active version
|
|
1763
|
+
active_version = version_mgr.get_active_version
|
|
1764
|
+
if active_version
|
|
1765
|
+
prod_evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: active_version[:content])
|
|
1766
|
+
production_agent = DecisionAgent::Agent.new(evaluators: [prod_evaluator])
|
|
1767
|
+
else
|
|
1768
|
+
ctx.status(400)
|
|
1769
|
+
ctx.json({ error: "No active version found and production_rules not provided" })
|
|
1770
|
+
next
|
|
1771
|
+
end
|
|
1772
|
+
end
|
|
1318
1773
|
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
model_id: params[:model_id],
|
|
1325
|
-
decision_id: params[:decision_id],
|
|
1326
|
-
input_id: data["input_id"],
|
|
1327
|
-
label: data["label"],
|
|
1328
|
-
type_ref: data["type_ref"],
|
|
1329
|
-
expression: data["expression"]
|
|
1330
|
-
)
|
|
1774
|
+
# Create shadow test engine
|
|
1775
|
+
shadow_engine = DecisionAgent::Simulation::ShadowTestEngine.new(
|
|
1776
|
+
production_agent: production_agent,
|
|
1777
|
+
version_manager: version_mgr
|
|
1778
|
+
)
|
|
1331
1779
|
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1780
|
+
# Execute shadow test
|
|
1781
|
+
if shadow_rules
|
|
1782
|
+
# Create a temporary version for shadow rules
|
|
1783
|
+
temp_version = {
|
|
1784
|
+
content: shadow_rules,
|
|
1785
|
+
rule_id: "shadow_temp",
|
|
1786
|
+
version_number: 1
|
|
1787
|
+
}
|
|
1788
|
+
result = shadow_engine.test(
|
|
1789
|
+
context: context,
|
|
1790
|
+
shadow_version: temp_version,
|
|
1791
|
+
options: options
|
|
1792
|
+
)
|
|
1793
|
+
else
|
|
1794
|
+
result = shadow_engine.test(
|
|
1795
|
+
context: context,
|
|
1796
|
+
shadow_version: shadow_version,
|
|
1797
|
+
options: options
|
|
1798
|
+
)
|
|
1799
|
+
end
|
|
1800
|
+
|
|
1801
|
+
ctx.json({ result: result })
|
|
1802
|
+
rescue StandardError => e
|
|
1803
|
+
ctx.status(500)
|
|
1804
|
+
ctx.json({ error: "Shadow test failed: #{e.message}" })
|
|
1338
1805
|
end
|
|
1339
|
-
rescue StandardError => e
|
|
1340
|
-
status 500
|
|
1341
|
-
{ error: e.message }.to_json
|
|
1342
1806
|
end
|
|
1343
|
-
end
|
|
1344
1807
|
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1808
|
+
# POST /api/simulation/shadow/batch - Batch shadow testing
|
|
1809
|
+
router.post "/api/simulation/shadow/batch" do |ctx|
|
|
1810
|
+
ctx.content_type "application/json"
|
|
1348
1811
|
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1812
|
+
begin
|
|
1813
|
+
request_body = RackRequestHelpers.read_body(ctx.env)
|
|
1814
|
+
data = request_body.empty? ? {} : JSON.parse(request_body)
|
|
1815
|
+
|
|
1816
|
+
contexts = data["contexts"]
|
|
1817
|
+
shadow_version = data["shadow_version"]
|
|
1818
|
+
production_rules = data["production_rules"]
|
|
1819
|
+
shadow_rules = data["shadow_rules"]
|
|
1820
|
+
options = data["options"] || {}
|
|
1821
|
+
|
|
1822
|
+
unless contexts.is_a?(Array)
|
|
1823
|
+
ctx.status(400)
|
|
1824
|
+
ctx.json({ error: "contexts array is required" })
|
|
1825
|
+
next
|
|
1826
|
+
end
|
|
1361
1827
|
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1828
|
+
unless (production_rules && shadow_rules) || shadow_version
|
|
1829
|
+
ctx.status(400)
|
|
1830
|
+
ctx.json({ error: "Either (production_rules and shadow_rules) or shadow_version is required" })
|
|
1831
|
+
next
|
|
1832
|
+
end
|
|
1833
|
+
|
|
1834
|
+
version_mgr = Server.version_manager
|
|
1835
|
+
|
|
1836
|
+
# Create production agent
|
|
1837
|
+
if production_rules
|
|
1838
|
+
prod_evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: production_rules)
|
|
1839
|
+
production_agent = DecisionAgent::Agent.new(evaluators: [prod_evaluator])
|
|
1840
|
+
else
|
|
1841
|
+
# Use active version
|
|
1842
|
+
active_version = version_mgr.get_active_version
|
|
1843
|
+
if active_version
|
|
1844
|
+
prod_evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: active_version[:content])
|
|
1845
|
+
production_agent = DecisionAgent::Agent.new(evaluators: [prod_evaluator])
|
|
1846
|
+
else
|
|
1847
|
+
ctx.status(400)
|
|
1848
|
+
ctx.json({ error: "No active version found and production_rules not provided" })
|
|
1849
|
+
next
|
|
1850
|
+
end
|
|
1851
|
+
end
|
|
1852
|
+
|
|
1853
|
+
# Create shadow test engine
|
|
1854
|
+
shadow_engine = DecisionAgent::Simulation::ShadowTestEngine.new(
|
|
1855
|
+
production_agent: production_agent,
|
|
1856
|
+
version_manager: version_mgr
|
|
1857
|
+
)
|
|
1858
|
+
|
|
1859
|
+
# Execute batch shadow test
|
|
1860
|
+
if shadow_rules
|
|
1861
|
+
# Create a temporary version for shadow rules
|
|
1862
|
+
temp_version = {
|
|
1863
|
+
content: shadow_rules,
|
|
1864
|
+
rule_id: "shadow_temp",
|
|
1865
|
+
version_number: 1
|
|
1866
|
+
}
|
|
1867
|
+
results = shadow_engine.batch_test(
|
|
1868
|
+
contexts: contexts,
|
|
1869
|
+
shadow_version: temp_version,
|
|
1870
|
+
options: options
|
|
1871
|
+
)
|
|
1872
|
+
else
|
|
1873
|
+
results = shadow_engine.batch_test(
|
|
1874
|
+
contexts: contexts,
|
|
1875
|
+
shadow_version: shadow_version,
|
|
1876
|
+
options: options
|
|
1877
|
+
)
|
|
1878
|
+
end
|
|
1879
|
+
|
|
1880
|
+
ctx.json({ results: results })
|
|
1881
|
+
rescue StandardError => e
|
|
1882
|
+
ctx.status(500)
|
|
1883
|
+
ctx.json({ error: "Batch shadow test failed: #{e.message}" })
|
|
1368
1884
|
end
|
|
1369
|
-
rescue StandardError => e
|
|
1370
|
-
status 500
|
|
1371
|
-
{ error: e.message }.to_json
|
|
1372
1885
|
end
|
|
1373
|
-
end
|
|
1374
1886
|
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1887
|
+
# GET /api/simulation/:id - Get simulation results
|
|
1888
|
+
router.get "/api/simulation/:id" do |ctx|
|
|
1889
|
+
ctx.content_type "application/json"
|
|
1378
1890
|
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
data = JSON.parse(request_body)
|
|
1382
|
-
|
|
1383
|
-
rule = dmn_editor.add_rule(
|
|
1384
|
-
model_id: params[:model_id],
|
|
1385
|
-
decision_id: params[:decision_id],
|
|
1386
|
-
rule_id: data["rule_id"],
|
|
1387
|
-
input_entries: data["input_entries"],
|
|
1388
|
-
output_entries: data["output_entries"],
|
|
1389
|
-
description: data["description"]
|
|
1390
|
-
)
|
|
1891
|
+
begin
|
|
1892
|
+
sim_id = ctx.params[:id] || ctx.params["id"]
|
|
1391
1893
|
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1894
|
+
sim_data = nil
|
|
1895
|
+
Server.simulation_storage_mutex.synchronize do
|
|
1896
|
+
sim_data = Server.simulation_storage[sim_id]
|
|
1897
|
+
end
|
|
1898
|
+
|
|
1899
|
+
unless sim_data
|
|
1900
|
+
ctx.status(404)
|
|
1901
|
+
ctx.json({ error: "Simulation not found" })
|
|
1902
|
+
next
|
|
1903
|
+
end
|
|
1904
|
+
|
|
1905
|
+
ctx.json(sim_data)
|
|
1906
|
+
rescue StandardError => e
|
|
1907
|
+
ctx.status(500)
|
|
1908
|
+
ctx.json({ error: e.message })
|
|
1398
1909
|
end
|
|
1910
|
+
end
|
|
1911
|
+
|
|
1912
|
+
# GET /api/versions - List all versions (for simulation dropdowns)
|
|
1913
|
+
router.get "/api/versions" do |ctx|
|
|
1914
|
+
ctx.content_type "application/json"
|
|
1915
|
+
|
|
1916
|
+
begin
|
|
1917
|
+
version_mgr = Server.version_manager
|
|
1918
|
+
versions = version_mgr.list_all_versions
|
|
1919
|
+
|
|
1920
|
+
ctx.json({
|
|
1921
|
+
versions: versions.map do |v|
|
|
1922
|
+
{
|
|
1923
|
+
id: v[:id] || v["id"],
|
|
1924
|
+
rule_id: v[:rule_id] || v["rule_id"],
|
|
1925
|
+
version_number: v[:version_number] || v["version_number"],
|
|
1926
|
+
status: v[:status] || v["status"],
|
|
1927
|
+
created_at: v[:created_at] || v["created_at"]
|
|
1928
|
+
}
|
|
1929
|
+
end
|
|
1930
|
+
})
|
|
1931
|
+
rescue StandardError => e
|
|
1932
|
+
ctx.status(500)
|
|
1933
|
+
ctx.json({ error: e.message })
|
|
1934
|
+
end
|
|
1935
|
+
end
|
|
1936
|
+
|
|
1937
|
+
# GET /simulation - Simulation dashboard UI page
|
|
1938
|
+
router.get "/simulation" do |ctx|
|
|
1939
|
+
sim_file = File.join(Server.public_folder, "simulation.html")
|
|
1940
|
+
Server.serve_html_with_base_tag(ctx, sim_file, "Simulation page not found")
|
|
1399
1941
|
rescue StandardError => e
|
|
1400
|
-
|
|
1401
|
-
|
|
1942
|
+
warn "[DecisionAgent] Error serving simulation page: #{e.message}"
|
|
1943
|
+
ctx.status(404)
|
|
1944
|
+
ctx.body("Simulation page not found")
|
|
1402
1945
|
end
|
|
1403
|
-
end
|
|
1404
1946
|
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1947
|
+
# GET /simulation/replay - Historical replay UI page
|
|
1948
|
+
router.get "/simulation/replay" do |ctx|
|
|
1949
|
+
replay_file = File.join(Server.public_folder, "simulation_replay.html")
|
|
1950
|
+
Server.serve_html_with_base_tag(ctx, replay_file, "Historical replay page not found")
|
|
1951
|
+
rescue StandardError => e
|
|
1952
|
+
warn "[DecisionAgent] Error serving replay page: #{e.message}"
|
|
1953
|
+
ctx.status(404)
|
|
1954
|
+
ctx.body("Historical replay page not found")
|
|
1955
|
+
end
|
|
1408
1956
|
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
output_entries: data["output_entries"],
|
|
1419
|
-
description: data["description"]
|
|
1420
|
-
)
|
|
1957
|
+
# GET /simulation/whatif - What-if analysis UI page
|
|
1958
|
+
router.get "/simulation/whatif" do |ctx|
|
|
1959
|
+
whatif_file = File.join(Server.public_folder, "simulation_whatif.html")
|
|
1960
|
+
Server.serve_html_with_base_tag(ctx, whatif_file, "What-if analysis page not found")
|
|
1961
|
+
rescue StandardError => e
|
|
1962
|
+
warn "[DecisionAgent] Error serving what-if page: #{e.message}"
|
|
1963
|
+
ctx.status(404)
|
|
1964
|
+
ctx.body("What-if analysis page not found")
|
|
1965
|
+
end
|
|
1421
1966
|
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
{ error: "Rule not found" }.to_json
|
|
1427
|
-
end
|
|
1967
|
+
# GET /simulation/impact - Impact analysis UI page
|
|
1968
|
+
router.get "/simulation/impact" do |ctx|
|
|
1969
|
+
impact_file = File.join(Server.public_folder, "simulation_impact.html")
|
|
1970
|
+
Server.serve_html_with_base_tag(ctx, impact_file, "Impact analysis page not found")
|
|
1428
1971
|
rescue StandardError => e
|
|
1429
|
-
|
|
1430
|
-
|
|
1972
|
+
warn "[DecisionAgent] Error serving impact page: #{e.message}"
|
|
1973
|
+
ctx.status(404)
|
|
1974
|
+
ctx.body("Impact analysis page not found")
|
|
1431
1975
|
end
|
|
1432
|
-
end
|
|
1433
1976
|
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1977
|
+
# GET /simulation/shadow - Shadow testing UI page
|
|
1978
|
+
router.get "/simulation/shadow" do |ctx|
|
|
1979
|
+
shadow_file = File.join(Server.public_folder, "simulation_shadow.html")
|
|
1980
|
+
Server.serve_html_with_base_tag(ctx, shadow_file, "Shadow testing page not found")
|
|
1981
|
+
rescue StandardError => e
|
|
1982
|
+
warn "[DecisionAgent] Error serving shadow testing page: #{e.message}"
|
|
1983
|
+
ctx.status(404)
|
|
1984
|
+
ctx.body("Shadow testing page not found")
|
|
1985
|
+
end
|
|
1437
1986
|
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1987
|
+
# GET /auth/login - Login page
|
|
1988
|
+
router.get "/auth/login" do |ctx|
|
|
1989
|
+
login_file = File.join(Server.public_folder, "login.html")
|
|
1990
|
+
Server.serve_html_with_base_tag(ctx, login_file, "Login page not found")
|
|
1991
|
+
rescue StandardError => e
|
|
1992
|
+
warn "[DecisionAgent] Error serving login page: #{e.message}"
|
|
1993
|
+
ctx.status(404)
|
|
1994
|
+
ctx.body("Login page not found")
|
|
1995
|
+
end
|
|
1443
1996
|
|
|
1444
|
-
|
|
1445
|
-
|
|
1997
|
+
# GET /auth/users - User management page
|
|
1998
|
+
router.get "/auth/users" do |ctx|
|
|
1999
|
+
users_file = File.join(Server.public_folder, "users.html")
|
|
2000
|
+
Server.serve_html_with_base_tag(ctx, users_file, "User management page not found")
|
|
2001
|
+
rescue StandardError => e
|
|
2002
|
+
warn "[DecisionAgent] Error serving users page: #{e.message}"
|
|
2003
|
+
ctx.status(404)
|
|
2004
|
+
ctx.body("User management page not found")
|
|
2005
|
+
end
|
|
1446
2006
|
|
|
1447
|
-
|
|
1448
|
-
get "/api/dmn/models/:id/validate" do
|
|
1449
|
-
content_type :json
|
|
1450
|
-
dmn_editor.validate_model(params[:id]).to_json
|
|
1451
|
-
end
|
|
2007
|
+
# DMN Editor Routes
|
|
1452
2008
|
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
2009
|
+
# GET /dmn/editor - DMN Editor UI page
|
|
2010
|
+
router.get "/dmn/editor" do |ctx|
|
|
2011
|
+
dmn_file = File.join(Server.public_folder, "dmn-editor.html")
|
|
2012
|
+
Server.serve_html_with_base_tag(ctx, dmn_file, "DMN Editor page not found")
|
|
2013
|
+
rescue StandardError => e
|
|
2014
|
+
warn "[DecisionAgent] Error serving DMN editor page: #{e.message}"
|
|
2015
|
+
ctx.status(404)
|
|
2016
|
+
ctx.body("DMN Editor page not found")
|
|
2017
|
+
end
|
|
1456
2018
|
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
status 404
|
|
1462
|
-
"Model not found"
|
|
2019
|
+
# API: List all DMN models
|
|
2020
|
+
router.get "/api/dmn/models" do |ctx|
|
|
2021
|
+
ctx.content_type "application/json"
|
|
2022
|
+
ctx.json(Server.dmn_editor.list_models)
|
|
1463
2023
|
end
|
|
1464
|
-
end
|
|
1465
2024
|
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
2025
|
+
# API: Create new DMN model
|
|
2026
|
+
router.post "/api/dmn/models" do |ctx|
|
|
2027
|
+
ctx.content_type "application/json"
|
|
1469
2028
|
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
2029
|
+
begin
|
|
2030
|
+
request_body = RackRequestHelpers.read_body(ctx.env)
|
|
2031
|
+
data = JSON.parse(request_body)
|
|
2032
|
+
|
|
2033
|
+
model = Server.dmn_editor.create_model(
|
|
2034
|
+
name: data["name"],
|
|
2035
|
+
namespace: data["namespace"]
|
|
2036
|
+
)
|
|
1473
2037
|
|
|
1474
|
-
|
|
2038
|
+
ctx.status(201)
|
|
2039
|
+
ctx.json(model)
|
|
2040
|
+
rescue StandardError => e
|
|
2041
|
+
ctx.status(500)
|
|
2042
|
+
ctx.json({ error: e.message })
|
|
2043
|
+
end
|
|
2044
|
+
end
|
|
1475
2045
|
|
|
2046
|
+
# API: Get DMN model
|
|
2047
|
+
router.get "/api/dmn/models/:id" do |ctx|
|
|
2048
|
+
ctx.content_type "application/json"
|
|
2049
|
+
|
|
2050
|
+
model_id = ctx.params[:id] || ctx.params["id"]
|
|
2051
|
+
model = Server.dmn_editor.get_model(model_id)
|
|
1476
2052
|
if model
|
|
1477
|
-
|
|
1478
|
-
model.to_json
|
|
2053
|
+
ctx.json(model)
|
|
1479
2054
|
else
|
|
1480
|
-
status
|
|
1481
|
-
{ error: "
|
|
2055
|
+
ctx.status(404)
|
|
2056
|
+
ctx.json({ error: "Model not found" })
|
|
1482
2057
|
end
|
|
1483
|
-
rescue StandardError => e
|
|
1484
|
-
status 500
|
|
1485
|
-
{ error: e.message }.to_json
|
|
1486
2058
|
end
|
|
1487
|
-
end
|
|
1488
2059
|
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
2060
|
+
# API: Update DMN model
|
|
2061
|
+
router.put "/api/dmn/models/:id" do |ctx|
|
|
2062
|
+
ctx.content_type "application/json"
|
|
1492
2063
|
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
)
|
|
2064
|
+
begin
|
|
2065
|
+
model_id = ctx.params[:id] || ctx.params["id"]
|
|
2066
|
+
request_body = RackRequestHelpers.read_body(ctx.env)
|
|
2067
|
+
data = JSON.parse(request_body)
|
|
1498
2068
|
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
"Decision not found or not a tree"
|
|
1505
|
-
end
|
|
1506
|
-
end
|
|
2069
|
+
model = Server.dmn_editor.update_model(
|
|
2070
|
+
model_id,
|
|
2071
|
+
name: data["name"],
|
|
2072
|
+
namespace: data["namespace"]
|
|
2073
|
+
)
|
|
1507
2074
|
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
2075
|
+
if model
|
|
2076
|
+
ctx.json(model)
|
|
2077
|
+
else
|
|
2078
|
+
ctx.status(404)
|
|
2079
|
+
ctx.json({ error: "Model not found" })
|
|
2080
|
+
end
|
|
2081
|
+
rescue StandardError => e
|
|
2082
|
+
ctx.status(500)
|
|
2083
|
+
ctx.json({ error: e.message })
|
|
2084
|
+
end
|
|
2085
|
+
end
|
|
1511
2086
|
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
)
|
|
2087
|
+
# API: Delete DMN model
|
|
2088
|
+
router.delete "/api/dmn/models/:id" do |ctx|
|
|
2089
|
+
ctx.content_type "application/json"
|
|
1516
2090
|
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
else
|
|
1521
|
-
status 404
|
|
1522
|
-
"Model not found"
|
|
2091
|
+
model_id = ctx.params[:id] || ctx.params["id"]
|
|
2092
|
+
result = Server.dmn_editor.delete_model(model_id)
|
|
2093
|
+
ctx.json({ success: result })
|
|
1523
2094
|
end
|
|
1524
|
-
end
|
|
1525
2095
|
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
2096
|
+
# API: Add decision to model
|
|
2097
|
+
router.post "/api/dmn/models/:model_id/decisions" do |ctx|
|
|
2098
|
+
ctx.content_type "application/json"
|
|
1529
2099
|
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
# File upload
|
|
1534
|
-
file = params[:file][:tempfile]
|
|
1535
|
-
xml_content = file.read
|
|
1536
|
-
ruleset_name = params[:ruleset_name] || params[:file][:filename]&.gsub(/\.dmn$/i, "")
|
|
1537
|
-
created_by = @current_user ? @current_user.id.to_s : params[:created_by] || "system"
|
|
1538
|
-
elsif request.content_type&.include?("application/json")
|
|
1539
|
-
# JSON body with XML content
|
|
1540
|
-
request_body = request.body.read
|
|
2100
|
+
begin
|
|
2101
|
+
model_id = ctx.params[:model_id] || ctx.params["model_id"]
|
|
2102
|
+
request_body = RackRequestHelpers.read_body(ctx.env)
|
|
1541
2103
|
data = JSON.parse(request_body)
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
2104
|
+
|
|
2105
|
+
decision = Server.dmn_editor.add_decision(
|
|
2106
|
+
model_id: model_id,
|
|
2107
|
+
decision_id: data["decision_id"],
|
|
2108
|
+
name: data["name"],
|
|
2109
|
+
type: data["type"] || "decision_table"
|
|
2110
|
+
)
|
|
2111
|
+
|
|
2112
|
+
if decision
|
|
2113
|
+
ctx.status(201)
|
|
2114
|
+
ctx.json(decision)
|
|
2115
|
+
else
|
|
2116
|
+
ctx.status(404)
|
|
2117
|
+
ctx.json({ error: "Model not found" })
|
|
2118
|
+
end
|
|
2119
|
+
rescue StandardError => e
|
|
2120
|
+
ctx.status(500)
|
|
2121
|
+
ctx.json({ error: e.message })
|
|
1553
2122
|
end
|
|
2123
|
+
end
|
|
1554
2124
|
|
|
1555
|
-
|
|
2125
|
+
# API: Update decision
|
|
2126
|
+
router.put "/api/dmn/models/:model_id/decisions/:decision_id" do |ctx|
|
|
2127
|
+
ctx.content_type "application/json"
|
|
1556
2128
|
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
created_by: created_by
|
|
1563
|
-
)
|
|
2129
|
+
begin
|
|
2130
|
+
model_id = ctx.params[:model_id] || ctx.params["model_id"]
|
|
2131
|
+
decision_id = ctx.params[:decision_id] || ctx.params["decision_id"]
|
|
2132
|
+
request_body = RackRequestHelpers.read_body(ctx.env)
|
|
2133
|
+
data = JSON.parse(request_body)
|
|
1564
2134
|
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
name: d.name
|
|
1578
|
-
}
|
|
1579
|
-
end
|
|
1580
|
-
},
|
|
1581
|
-
versions: result[:versions].map do |v|
|
|
1582
|
-
{
|
|
1583
|
-
version: v[:version],
|
|
1584
|
-
rule_id: v[:rule_id],
|
|
1585
|
-
created_by: v[:created_by],
|
|
1586
|
-
created_at: v[:created_at]
|
|
1587
|
-
}
|
|
2135
|
+
decision = Server.dmn_editor.update_decision(
|
|
2136
|
+
model_id: model_id,
|
|
2137
|
+
decision_id: decision_id,
|
|
2138
|
+
name: data["name"],
|
|
2139
|
+
logic: data["logic"]
|
|
2140
|
+
)
|
|
2141
|
+
|
|
2142
|
+
if decision
|
|
2143
|
+
ctx.json(decision)
|
|
2144
|
+
else
|
|
2145
|
+
ctx.status(404)
|
|
2146
|
+
ctx.json({ error: "Decision not found" })
|
|
1588
2147
|
end
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
rescue StandardError => e
|
|
1594
|
-
status 500
|
|
1595
|
-
{ error: "Import failed", message: e.message }.to_json
|
|
2148
|
+
rescue StandardError => e
|
|
2149
|
+
ctx.status(500)
|
|
2150
|
+
ctx.json({ error: e.message })
|
|
2151
|
+
end
|
|
1596
2152
|
end
|
|
1597
|
-
end
|
|
1598
2153
|
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
2154
|
+
# API: Delete decision
|
|
2155
|
+
router.delete "/api/dmn/models/:model_id/decisions/:decision_id" do |ctx|
|
|
2156
|
+
ctx.content_type "application/json"
|
|
1602
2157
|
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
status 404
|
|
1612
|
-
content_type :json
|
|
1613
|
-
{ error: "Ruleset not found", message: e.message }.to_json
|
|
1614
|
-
rescue StandardError => e
|
|
1615
|
-
status 500
|
|
1616
|
-
content_type :json
|
|
1617
|
-
{ error: "Export failed", message: e.message }.to_json
|
|
2158
|
+
model_id = ctx.params[:model_id] || ctx.params["model_id"]
|
|
2159
|
+
decision_id = ctx.params[:decision_id] || ctx.params["decision_id"]
|
|
2160
|
+
result = Server.dmn_editor.delete_decision(
|
|
2161
|
+
model_id: model_id,
|
|
2162
|
+
decision_id: decision_id
|
|
2163
|
+
)
|
|
2164
|
+
|
|
2165
|
+
ctx.json({ success: result })
|
|
1618
2166
|
end
|
|
1619
|
-
end
|
|
1620
2167
|
|
|
1621
|
-
|
|
2168
|
+
# API: Add input column
|
|
2169
|
+
router.post "/api/dmn/models/:model_id/decisions/:decision_id/inputs" do |ctx|
|
|
2170
|
+
ctx.content_type "application/json"
|
|
1622
2171
|
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
2172
|
+
begin
|
|
2173
|
+
model_id = ctx.params[:model_id] || ctx.params["model_id"]
|
|
2174
|
+
decision_id = ctx.params[:decision_id] || ctx.params["decision_id"]
|
|
2175
|
+
request_body = RackRequestHelpers.read_body(ctx.env)
|
|
2176
|
+
data = JSON.parse(request_body)
|
|
1626
2177
|
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
2178
|
+
input = Server.dmn_editor.add_input(
|
|
2179
|
+
model_id: model_id,
|
|
2180
|
+
decision_id: decision_id,
|
|
2181
|
+
input_id: data["input_id"],
|
|
2182
|
+
label: data["label"],
|
|
2183
|
+
type_ref: data["type_ref"],
|
|
2184
|
+
expression: data["expression"]
|
|
2185
|
+
)
|
|
1630
2186
|
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
2187
|
+
if input
|
|
2188
|
+
ctx.status(201)
|
|
2189
|
+
ctx.json(input)
|
|
2190
|
+
else
|
|
2191
|
+
ctx.status(404)
|
|
2192
|
+
ctx.json({ error: "Decision not found" })
|
|
2193
|
+
end
|
|
2194
|
+
rescue StandardError => e
|
|
2195
|
+
ctx.status(500)
|
|
2196
|
+
ctx.json({ error: e.message })
|
|
2197
|
+
end
|
|
2198
|
+
end
|
|
1635
2199
|
|
|
1636
|
-
#
|
|
1637
|
-
|
|
1638
|
-
|
|
2200
|
+
# API: Add output column
|
|
2201
|
+
router.post "/api/dmn/models/:model_id/decisions/:decision_id/outputs" do |ctx|
|
|
2202
|
+
ctx.content_type "application/json"
|
|
1639
2203
|
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
2204
|
+
begin
|
|
2205
|
+
model_id = ctx.params[:model_id] || ctx.params["model_id"]
|
|
2206
|
+
decision_id = ctx.params[:decision_id] || ctx.params["decision_id"]
|
|
2207
|
+
request_body = RackRequestHelpers.read_body(ctx.env)
|
|
2208
|
+
data = JSON.parse(request_body)
|
|
1643
2209
|
|
|
1644
|
-
|
|
2210
|
+
output = Server.dmn_editor.add_output(
|
|
2211
|
+
model_id: model_id,
|
|
2212
|
+
decision_id: decision_id,
|
|
2213
|
+
output_id: data["output_id"],
|
|
2214
|
+
label: data["label"],
|
|
2215
|
+
type_ref: data["type_ref"],
|
|
2216
|
+
name: data["name"]
|
|
2217
|
+
)
|
|
1645
2218
|
|
|
1646
|
-
|
|
1647
|
-
|
|
2219
|
+
if output
|
|
2220
|
+
ctx.status(201)
|
|
2221
|
+
ctx.json(output)
|
|
2222
|
+
else
|
|
2223
|
+
ctx.status(404)
|
|
2224
|
+
ctx.json({ error: "Decision not found" })
|
|
2225
|
+
end
|
|
2226
|
+
rescue StandardError => e
|
|
2227
|
+
ctx.status(500)
|
|
2228
|
+
ctx.json({ error: e.message })
|
|
2229
|
+
end
|
|
2230
|
+
end
|
|
1648
2231
|
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
2232
|
+
# API: Add rule
|
|
2233
|
+
router.post "/api/dmn/models/:model_id/decisions/:decision_id/rules" do |ctx|
|
|
2234
|
+
ctx.content_type "application/json"
|
|
1652
2235
|
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
2236
|
+
begin
|
|
2237
|
+
model_id = ctx.params[:model_id] || ctx.params["model_id"]
|
|
2238
|
+
decision_id = ctx.params[:decision_id] || ctx.params["decision_id"]
|
|
2239
|
+
request_body = RackRequestHelpers.read_body(ctx.env)
|
|
2240
|
+
data = JSON.parse(request_body)
|
|
1656
2241
|
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
2242
|
+
rule = Server.dmn_editor.add_rule(
|
|
2243
|
+
model_id: model_id,
|
|
2244
|
+
decision_id: decision_id,
|
|
2245
|
+
rule_id: data["rule_id"],
|
|
2246
|
+
input_entries: data["input_entries"],
|
|
2247
|
+
output_entries: data["output_entries"],
|
|
2248
|
+
description: data["description"]
|
|
2249
|
+
)
|
|
2250
|
+
|
|
2251
|
+
if rule
|
|
2252
|
+
ctx.status(201)
|
|
2253
|
+
ctx.json(rule)
|
|
2254
|
+
else
|
|
2255
|
+
ctx.status(404)
|
|
2256
|
+
ctx.json({ error: "Decision not found" })
|
|
2257
|
+
end
|
|
2258
|
+
rescue StandardError => e
|
|
2259
|
+
ctx.status(500)
|
|
2260
|
+
ctx.json({ error: e.message })
|
|
2261
|
+
end
|
|
2262
|
+
end
|
|
2263
|
+
|
|
2264
|
+
# API: Update rule
|
|
2265
|
+
router.put "/api/dmn/models/:model_id/decisions/:decision_id/rules/:rule_id" do |ctx|
|
|
2266
|
+
ctx.content_type "application/json"
|
|
1661
2267
|
|
|
1662
|
-
checker = self.class.permission_checker
|
|
1663
|
-
unless checker.can?(@current_user, permission, resource)
|
|
1664
2268
|
begin
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
2269
|
+
model_id = ctx.params[:model_id] || ctx.params["model_id"]
|
|
2270
|
+
decision_id = ctx.params[:decision_id] || ctx.params["decision_id"]
|
|
2271
|
+
rule_id = ctx.params[:rule_id] || ctx.params["rule_id"]
|
|
2272
|
+
request_body = RackRequestHelpers.read_body(ctx.env)
|
|
2273
|
+
data = JSON.parse(request_body)
|
|
2274
|
+
|
|
2275
|
+
rule = Server.dmn_editor.update_rule(
|
|
2276
|
+
model_id: model_id,
|
|
2277
|
+
decision_id: decision_id,
|
|
2278
|
+
rule_id: rule_id,
|
|
2279
|
+
input_entries: data["input_entries"],
|
|
2280
|
+
output_entries: data["output_entries"],
|
|
2281
|
+
description: data["description"]
|
|
1671
2282
|
)
|
|
1672
|
-
|
|
1673
|
-
|
|
2283
|
+
|
|
2284
|
+
if rule
|
|
2285
|
+
ctx.json(rule)
|
|
2286
|
+
else
|
|
2287
|
+
ctx.status(404)
|
|
2288
|
+
ctx.json({ error: "Rule not found" })
|
|
2289
|
+
end
|
|
2290
|
+
rescue StandardError => e
|
|
2291
|
+
ctx.status(500)
|
|
2292
|
+
ctx.json({ error: e.message })
|
|
1674
2293
|
end
|
|
1675
|
-
# Move halt outside ensure block - Ruby 3.1 compatibility
|
|
1676
|
-
# Placing halt here instead of ensure block fixes Ruby 3.1 issue where
|
|
1677
|
-
# halt inside ensure doesn't reliably stop execution
|
|
1678
|
-
content_type :json
|
|
1679
|
-
halt 403, { error: "Permission denied: #{permission}" }.to_json
|
|
1680
2294
|
end
|
|
1681
2295
|
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
2296
|
+
# API: Delete rule
|
|
2297
|
+
router.delete "/api/dmn/models/:model_id/decisions/:decision_id/rules/:rule_id" do |ctx|
|
|
2298
|
+
ctx.content_type "application/json"
|
|
2299
|
+
|
|
2300
|
+
model_id = ctx.params[:model_id] || ctx.params["model_id"]
|
|
2301
|
+
decision_id = ctx.params[:decision_id] || ctx.params["decision_id"]
|
|
2302
|
+
rule_id = ctx.params[:rule_id] || ctx.params["rule_id"]
|
|
2303
|
+
result = Server.dmn_editor.delete_rule(
|
|
2304
|
+
model_id: model_id,
|
|
2305
|
+
decision_id: decision_id,
|
|
2306
|
+
rule_id: rule_id
|
|
1689
2307
|
)
|
|
1690
|
-
|
|
1691
|
-
|
|
2308
|
+
|
|
2309
|
+
ctx.json({ success: result })
|
|
1692
2310
|
end
|
|
1693
|
-
end
|
|
1694
2311
|
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
normalized = disable_flag.to_s.strip.downcase
|
|
1701
|
-
return true if %w[true 1 yes].include?(normalized)
|
|
1702
|
-
return false if %w[false 0 no].include?(normalized)
|
|
2312
|
+
# API: Validate DMN model
|
|
2313
|
+
router.get "/api/dmn/models/:id/validate" do |ctx|
|
|
2314
|
+
ctx.content_type "application/json"
|
|
2315
|
+
model_id = ctx.params[:id] || ctx.params["id"]
|
|
2316
|
+
ctx.json(Server.dmn_editor.validate_model(model_id))
|
|
1703
2317
|
end
|
|
1704
2318
|
|
|
1705
|
-
#
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
end
|
|
2319
|
+
# API: Export DMN model to XML
|
|
2320
|
+
router.get "/api/dmn/models/:id/export" do |ctx|
|
|
2321
|
+
ctx.content_type "application/xml"
|
|
1709
2322
|
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
2323
|
+
model_id = ctx.params[:id] || ctx.params["id"]
|
|
2324
|
+
xml = Server.dmn_editor.export_to_xml(model_id)
|
|
2325
|
+
if xml
|
|
2326
|
+
ctx.body(xml)
|
|
2327
|
+
else
|
|
2328
|
+
ctx.status(404)
|
|
2329
|
+
ctx.body("Model not found")
|
|
2330
|
+
end
|
|
2331
|
+
end
|
|
1713
2332
|
|
|
1714
|
-
#
|
|
1715
|
-
|
|
2333
|
+
# API: Visualize decision tree
|
|
2334
|
+
router.get "/api/dmn/models/:model_id/decisions/:decision_id/visualize/tree" do |ctx|
|
|
2335
|
+
model_id = ctx.params[:model_id] || ctx.params["model_id"]
|
|
2336
|
+
decision_id = ctx.params[:decision_id] || ctx.params["decision_id"]
|
|
2337
|
+
format = (ctx.params[:format] || ctx.params["format"]) || "svg"
|
|
1716
2338
|
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
2339
|
+
visualization = Server.dmn_editor.visualize_tree(
|
|
2340
|
+
model_id: model_id,
|
|
2341
|
+
decision_id: decision_id,
|
|
2342
|
+
format: format
|
|
2343
|
+
)
|
|
2344
|
+
|
|
2345
|
+
if visualization
|
|
2346
|
+
ctx.content_type(format == "svg" ? "image/svg+xml" : "text/plain")
|
|
2347
|
+
ctx.body(visualization)
|
|
2348
|
+
else
|
|
2349
|
+
ctx.status(404)
|
|
2350
|
+
ctx.body("Decision not found or not a tree")
|
|
1722
2351
|
end
|
|
1723
2352
|
end
|
|
1724
2353
|
|
|
1725
|
-
#
|
|
1726
|
-
|
|
2354
|
+
# API: Visualize decision graph
|
|
2355
|
+
router.get "/api/dmn/models/:id/visualize/graph" do |ctx|
|
|
2356
|
+
model_id = ctx.params[:id] || ctx.params["id"]
|
|
2357
|
+
format = (ctx.params[:format] || ctx.params["format"]) || "svg"
|
|
2358
|
+
|
|
2359
|
+
visualization = Server.dmn_editor.visualize_graph(
|
|
2360
|
+
model_id: model_id,
|
|
2361
|
+
format: format
|
|
2362
|
+
)
|
|
2363
|
+
|
|
2364
|
+
if visualization
|
|
2365
|
+
ctx.content_type(format == "svg" ? "image/svg+xml" : "text/plain")
|
|
2366
|
+
ctx.body(visualization)
|
|
2367
|
+
else
|
|
2368
|
+
ctx.status(404)
|
|
2369
|
+
ctx.body("Model not found")
|
|
2370
|
+
end
|
|
2371
|
+
end
|
|
2372
|
+
|
|
2373
|
+
# API: Import DMN file (uploads and imports to versioning system)
|
|
2374
|
+
router.post "/api/dmn/import" do |ctx|
|
|
2375
|
+
ctx.content_type "application/json"
|
|
2376
|
+
|
|
2377
|
+
begin
|
|
2378
|
+
# Check if request has multipart form data (file upload)
|
|
2379
|
+
file_param = ctx.params[:file] || ctx.params["file"]
|
|
2380
|
+
content_type_header = ctx.request.content_type || ""
|
|
2381
|
+
|
|
2382
|
+
if file_param && (file_param[:tempfile] || file_param["tempfile"])
|
|
2383
|
+
# File upload
|
|
2384
|
+
file = file_param[:tempfile] || file_param["tempfile"]
|
|
2385
|
+
xml_content = file.read
|
|
2386
|
+
filename = file_param[:filename] || file_param["filename"] || ""
|
|
2387
|
+
ruleset_name = (ctx.params[:ruleset_name] || ctx.params["ruleset_name"]) || filename.gsub(/\.dmn$/i, "")
|
|
2388
|
+
created_by = ctx.current_user ? ctx.current_user.id.to_s : (ctx.params[:created_by] || ctx.params["created_by"] || "system")
|
|
2389
|
+
elsif content_type_header.include?("application/json")
|
|
2390
|
+
# JSON body with XML content
|
|
2391
|
+
request_body = RackRequestHelpers.read_body(ctx.env)
|
|
2392
|
+
data = JSON.parse(request_body)
|
|
2393
|
+
xml_content = data["xml"] || data["content"]
|
|
2394
|
+
ruleset_name = data["ruleset_name"] || data["name"]
|
|
2395
|
+
created_by = ctx.current_user ? ctx.current_user.id.to_s : (data["created_by"] || "system")
|
|
2396
|
+
elsif content_type_header.include?("application/xml") || content_type_header.include?("text/xml")
|
|
2397
|
+
# Direct XML upload
|
|
2398
|
+
xml_content = RackRequestHelpers.read_body(ctx.env)
|
|
2399
|
+
ruleset_name = ctx.params[:ruleset_name] || ctx.params["ruleset_name"] || "imported_dmn"
|
|
2400
|
+
created_by = ctx.current_user ? ctx.current_user.id.to_s : (ctx.params[:created_by] || ctx.params["created_by"] || "system")
|
|
2401
|
+
else
|
|
2402
|
+
ctx.status(400)
|
|
2403
|
+
ctx.json({ error: "Invalid request. Expected file upload, JSON with 'xml' field, or XML content." })
|
|
2404
|
+
next
|
|
2405
|
+
end
|
|
2406
|
+
|
|
2407
|
+
raise ArgumentError, "DMN XML content is required" if xml_content.nil? || xml_content.strip.empty?
|
|
2408
|
+
|
|
2409
|
+
# Import using DMN Importer
|
|
2410
|
+
importer = Dmn::Importer.new(version_manager: Server.version_manager)
|
|
2411
|
+
result = importer.import_from_xml(
|
|
2412
|
+
xml_content,
|
|
2413
|
+
ruleset_name: ruleset_name,
|
|
2414
|
+
created_by: created_by
|
|
2415
|
+
)
|
|
2416
|
+
|
|
2417
|
+
ctx.status(201)
|
|
2418
|
+
ctx.json({
|
|
2419
|
+
success: true,
|
|
2420
|
+
ruleset_name: ruleset_name,
|
|
2421
|
+
decisions_imported: result[:decisions_imported],
|
|
2422
|
+
model: {
|
|
2423
|
+
id: result[:model].id,
|
|
2424
|
+
name: result[:model].name,
|
|
2425
|
+
namespace: result[:model].namespace,
|
|
2426
|
+
decisions: result[:model].decisions.map do |d|
|
|
2427
|
+
{
|
|
2428
|
+
id: d.id,
|
|
2429
|
+
name: d.name
|
|
2430
|
+
}
|
|
2431
|
+
end
|
|
2432
|
+
},
|
|
2433
|
+
versions: result[:versions].map do |v|
|
|
2434
|
+
{
|
|
2435
|
+
version: v[:version],
|
|
2436
|
+
rule_id: v[:rule_id],
|
|
2437
|
+
created_by: v[:created_by],
|
|
2438
|
+
created_at: v[:created_at]
|
|
2439
|
+
}
|
|
2440
|
+
end
|
|
2441
|
+
})
|
|
2442
|
+
rescue Dmn::InvalidDmnModelError, Dmn::DmnParseError => e
|
|
2443
|
+
ctx.status(400)
|
|
2444
|
+
ctx.json({ error: "DMN validation error", message: e.message })
|
|
2445
|
+
rescue StandardError => e
|
|
2446
|
+
ctx.status(500)
|
|
2447
|
+
ctx.json({ error: "Import failed", message: e.message })
|
|
2448
|
+
end
|
|
2449
|
+
end
|
|
2450
|
+
|
|
2451
|
+
# API: Export ruleset as DMN XML
|
|
2452
|
+
router.get "/api/dmn/export/:ruleset_id" do |ctx|
|
|
2453
|
+
ctx.content_type "application/xml"
|
|
2454
|
+
|
|
2455
|
+
begin
|
|
2456
|
+
ruleset_id = ctx.params[:ruleset_id] || ctx.params["ruleset_id"]
|
|
2457
|
+
exporter = Dmn::Exporter.new(version_manager: Server.version_manager)
|
|
2458
|
+
dmn_xml = exporter.export(ruleset_id)
|
|
2459
|
+
|
|
2460
|
+
ctx.headers["Content-Disposition"] = "attachment; filename=\"#{ruleset_id}.dmn\""
|
|
2461
|
+
ctx.body(dmn_xml)
|
|
2462
|
+
rescue Dmn::InvalidDmnModelError => e
|
|
2463
|
+
ctx.status(404)
|
|
2464
|
+
ctx.content_type "application/json"
|
|
2465
|
+
ctx.json({ error: "Ruleset not found", message: e.message })
|
|
2466
|
+
rescue StandardError => e
|
|
2467
|
+
ctx.status(500)
|
|
2468
|
+
ctx.content_type "application/json"
|
|
2469
|
+
ctx.json({ error: "Export failed", message: e.message })
|
|
2470
|
+
end
|
|
2471
|
+
end
|
|
1727
2472
|
end
|
|
1728
2473
|
|
|
1729
2474
|
# Class method to start the server (for CLI usage)
|
|
2475
|
+
# Framework-agnostic: uses Rack::Server which supports any Rack-compatible server
|
|
1730
2476
|
def self.start!(port: 4567, host: "0.0.0.0")
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
2477
|
+
@port = port
|
|
2478
|
+
@bind = host
|
|
2479
|
+
|
|
2480
|
+
puts "🎯 DecisionAgent Web UI starting..."
|
|
2481
|
+
puts "📍 Server: http://#{host == '0.0.0.0' ? 'localhost' : host}:#{port}"
|
|
2482
|
+
puts "⚡️ Press Ctrl+C to stop"
|
|
2483
|
+
puts ""
|
|
2484
|
+
|
|
2485
|
+
# Use Rack::Server which automatically selects the best available handler
|
|
2486
|
+
# Supports: Puma, WEBrick, Thin, Unicorn, etc. (any Rack-compatible server)
|
|
2487
|
+
Rack::Server.start(
|
|
2488
|
+
app: self,
|
|
2489
|
+
Port: port,
|
|
2490
|
+
Host: host,
|
|
2491
|
+
server: ENV.fetch("RACK_HANDLER", nil), # Allows override via ENV
|
|
2492
|
+
environment: ENV.fetch("RACK_ENV", "development")
|
|
2493
|
+
)
|
|
1742
2494
|
end
|
|
1743
2495
|
end
|
|
1744
2496
|
# rubocop:enable Metrics/ClassLength
|