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.
Files changed (220) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +234 -14
  3. data/lib/decision_agent/ab_testing/ab_test.rb +5 -1
  4. data/lib/decision_agent/ab_testing/ab_test_assignment.rb +2 -0
  5. data/lib/decision_agent/ab_testing/ab_test_manager.rb +2 -0
  6. data/lib/decision_agent/ab_testing/ab_testing_agent.rb +2 -0
  7. data/lib/decision_agent/ab_testing/storage/activerecord_adapter.rb +2 -13
  8. data/lib/decision_agent/ab_testing/storage/adapter.rb +2 -0
  9. data/lib/decision_agent/ab_testing/storage/memory_adapter.rb +2 -0
  10. data/lib/decision_agent/agent.rb +78 -9
  11. data/lib/decision_agent/audit/adapter.rb +2 -0
  12. data/lib/decision_agent/audit/logger_adapter.rb +2 -0
  13. data/lib/decision_agent/audit/null_adapter.rb +2 -0
  14. data/lib/decision_agent/auth/access_audit_logger.rb +2 -0
  15. data/lib/decision_agent/auth/authenticator.rb +2 -0
  16. data/lib/decision_agent/auth/password_reset_manager.rb +2 -0
  17. data/lib/decision_agent/auth/password_reset_token.rb +2 -0
  18. data/lib/decision_agent/auth/permission.rb +2 -0
  19. data/lib/decision_agent/auth/permission_checker.rb +2 -0
  20. data/lib/decision_agent/auth/rbac_adapter.rb +2 -0
  21. data/lib/decision_agent/auth/rbac_config.rb +2 -0
  22. data/lib/decision_agent/auth/role.rb +2 -0
  23. data/lib/decision_agent/auth/session.rb +2 -0
  24. data/lib/decision_agent/auth/session_manager.rb +2 -0
  25. data/lib/decision_agent/auth/user.rb +2 -0
  26. data/lib/decision_agent/context.rb +14 -0
  27. data/lib/decision_agent/decision.rb +113 -4
  28. data/lib/decision_agent/dmn/adapter.rb +2 -0
  29. data/lib/decision_agent/dmn/cache.rb +2 -2
  30. data/lib/decision_agent/dmn/decision_graph.rb +7 -7
  31. data/lib/decision_agent/dmn/decision_tree.rb +16 -8
  32. data/lib/decision_agent/dmn/errors.rb +2 -0
  33. data/lib/decision_agent/dmn/exporter.rb +2 -0
  34. data/lib/decision_agent/dmn/feel/evaluator.rb +130 -114
  35. data/lib/decision_agent/dmn/feel/functions.rb +2 -0
  36. data/lib/decision_agent/dmn/feel/parser.rb +2 -0
  37. data/lib/decision_agent/dmn/feel/simple_parser.rb +98 -77
  38. data/lib/decision_agent/dmn/feel/transformer.rb +56 -102
  39. data/lib/decision_agent/dmn/feel/types.rb +2 -0
  40. data/lib/decision_agent/dmn/importer.rb +2 -0
  41. data/lib/decision_agent/dmn/model.rb +2 -4
  42. data/lib/decision_agent/dmn/parser.rb +2 -0
  43. data/lib/decision_agent/dmn/testing.rb +3 -2
  44. data/lib/decision_agent/dmn/validator.rb +5 -3
  45. data/lib/decision_agent/dmn/visualizer.rb +7 -6
  46. data/lib/decision_agent/dsl/condition_evaluator.rb +242 -1375
  47. data/lib/decision_agent/dsl/helpers/cache_helpers.rb +82 -0
  48. data/lib/decision_agent/dsl/helpers/comparison_helpers.rb +98 -0
  49. data/lib/decision_agent/dsl/helpers/date_helpers.rb +91 -0
  50. data/lib/decision_agent/dsl/helpers/geospatial_helpers.rb +85 -0
  51. data/lib/decision_agent/dsl/helpers/operator_evaluation_helpers.rb +160 -0
  52. data/lib/decision_agent/dsl/helpers/parameter_parsing_helpers.rb +206 -0
  53. data/lib/decision_agent/dsl/helpers/template_helpers.rb +39 -0
  54. data/lib/decision_agent/dsl/helpers/utility_helpers.rb +45 -0
  55. data/lib/decision_agent/dsl/operators/base.rb +70 -0
  56. data/lib/decision_agent/dsl/operators/basic_comparison_operators.rb +80 -0
  57. data/lib/decision_agent/dsl/operators/collection_operators.rb +60 -0
  58. data/lib/decision_agent/dsl/operators/date_arithmetic_operators.rb +206 -0
  59. data/lib/decision_agent/dsl/operators/date_time_operators.rb +47 -0
  60. data/lib/decision_agent/dsl/operators/duration_operators.rb +149 -0
  61. data/lib/decision_agent/dsl/operators/financial_operators.rb +237 -0
  62. data/lib/decision_agent/dsl/operators/geospatial_operators.rb +106 -0
  63. data/lib/decision_agent/dsl/operators/mathematical_operators.rb +234 -0
  64. data/lib/decision_agent/dsl/operators/moving_window_operators.rb +135 -0
  65. data/lib/decision_agent/dsl/operators/numeric_operators.rb +120 -0
  66. data/lib/decision_agent/dsl/operators/rate_operators.rb +65 -0
  67. data/lib/decision_agent/dsl/operators/statistical_aggregations.rb +187 -0
  68. data/lib/decision_agent/dsl/operators/string_aggregations.rb +84 -0
  69. data/lib/decision_agent/dsl/operators/string_operators.rb +72 -0
  70. data/lib/decision_agent/dsl/operators/time_component_operators.rb +72 -0
  71. data/lib/decision_agent/dsl/rule_parser.rb +2 -0
  72. data/lib/decision_agent/dsl/schema_validator.rb +37 -14
  73. data/lib/decision_agent/errors.rb +2 -0
  74. data/lib/decision_agent/evaluation.rb +14 -2
  75. data/lib/decision_agent/evaluators/base.rb +2 -0
  76. data/lib/decision_agent/evaluators/dmn_evaluator.rb +108 -19
  77. data/lib/decision_agent/evaluators/json_rule_evaluator.rb +56 -11
  78. data/lib/decision_agent/evaluators/static_evaluator.rb +2 -0
  79. data/lib/decision_agent/explainability/condition_trace.rb +85 -0
  80. data/lib/decision_agent/explainability/explainability_result.rb +50 -0
  81. data/lib/decision_agent/explainability/rule_trace.rb +41 -0
  82. data/lib/decision_agent/explainability/trace_collector.rb +26 -0
  83. data/lib/decision_agent/monitoring/alert_manager.rb +7 -16
  84. data/lib/decision_agent/monitoring/dashboard_server.rb +383 -250
  85. data/lib/decision_agent/monitoring/metrics_collector.rb +2 -0
  86. data/lib/decision_agent/monitoring/monitored_agent.rb +2 -0
  87. data/lib/decision_agent/monitoring/prometheus_exporter.rb +3 -1
  88. data/lib/decision_agent/replay/replay.rb +4 -1
  89. data/lib/decision_agent/scoring/base.rb +2 -0
  90. data/lib/decision_agent/scoring/consensus.rb +2 -0
  91. data/lib/decision_agent/scoring/max_weight.rb +2 -0
  92. data/lib/decision_agent/scoring/threshold.rb +2 -0
  93. data/lib/decision_agent/scoring/weighted_average.rb +2 -0
  94. data/lib/decision_agent/simulation/errors.rb +20 -0
  95. data/lib/decision_agent/simulation/impact_analyzer.rb +500 -0
  96. data/lib/decision_agent/simulation/monte_carlo_simulator.rb +638 -0
  97. data/lib/decision_agent/simulation/replay_engine.rb +488 -0
  98. data/lib/decision_agent/simulation/scenario_engine.rb +320 -0
  99. data/lib/decision_agent/simulation/scenario_library.rb +165 -0
  100. data/lib/decision_agent/simulation/shadow_test_engine.rb +274 -0
  101. data/lib/decision_agent/simulation/what_if_analyzer.rb +1008 -0
  102. data/lib/decision_agent/simulation.rb +19 -0
  103. data/lib/decision_agent/testing/batch_test_importer.rb +6 -2
  104. data/lib/decision_agent/testing/batch_test_runner.rb +5 -2
  105. data/lib/decision_agent/testing/test_coverage_analyzer.rb +2 -0
  106. data/lib/decision_agent/testing/test_result_comparator.rb +2 -0
  107. data/lib/decision_agent/testing/test_scenario.rb +2 -0
  108. data/lib/decision_agent/version.rb +3 -1
  109. data/lib/decision_agent/versioning/activerecord_adapter.rb +108 -43
  110. data/lib/decision_agent/versioning/adapter.rb +9 -0
  111. data/lib/decision_agent/versioning/file_storage_adapter.rb +19 -6
  112. data/lib/decision_agent/versioning/version_manager.rb +9 -0
  113. data/lib/decision_agent/web/dmn_editor/serialization.rb +74 -0
  114. data/lib/decision_agent/web/dmn_editor/xml_builder.rb +107 -0
  115. data/lib/decision_agent/web/dmn_editor.rb +8 -67
  116. data/lib/decision_agent/web/middleware/auth_middleware.rb +2 -0
  117. data/lib/decision_agent/web/middleware/permission_middleware.rb +3 -1
  118. data/lib/decision_agent/web/public/app.js +186 -26
  119. data/lib/decision_agent/web/public/batch_testing.html +80 -6
  120. data/lib/decision_agent/web/public/dmn-editor.html +2 -2
  121. data/lib/decision_agent/web/public/dmn-editor.js +74 -8
  122. data/lib/decision_agent/web/public/index.html +69 -3
  123. data/lib/decision_agent/web/public/login.html +1 -1
  124. data/lib/decision_agent/web/public/sample_batch.csv +11 -0
  125. data/lib/decision_agent/web/public/sample_impact.csv +11 -0
  126. data/lib/decision_agent/web/public/sample_replay.csv +11 -0
  127. data/lib/decision_agent/web/public/sample_rules.json +118 -0
  128. data/lib/decision_agent/web/public/sample_shadow.csv +11 -0
  129. data/lib/decision_agent/web/public/sample_whatif.csv +11 -0
  130. data/lib/decision_agent/web/public/simulation.html +146 -0
  131. data/lib/decision_agent/web/public/simulation_impact.html +495 -0
  132. data/lib/decision_agent/web/public/simulation_replay.html +547 -0
  133. data/lib/decision_agent/web/public/simulation_shadow.html +561 -0
  134. data/lib/decision_agent/web/public/simulation_whatif.html +549 -0
  135. data/lib/decision_agent/web/public/styles.css +65 -0
  136. data/lib/decision_agent/web/public/users.html +1 -1
  137. data/lib/decision_agent/web/rack_helpers.rb +106 -0
  138. data/lib/decision_agent/web/rack_request_helpers.rb +196 -0
  139. data/lib/decision_agent/web/server.rb +2126 -1374
  140. data/lib/decision_agent.rb +19 -1
  141. data/lib/generators/decision_agent/install/install_generator.rb +2 -0
  142. data/lib/generators/decision_agent/install/templates/ab_test_assignment_model.rb +2 -0
  143. data/lib/generators/decision_agent/install/templates/ab_test_model.rb +2 -0
  144. data/lib/generators/decision_agent/install/templates/ab_testing_migration.rb +2 -0
  145. data/lib/generators/decision_agent/install/templates/migration.rb +2 -0
  146. data/lib/generators/decision_agent/install/templates/rule.rb +2 -0
  147. data/lib/generators/decision_agent/install/templates/rule_version.rb +2 -0
  148. metadata +103 -89
  149. data/spec/ab_testing/ab_test_assignment_spec.rb +0 -253
  150. data/spec/ab_testing/ab_test_manager_spec.rb +0 -612
  151. data/spec/ab_testing/ab_test_spec.rb +0 -270
  152. data/spec/ab_testing/ab_testing_agent_spec.rb +0 -655
  153. data/spec/ab_testing/storage/adapter_spec.rb +0 -64
  154. data/spec/ab_testing/storage/memory_adapter_spec.rb +0 -485
  155. data/spec/activerecord_thread_safety_spec.rb +0 -553
  156. data/spec/advanced_operators_spec.rb +0 -3150
  157. data/spec/agent_spec.rb +0 -289
  158. data/spec/api_contract_spec.rb +0 -430
  159. data/spec/audit_adapters_spec.rb +0 -92
  160. data/spec/auth/access_audit_logger_spec.rb +0 -394
  161. data/spec/auth/authenticator_spec.rb +0 -112
  162. data/spec/auth/password_reset_spec.rb +0 -294
  163. data/spec/auth/permission_checker_spec.rb +0 -207
  164. data/spec/auth/permission_spec.rb +0 -73
  165. data/spec/auth/rbac_adapter_spec.rb +0 -778
  166. data/spec/auth/rbac_config_spec.rb +0 -82
  167. data/spec/auth/role_spec.rb +0 -51
  168. data/spec/auth/session_manager_spec.rb +0 -172
  169. data/spec/auth/session_spec.rb +0 -112
  170. data/spec/auth/user_spec.rb +0 -130
  171. data/spec/comprehensive_edge_cases_spec.rb +0 -1777
  172. data/spec/context_spec.rb +0 -127
  173. data/spec/decision_agent_spec.rb +0 -96
  174. data/spec/decision_spec.rb +0 -423
  175. data/spec/dmn/decision_graph_spec.rb +0 -282
  176. data/spec/dmn/decision_tree_spec.rb +0 -203
  177. data/spec/dmn/feel/errors_spec.rb +0 -18
  178. data/spec/dmn/feel/functions_spec.rb +0 -400
  179. data/spec/dmn/feel/simple_parser_spec.rb +0 -274
  180. data/spec/dmn/feel/types_spec.rb +0 -176
  181. data/spec/dmn/feel_parser_spec.rb +0 -489
  182. data/spec/dmn/hit_policy_spec.rb +0 -202
  183. data/spec/dmn/integration_spec.rb +0 -226
  184. data/spec/dsl/condition_evaluator_spec.rb +0 -774
  185. data/spec/dsl_validation_spec.rb +0 -648
  186. data/spec/edge_cases_spec.rb +0 -353
  187. data/spec/evaluation_spec.rb +0 -364
  188. data/spec/evaluation_validator_spec.rb +0 -165
  189. data/spec/examples/feedback_aware_evaluator_spec.rb +0 -460
  190. data/spec/examples.txt +0 -1909
  191. data/spec/fixtures/dmn/complex_decision.dmn +0 -81
  192. data/spec/fixtures/dmn/invalid_structure.dmn +0 -31
  193. data/spec/fixtures/dmn/simple_decision.dmn +0 -40
  194. data/spec/issue_verification_spec.rb +0 -759
  195. data/spec/json_rule_evaluator_spec.rb +0 -587
  196. data/spec/monitoring/alert_manager_spec.rb +0 -378
  197. data/spec/monitoring/metrics_collector_spec.rb +0 -501
  198. data/spec/monitoring/monitored_agent_spec.rb +0 -225
  199. data/spec/monitoring/prometheus_exporter_spec.rb +0 -242
  200. data/spec/monitoring/storage/activerecord_adapter_spec.rb +0 -498
  201. data/spec/monitoring/storage/base_adapter_spec.rb +0 -61
  202. data/spec/monitoring/storage/memory_adapter_spec.rb +0 -247
  203. data/spec/performance_optimizations_spec.rb +0 -493
  204. data/spec/replay_edge_cases_spec.rb +0 -699
  205. data/spec/replay_spec.rb +0 -210
  206. data/spec/rfc8785_canonicalization_spec.rb +0 -215
  207. data/spec/scoring_spec.rb +0 -225
  208. data/spec/spec_helper.rb +0 -60
  209. data/spec/testing/batch_test_importer_spec.rb +0 -693
  210. data/spec/testing/batch_test_runner_spec.rb +0 -307
  211. data/spec/testing/test_coverage_analyzer_spec.rb +0 -292
  212. data/spec/testing/test_result_comparator_spec.rb +0 -392
  213. data/spec/testing/test_scenario_spec.rb +0 -113
  214. data/spec/thread_safety_spec.rb +0 -490
  215. data/spec/thread_safety_spec.rb.broken +0 -878
  216. data/spec/versioning/adapter_spec.rb +0 -156
  217. data/spec/versioning_spec.rb +0 -1030
  218. data/spec/web/middleware/auth_middleware_spec.rb +0 -133
  219. data/spec/web/middleware/permission_middleware_spec.rb +0 -247
  220. data/spec/web_ui_rack_spec.rb +0 -2134
@@ -1,7 +1,13 @@
1
- require "sinatra/base"
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
- class Server < Sinatra::Base
35
- set :public_folder, File.expand_path("public", __dir__)
36
- set :views, File.expand_path("views", __dir__)
37
- set :bind, "0.0.0.0"
38
- set :port, 4567
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
- def self.batch_test_storage
50
- @batch_test_storage ||= {}
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
- attr_writer :authenticator
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 ||= Auth::Authenticator.new
63
- end
89
+ return @authenticator if @authenticator
64
90
 
65
- class << self
66
- attr_writer :permission_checker
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 ||= Auth::PermissionChecker.new(adapter: DecisionAgent.rbac_config.adapter)
71
- end
97
+ return @permission_checker if @permission_checker
72
98
 
73
- class << self
74
- attr_writer :access_audit_logger
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 ||= Auth::AccessAuditLogger.new
79
- end
105
+ return @access_audit_logger if @access_audit_logger
80
106
 
81
- # Enable CORS for API calls
82
- before do
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
- # OPTIONS handler for CORS preflight
101
- options "*" do
102
- 200
103
- end
112
+ # Initialize router and define routes
113
+ def self.router
114
+ return @router if @router
104
115
 
105
- # Main page - serve the rule builder UI
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
- html_content = File.read(html_file, encoding: "UTF-8")
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
- # Determine the base path from the request
117
- # When mounted in Rails, request.script_name contains the mount path
118
- base_path = request.script_name.empty? ? "./" : "#{request.script_name}/"
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
- # Inject or update base tag
121
- base_tag = "<base href=\"#{base_path}\">"
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
- content_type "text/html"
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
- # Serve static assets explicitly (needed when mounted in Rails)
139
- get "/styles.css" do
140
- content_type "text/css"
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
- get "/app.js" do
145
- content_type "application/javascript"
146
- send_file File.join(settings.public_folder, "app.js")
147
- end
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
- # API: Validate rules
150
- post "/api/validate" do
151
- content_type :json
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
- begin
154
- # Parse request body
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
- # API: Test rule evaluation (optional feature)
190
- post "/api/evaluate" do
191
- content_type :json
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
- request_body = request.body.read
195
- data = JSON.parse(request_body)
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
- status 500
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
- # API: Get example rules
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
- examples.to_json
334
- end
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
- # Health check
337
- get "/health" do
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
- # Authentication API endpoints
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
- # POST /api/auth/login - User login
345
- post "/api/auth/login" do
346
- content_type :json
229
+ def self.require_authentication!(ctx)
230
+ return if ctx.current_user
347
231
 
348
- begin
349
- request_body = request.body.read
350
- data = JSON.parse(request_body)
232
+ ctx.content_type "application/json"
233
+ ctx.halt(401, { error: "Authentication required" }.to_json)
234
+ end
351
235
 
352
- email = data["email"]
353
- password = data["password"]
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
- unless email && password
356
- status 400
357
- return { error: "Email and password are required" }.to_json
358
- end
240
+ # Require authentication only if permissions are enabled
241
+ require_authentication!(ctx)
242
+ return if ctx.halted?
359
243
 
360
- session = self.class.authenticator.login(email, password)
244
+ checker = Server.permission_checker
245
+ granted = checker.can?(ctx.current_user, permission, resource)
361
246
 
362
- unless session
363
- self.class.access_audit_logger.log_authentication(
364
- "login",
365
- user_id: nil,
366
- email: email,
367
- success: false,
368
- reason: "Invalid credentials"
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
- status 401
371
- return { error: "Invalid email or password" }.to_json
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
- user = self.class.authenticator.find_user(session.user_id)
375
-
376
- self.class.access_audit_logger.log_authentication(
377
- "login",
378
- user_id: user.id,
379
- email: user.email,
380
- success: true
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
- status 500
393
- { error: e.message }.to_json
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
- # POST /api/auth/logout - User logout
398
- post "/api/auth/logout" do
399
- content_type :json
296
+ def self.parse_validation_errors(error_message)
297
+ # Extract individual errors from the formatted error message
298
+ errors = []
400
299
 
401
- begin
402
- token = extract_token
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
- { success: true, message: "Logged out successfully" }.to_json
417
- rescue StandardError => e
418
- status 500
419
- { error: e.message }.to_json
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
- # GET /api/auth/me - Current user info
424
- get "/api/auth/me" do
425
- content_type :json
319
+ def self.dmn_editor
320
+ @dmn_editor ||= DecisionAgent::Web::DmnEditor.new
321
+ end
426
322
 
427
- if @current_user
428
- @current_user.to_h.to_json
429
- else
430
- status 401
431
- { error: "Not authenticated" }.to_json
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
- # GET /api/auth/roles - List all roles
436
- get "/api/auth/roles" do
437
- content_type :json
438
- require_permission!(:read)
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
- roles = Auth::Role.all.map do |role|
441
- {
442
- id: role.to_s,
443
- name: Auth::Role.name_for(role),
444
- permissions: Auth::Role.permissions_for(role).map(&:to_s)
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
- roles.to_json
449
- end
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
- # POST /api/auth/users - Create user (admin only)
452
- post "/api/auth/users" do
453
- content_type :json
454
- require_permission!(:manage_users)
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
- begin
457
- request_body = request.body.read
458
- data = JSON.parse(request_body)
379
+ # API: Validate rules
380
+ router.post "/api/validate" do |ctx|
381
+ ctx.content_type "application/json"
459
382
 
460
- email = data["email"]
461
- password = data["password"]
462
- roles = data["roles"] || []
383
+ begin
384
+ # Parse request body
385
+ request_body = RackRequestHelpers.read_body(ctx.env)
386
+ data = JSON.parse(request_body)
463
387
 
464
- unless email && password
465
- status 400
466
- return { error: "Email and password are required" }.to_json
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
- # Validate roles
470
- roles.each do |role|
471
- unless Auth::Role.exists?(role)
472
- status 400
473
- return { error: "Invalid role: #{role}" }.to_json
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
- user = self.class.authenticator.create_user(
478
- email: email,
479
- password: password,
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
- checker = self.class.permission_checker
484
- self.class.access_audit_logger.log_access(
485
- user_id: checker.user_id(@current_user),
486
- action: "create_user",
487
- resource_type: "user",
488
- resource_id: user.id,
489
- success: true
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
- status 201
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
- # GET /api/auth/users - List users (admin only)
504
- get "/api/auth/users" do
505
- content_type :json
506
- require_permission!(:manage_users)
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
- users = self.class.authenticator.user_store.all.map(&:to_h)
509
- users.to_json
510
- end
608
+ # Authentication API endpoints
511
609
 
512
- # POST /api/auth/users/:id/roles - Assign role to user (admin only)
513
- post "/api/auth/users/:id/roles" do
514
- content_type :json
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
- begin
518
- user_id = params[:id]
519
- request_body = request.body.read
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
- role = data["role"]
618
+ email = data["email"]
619
+ password = data["password"]
523
620
 
524
- unless role
525
- status 400
526
- return { error: "Role is required" }.to_json
527
- end
621
+ unless email && password
622
+ ctx.status(400)
623
+ ctx.json({ error: "Email and password are required" })
624
+ next
625
+ end
528
626
 
529
- unless Auth::Role.exists?(role)
530
- status 400
531
- return { error: "Invalid role: #{role}" }.to_json
532
- end
627
+ session = Server.authenticator.login(email, password)
533
628
 
534
- user = self.class.authenticator.find_user(user_id)
535
- unless user
536
- status 404
537
- return { error: "User not found" }.to_json
538
- end
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
- user.assign_role(role)
642
+ user = Server.authenticator.find_user(session.user_id)
541
643
 
542
- checker = self.class.permission_checker
543
- self.class.access_audit_logger.log_access(
544
- user_id: checker.user_id(@current_user),
545
- action: "assign_role",
546
- resource_type: "user",
547
- resource_id: user.id,
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
- user.to_h.to_json
552
- rescue JSON::ParserError
553
- status 400
554
- { error: "Invalid JSON" }.to_json
555
- rescue StandardError => e
556
- status 500
557
- { error: e.message }.to_json
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
- # DELETE /api/auth/users/:id/roles/:role - Remove role from user (admin only)
562
- delete "/api/auth/users/:id/roles/:role" do
563
- content_type :json
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
- begin
567
- user_id = params[:id]
568
- role = params[:role]
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
- user = self.class.authenticator.find_user(user_id)
571
- unless user
572
- status 404
573
- return { error: "User not found" }.to_json
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
- user.remove_role(role)
577
-
578
- checker = self.class.permission_checker
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
- user.to_h.to_json
588
- rescue StandardError => e
589
- status 500
590
- { error: e.message }.to_json
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
- begin
600
- filters = {}
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
- filters[:user_id] = params[:user_id] if params[:user_id]
603
- filters[:event_type] = params[:event_type] if params[:event_type]
604
- filters[:start_time] = params[:start_time] if params[:start_time]
605
- filters[:end_time] = params[:end_time] if params[:end_time]
606
- filters[:limit] = params[:limit]&.to_i if params[:limit]
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
- logs = self.class.access_audit_logger.query(filters)
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
- # POST /api/auth/password/reset-request - Request password reset
617
- post "/api/auth/password/reset-request" do
618
- content_type :json
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
- begin
621
- request_body = request.body.read
622
- data = JSON.parse(request_body)
726
+ begin
727
+ request_body = RackRequestHelpers.read_body(ctx.env)
728
+ data = JSON.parse(request_body)
623
729
 
624
- email = data["email"]
730
+ email = data["email"]
731
+ password = data["password"]
732
+ roles = data["roles"] || []
625
733
 
626
- unless email
627
- status 400
628
- return { error: "Email is required" }.to_json
629
- end
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
- token = self.class.authenticator.request_password_reset(email)
749
+ if invalid_role
750
+ ctx.status(400)
751
+ ctx.json({ error: "Invalid role: #{invalid_role}" })
752
+ next
753
+ end
632
754
 
633
- # For security, we always return success even if user doesn't exist
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
- success: true
757
+ password: password,
758
+ roles: roles
641
759
  )
642
760
 
643
- {
644
- success: true,
645
- message: "If the email exists, a password reset token has been generated",
646
- # In production, remove this token from response and send via email
647
- token: token.token,
648
- expires_at: token.expires_at.iso8601
649
- }.to_json
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
- success: true,
662
- message: "If the email exists, a password reset token has been generated"
663
- }.to_json
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
- # POST /api/auth/password/reset - Reset password with token
675
- post "/api/auth/password/reset" do
676
- content_type :json
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
- begin
679
- request_body = request.body.read
680
- data = JSON.parse(request_body)
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
- unless new_password.length >= 8
691
- status 400
692
- return { error: "Password must be at least 8 characters long" }.to_json
693
- end
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
- user = self.class.authenticator.reset_password(token, new_password)
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
- unless user
698
- status 400
699
- return { error: "Invalid or expired reset token" }.to_json
700
- end
802
+ role = data["role"]
701
803
 
702
- self.class.access_audit_logger.log_authentication(
703
- "password_reset",
704
- user_id: user.id,
705
- email: user.email,
706
- success: true
707
- )
804
+ unless role
805
+ ctx.status(400)
806
+ ctx.json({ error: "Role is required" })
807
+ next
808
+ end
708
809
 
709
- {
710
- success: true,
711
- message: "Password has been reset successfully"
712
- }.to_json
713
- rescue JSON::ParserError
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
- # Versioning API endpoints
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
- # Create a new version
725
- post "/api/versions" do
726
- content_type :json
727
- require_permission!(:write)
823
+ user.assign_role(role)
728
824
 
729
- begin
730
- request_body = request.body.read
731
- data = JSON.parse(request_body)
732
-
733
- rule_id = data["rule_id"]
734
- rule_content = data["content"]
735
- created_by = data["created_by"] || (@current_user&.email || "system")
736
- changelog = data["changelog"]
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
- status 201
746
- version.to_json
747
- rescue StandardError => e
748
- status 500
749
- { error: e.message }.to_json
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
- # List all versions for a rule
754
- get "/api/rules/:rule_id/versions" do
755
- content_type :json
756
- require_permission!(:read)
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
- begin
759
- rule_id = params[:rule_id]
760
- limit = params[:limit]&.to_i
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
- versions = version_manager.get_versions(rule_id: rule_id, limit: limit)
861
+ user.remove_role(role)
763
862
 
764
- versions.to_json
765
- rescue StandardError => e
766
- status 500
767
- { error: e.message }.to_json
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
- # Get version history with metadata
772
- get "/api/rules/:rule_id/history" do
773
- content_type :json
774
- require_permission!(:read)
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
- begin
777
- rule_id = params[:rule_id]
778
- history = version_manager.get_history(rule_id: rule_id)
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
- history.to_json
781
- rescue StandardError => e
782
- status 500
783
- { error: e.message }.to_json
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
- # Get a specific version
788
- get "/api/versions/:version_id" do
789
- content_type :json
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
- begin
793
- version_id = params[:version_id]
794
- version = version_manager.get_version(version_id: version_id)
965
+ begin
966
+ request_body = RackRequestHelpers.read_body(ctx.env)
967
+ data = JSON.parse(request_body)
795
968
 
796
- if version
797
- version.to_json
798
- else
799
- status 404
800
- { error: "Version not found" }.to_json
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
- # Activate a version (rollback)
809
- post "/api/versions/:version_id/activate" do
810
- content_type :json
811
- require_permission!(:deploy)
1012
+ # Versioning API endpoints
812
1013
 
813
- begin
814
- version_id = params[:version_id]
815
- request_body = request.body.read
816
- data = request_body.empty? ? {} : JSON.parse(request_body)
817
- performed_by = data["performed_by"] || (@current_user&.email || "system")
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
- version.to_json
825
- rescue StandardError => e
826
- status 500
827
- { error: e.message }.to_json
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
- # Compare two versions
832
- get "/api/versions/:version_id_1/compare/:version_id_2" do
833
- content_type :json
834
- require_permission!(:read)
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
- begin
837
- version_id_1 = params[:version_id_1]
838
- version_id_2 = params[:version_id_2]
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
- comparison = version_manager.compare(
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
- if comparison
846
- comparison.to_json
847
- else
848
- status 404
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
- # Delete a version
858
- delete "/api/versions/:version_id" do
859
- content_type :json
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
- begin
862
- require_permission!(:delete)
863
- version_id = params[:version_id]
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
- # Ensure version_id is present
866
- unless version_id
867
- status 400
868
- return { error: "Version ID is required" }.to_json
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
- result = version_manager.delete_version(version_id: version_id)
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
- if result == false
874
- status 404
875
- { error: "Version not found" }.to_json
876
- else
877
- status 200
878
- { success: true, message: "Version deleted successfully" }.to_json
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
- # Batch Testing API Endpoints
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
- # POST /api/testing/batch/import - Upload CSV/Excel file
897
- post "/api/testing/batch/import" do
898
- content_type :json
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
- begin
901
- unless params[:file] && params[:file][:tempfile]
902
- status 400
903
- return { error: "No file uploaded" }.to_json
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
- uploaded_file = params[:file][:tempfile]
907
- filename = params[:file][:filename] || "uploaded_file"
908
- file_extension = File.extname(filename).downcase
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
- # Create temporary file
911
- temp_file = Tempfile.new(["batch_test", file_extension])
912
- temp_file.binmode
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
- # Import scenarios based on file type
917
- importer = DecisionAgent::Testing::BatchTestImporter.new
1136
+ comparison = Server.version_manager.compare(
1137
+ version_id_1: version_id_1,
1138
+ version_id_2: version_id_2
1139
+ )
918
1140
 
919
- scenarios = if [".xlsx", ".xls"].include?(file_extension)
920
- importer.import_excel(temp_file.path)
921
- else
922
- importer.import_csv(temp_file.path)
923
- end
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
- temp_file.close
926
- temp_file.unlink
1153
+ # Delete a version
1154
+ router.delete "/api/versions/:version_id" do |ctx|
1155
+ ctx.content_type "application/json"
927
1156
 
928
- # Check for import errors - return error status if there are errors and no scenarios
929
- if importer.errors.any? && scenarios.empty?
930
- status 422
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
- # If there are errors but some scenarios were created, still return error status
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
- # Store scenarios with a unique ID
948
- test_id = SecureRandom.uuid
949
- self.class.batch_test_storage_mutex.synchronize do
950
- self.class.batch_test_storage[test_id] = {
951
- id: test_id,
952
- scenarios: scenarios,
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
- status 201
961
- {
962
- test_id: test_id,
963
- scenarios_count: scenarios.size,
964
- errors: importer.errors,
965
- warnings: importer.warnings
966
- }.to_json
967
- rescue DecisionAgent::ImportError => e
968
- status 422
969
- { error: e.message, errors: importer&.errors || [] }.to_json
970
- rescue StandardError => e
971
- status 500
972
- { error: "Failed to import file: #{e.message}" }.to_json
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
- # POST /api/testing/batch/run - Execute batch test
977
- post "/api/testing/batch/run" do
978
- content_type :json
1193
+ # Batch Testing API Endpoints
979
1194
 
980
- begin
981
- request_body = request.body.read
982
- data = request_body.empty? ? {} : JSON.parse(request_body)
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
- test_id = data["test_id"] || params[:test_id]
985
- rules_json = data["rules"]
986
- options = data["options"] || {}
1199
+ begin
1200
+ # Handle file upload from multipart form data
1201
+ file_param = ctx.params[:file] || ctx.params["file"]
987
1202
 
988
- unless test_id
989
- status 400
990
- return { error: "test_id is required" }.to_json
991
- end
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
- unless rules_json
994
- status 400
995
- return { error: "rules JSON is required" }.to_json
996
- end
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
- # Get stored scenarios
999
- test_data = nil
1000
- self.class.batch_test_storage_mutex.synchronize do
1001
- test_data = self.class.batch_test_storage[test_id]
1002
- end
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
- unless test_data
1005
- status 404
1006
- return { error: "Test not found" }.to_json
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
- # Create agent from rules
1010
- evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules_json)
1011
- agent = DecisionAgent::Agent.new(evaluators: [evaluator])
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
- # Update status
1014
- self.class.batch_test_storage_mutex.synchronize do
1015
- self.class.batch_test_storage[test_id][:status] = "running"
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
- # Run batch test
1020
- runner = DecisionAgent::Testing::BatchTestRunner.new(agent)
1021
- results = runner.run(
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
- # Calculate comparison if expected results exist
1029
- comparison = nil
1030
- if test_data[:scenarios].any?(&:expected_result?)
1031
- comparator = DecisionAgent::Testing::TestResultComparator.new
1032
- comparison = comparator.compare(results, test_data[:scenarios])
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
- # Calculate coverage
1036
- coverage_analyzer = DecisionAgent::Testing::TestCoverageAnalyzer.new
1037
- coverage = coverage_analyzer.analyze(results, agent)
1038
-
1039
- # Store results
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
- test_id: test_id,
1051
- status: "completed",
1052
- results_count: results.size,
1053
- statistics: runner.statistics,
1054
- comparison: comparison,
1055
- coverage: coverage.to_h
1056
- }.to_json
1057
- rescue StandardError => e
1058
- # Update status to failed
1059
- if test_id
1060
- self.class.batch_test_storage_mutex.synchronize do
1061
- if self.class.batch_test_storage[test_id]
1062
- self.class.batch_test_storage[test_id][:status] = "failed"
1063
- self.class.batch_test_storage[test_id][:error] = e.message
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
- status 500
1069
- { error: "Batch test execution failed: #{e.message}" }.to_json
1376
+ ctx.status(500)
1377
+ ctx.json({ error: "Batch test execution failed: #{e.message}" })
1378
+ end
1070
1379
  end
1071
- end
1072
1380
 
1073
- # GET /api/testing/batch/:id/results - Get batch test results
1074
- get "/api/testing/batch/:id/results" do
1075
- content_type :json
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
- begin
1078
- test_id = params[:id]
1385
+ begin
1386
+ test_id = ctx.params[:id] || ctx.params["id"]
1079
1387
 
1080
- test_data = nil
1081
- self.class.batch_test_storage_mutex.synchronize do
1082
- test_data = self.class.batch_test_storage[test_id]
1083
- end
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
- unless test_data
1086
- status 404
1087
- return { error: "Test not found" }.to_json
1088
- end
1393
+ unless test_data
1394
+ ctx.status(404)
1395
+ ctx.json({ error: "Test not found" })
1396
+ next
1397
+ end
1089
1398
 
1090
- {
1091
- test_id: test_data[:id],
1092
- status: test_data[:status],
1093
- created_at: test_data[:created_at],
1094
- started_at: test_data[:started_at],
1095
- completed_at: test_data[:completed_at],
1096
- scenarios_count: test_data[:scenarios]&.size || 0,
1097
- results: test_data[:results],
1098
- comparison: test_data[:comparison],
1099
- statistics: test_data[:statistics],
1100
- error: test_data[:error]
1101
- }.to_json
1102
- rescue StandardError => e
1103
- status 500
1104
- { error: e.message }.to_json
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
- # GET /api/testing/batch/:id/coverage - Get coverage report
1109
- get "/api/testing/batch/:id/coverage" do
1110
- content_type :json
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
- begin
1113
- test_id = params[:id]
1421
+ begin
1422
+ test_id = ctx.params[:id] || ctx.params["id"]
1114
1423
 
1115
- test_data = nil
1116
- self.class.batch_test_storage_mutex.synchronize do
1117
- test_data = self.class.batch_test_storage[test_id]
1118
- end
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
- unless test_data
1121
- status 404
1122
- return { error: "Test not found" }.to_json
1123
- end
1429
+ unless test_data
1430
+ ctx.status(404)
1431
+ ctx.json({ error: "Test not found" })
1432
+ next
1433
+ end
1124
1434
 
1125
- unless test_data[:coverage]
1126
- status 404
1127
- return { error: "Coverage report not available. Run the batch test first." }.to_json
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
- test_id: test_data[:id],
1132
- coverage: test_data[:coverage]
1133
- }.to_json
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 500
1136
- { error: e.message }.to_json
1456
+ ctx.status(404)
1457
+ ctx.body("Batch testing page not found: #{e.message}")
1137
1458
  end
1138
- end
1139
1459
 
1140
- # GET /testing/batch - Batch testing UI page
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
- # GET /auth/login - Login page
1149
- get "/auth/login" do
1150
- send_file File.join(settings.public_folder, "login.html")
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
- # GET /auth/users - User management page
1157
- get "/auth/users" do
1158
- send_file File.join(settings.public_folder, "users.html")
1159
- rescue StandardError
1160
- status 404
1161
- "User management page not found"
1162
- end
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
- # DMN Editor Routes
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
- # GET /dmn/editor - DMN Editor UI page
1167
- get "/dmn/editor" do
1168
- send_file File.join(settings.public_folder, "dmn-editor.html")
1169
- rescue StandardError
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
- # API: List all DMN models
1175
- get "/api/dmn/models" do
1176
- content_type :json
1177
- dmn_editor.list_models.to_json
1178
- end
1494
+ # Create replay engine
1495
+ replay_engine = DecisionAgent::Simulation::ReplayEngine.new(
1496
+ agent: agent,
1497
+ version_manager: version_manager
1498
+ )
1179
1499
 
1180
- # API: Create new DMN model
1181
- post "/api/dmn/models" do
1182
- content_type :json
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
- begin
1185
- request_body = request.body.read
1186
- data = JSON.parse(request_body)
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
- model = dmn_editor.create_model(
1189
- name: data["name"],
1190
- namespace: data["namespace"]
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
- status 201
1194
- model.to_json
1195
- rescue StandardError => e
1196
- status 500
1197
- { error: e.message }.to_json
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
- # API: Get DMN model
1202
- get "/api/dmn/models/:id" do
1203
- content_type :json
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
- model = dmn_editor.get_model(params[:id])
1206
- if model
1207
- model.to_json
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
- # API: Update DMN model
1215
- put "/api/dmn/models/:id" do
1216
- content_type :json
1564
+ scenarios = data["scenarios"]
1565
+ rule_version = data["rule_version"]
1566
+ options = data["options"] || {}
1217
1567
 
1218
- begin
1219
- request_body = request.body.read
1220
- data = JSON.parse(request_body)
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
- model = dmn_editor.update_model(
1223
- params[:id],
1224
- name: data["name"],
1225
- namespace: data["namespace"]
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
- if model
1229
- model.to_json
1230
- else
1231
- status 404
1232
- { error: "Model not found" }.to_json
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
- # API: Delete DMN model
1241
- delete "/api/dmn/models/:id" do
1242
- content_type :json
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
- result = dmn_editor.delete_model(params[:id])
1245
- { success: result }.to_json
1246
- end
1626
+ begin
1627
+ request_body = RackRequestHelpers.read_body(ctx.env)
1628
+ data = request_body.empty? ? {} : JSON.parse(request_body)
1247
1629
 
1248
- # API: Add decision to model
1249
- post "/api/dmn/models/:model_id/decisions" do
1250
- content_type :json
1630
+ base_scenario = data["base_scenario"]
1631
+ variations = data["variations"]
1632
+ rule_version = data["rule_version"]
1251
1633
 
1252
- begin
1253
- request_body = request.body.read
1254
- data = JSON.parse(request_body)
1255
-
1256
- decision = dmn_editor.add_decision(
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
- if decision
1264
- status 201
1265
- decision.to_json
1266
- else
1267
- status 404
1268
- { error: "Model not found" }.to_json
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
- # API: Update decision
1277
- put "/api/dmn/models/:model_id/decisions/:decision_id" do
1278
- content_type :json
1673
+ # POST /api/simulation/impact - Impact analysis
1674
+ router.post "/api/simulation/impact" do |ctx|
1675
+ ctx.content_type "application/json"
1279
1676
 
1280
- begin
1281
- request_body = request.body.read
1282
- data = JSON.parse(request_body)
1283
-
1284
- decision = dmn_editor.update_decision(
1285
- model_id: params[:model_id],
1286
- decision_id: params[:decision_id],
1287
- name: data["name"],
1288
- logic: data["logic"]
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
- if decision
1292
- decision.to_json
1293
- else
1294
- status 404
1295
- { error: "Decision not found" }.to_json
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
- # API: Delete decision
1304
- delete "/api/dmn/models/:model_id/decisions/:decision_id" do
1305
- content_type :json
1729
+ # POST /api/simulation/shadow - Shadow testing
1730
+ router.post "/api/simulation/shadow" do |ctx|
1731
+ ctx.content_type "application/json"
1306
1732
 
1307
- result = dmn_editor.delete_decision(
1308
- model_id: params[:model_id],
1309
- decision_id: params[:decision_id]
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
- { success: result }.to_json
1313
- end
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
- # API: Add input column
1316
- post "/api/dmn/models/:model_id/decisions/:decision_id/inputs" do
1317
- content_type :json
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
- begin
1320
- request_body = request.body.read
1321
- data = JSON.parse(request_body)
1322
-
1323
- input = dmn_editor.add_input(
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
- if input
1333
- status 201
1334
- input.to_json
1335
- else
1336
- status 404
1337
- { error: "Decision not found" }.to_json
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
- # API: Add output column
1346
- post "/api/dmn/models/:model_id/decisions/:decision_id/outputs" do
1347
- content_type :json
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
- begin
1350
- request_body = request.body.read
1351
- data = JSON.parse(request_body)
1352
-
1353
- output = dmn_editor.add_output(
1354
- model_id: params[:model_id],
1355
- decision_id: params[:decision_id],
1356
- output_id: data["output_id"],
1357
- label: data["label"],
1358
- type_ref: data["type_ref"],
1359
- name: data["name"]
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
- if output
1363
- status 201
1364
- output.to_json
1365
- else
1366
- status 404
1367
- { error: "Decision not found" }.to_json
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
- # API: Add rule
1376
- post "/api/dmn/models/:model_id/decisions/:decision_id/rules" do
1377
- content_type :json
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
- begin
1380
- request_body = request.body.read
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
- if rule
1393
- status 201
1394
- rule.to_json
1395
- else
1396
- status 404
1397
- { error: "Decision not found" }.to_json
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
- status 500
1401
- { error: e.message }.to_json
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
- # API: Update rule
1406
- put "/api/dmn/models/:model_id/decisions/:decision_id/rules/:rule_id" do
1407
- content_type :json
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
- begin
1410
- request_body = request.body.read
1411
- data = JSON.parse(request_body)
1412
-
1413
- rule = dmn_editor.update_rule(
1414
- model_id: params[:model_id],
1415
- decision_id: params[:decision_id],
1416
- rule_id: params[:rule_id],
1417
- input_entries: data["input_entries"],
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
- if rule
1423
- rule.to_json
1424
- else
1425
- status 404
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
- status 500
1430
- { error: e.message }.to_json
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
- # API: Delete rule
1435
- delete "/api/dmn/models/:model_id/decisions/:decision_id/rules/:rule_id" do
1436
- content_type :json
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
- result = dmn_editor.delete_rule(
1439
- model_id: params[:model_id],
1440
- decision_id: params[:decision_id],
1441
- rule_id: params[:rule_id]
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
- { success: result }.to_json
1445
- end
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
- # API: Validate DMN model
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
- # API: Export DMN model to XML
1454
- get "/api/dmn/models/:id/export" do
1455
- content_type :xml
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
- xml = dmn_editor.export_to_xml(params[:id])
1458
- if xml
1459
- xml
1460
- else
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
- # API: Import DMN model from XML
1467
- post "/api/dmn/models/import" do
1468
- content_type :json
2025
+ # API: Create new DMN model
2026
+ router.post "/api/dmn/models" do |ctx|
2027
+ ctx.content_type "application/json"
1469
2028
 
1470
- begin
1471
- request_body = request.body.read
1472
- data = JSON.parse(request_body)
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
- model = dmn_editor.import_from_xml(data["xml"], name: data["name"])
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
- status 201
1478
- model.to_json
2053
+ ctx.json(model)
1479
2054
  else
1480
- status 400
1481
- { error: "Failed to import model" }.to_json
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
- # API: Visualize decision tree
1490
- get "/api/dmn/models/:model_id/decisions/:decision_id/visualize/tree" do
1491
- format = params[:format] || "svg"
2060
+ # API: Update DMN model
2061
+ router.put "/api/dmn/models/:id" do |ctx|
2062
+ ctx.content_type "application/json"
1492
2063
 
1493
- visualization = dmn_editor.visualize_tree(
1494
- model_id: params[:model_id],
1495
- decision_id: params[:decision_id],
1496
- format: format
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
- if visualization
1500
- content_type format == "svg" ? "image/svg+xml" : :text
1501
- visualization
1502
- else
1503
- status 404
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
- # API: Visualize decision graph
1509
- get "/api/dmn/models/:id/visualize/graph" do
1510
- format = params[:format] || "svg"
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
- visualization = dmn_editor.visualize_graph(
1513
- model_id: params[:id],
1514
- format: format
1515
- )
2087
+ # API: Delete DMN model
2088
+ router.delete "/api/dmn/models/:id" do |ctx|
2089
+ ctx.content_type "application/json"
1516
2090
 
1517
- if visualization
1518
- content_type format == "svg" ? "image/svg+xml" : :text
1519
- visualization
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
- # API: Import DMN file (uploads and imports to versioning system)
1527
- post "/api/dmn/import" do
1528
- content_type :json
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
- begin
1531
- # Check if request has multipart form data (file upload)
1532
- if params[:file] && params[:file][:tempfile]
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
- xml_content = data["xml"] || data["content"]
1543
- ruleset_name = data["ruleset_name"] || data["name"]
1544
- created_by = @current_user ? @current_user.id.to_s : data["created_by"] || "system"
1545
- elsif request.content_type&.include?("application/xml") || request.content_type&.include?("text/xml")
1546
- # Direct XML upload
1547
- xml_content = request.body.read
1548
- ruleset_name = params[:ruleset_name] || "imported_dmn"
1549
- created_by = @current_user ? @current_user.id.to_s : params[:created_by] || "system"
1550
- else
1551
- status 400
1552
- return { error: "Invalid request. Expected file upload, JSON with 'xml' field, or XML content." }.to_json
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
- raise ArgumentError, "DMN XML content is required" if xml_content.nil? || xml_content.strip.empty?
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
- # Import using DMN Importer
1558
- importer = Dmn::Importer.new(version_manager: version_manager)
1559
- result = importer.import_from_xml(
1560
- xml_content,
1561
- ruleset_name: ruleset_name,
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
- status 201
1566
- {
1567
- success: true,
1568
- ruleset_name: ruleset_name,
1569
- decisions_imported: result[:decisions_imported],
1570
- model: {
1571
- id: result[:model].id,
1572
- name: result[:model].name,
1573
- namespace: result[:model].namespace,
1574
- decisions: result[:model].decisions.map do |d|
1575
- {
1576
- id: d.id,
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
- }.to_json
1590
- rescue Dmn::InvalidDmnModelError, Dmn::DmnParseError => e
1591
- status 400
1592
- { error: "DMN validation error", message: e.message }.to_json
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
- # API: Export ruleset as DMN XML
1600
- get "/api/dmn/export/:ruleset_id" do
1601
- content_type :xml
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
- begin
1604
- ruleset_id = params[:ruleset_id]
1605
- exporter = Dmn::Exporter.new(version_manager: version_manager)
1606
- dmn_xml = exporter.export(ruleset_id)
1607
-
1608
- headers["Content-Disposition"] = "attachment; filename=\"#{ruleset_id}.dmn\""
1609
- dmn_xml
1610
- rescue Dmn::InvalidDmnModelError => e
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
- private
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
- def dmn_editor
1624
- @dmn_editor ||= DmnEditor.new
1625
- end
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
- def version_manager
1628
- @version_manager ||= DecisionAgent::Versioning::VersionManager.new
1629
- end
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
- def extract_token
1632
- # Check Authorization header: Bearer <token>
1633
- auth_header = request.env["HTTP_AUTHORIZATION"]
1634
- return auth_header[7..] if auth_header&.start_with?("Bearer ")
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
- # Check session cookie
1637
- cookie_token = request.cookies["decision_agent_session"]
1638
- return cookie_token if cookie_token
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
- # Check query parameter
1641
- params["token"]
1642
- end
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
- attr_reader :current_user
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
- def require_authentication!
1647
- return if @current_user
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
- content_type :json
1650
- halt 401, { error: "Authentication required" }.to_json
1651
- end
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
- def require_permission!(permission, resource = nil)
1654
- # Always require authentication first
1655
- require_authentication!
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
- # Skip permission checks if disabled via environment variable
1658
- # Useful for development environments
1659
- # This allows authenticated users to bypass permission checks
1660
- return true if permissions_disabled?
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
- self.class.access_audit_logger.log_permission_check(
1666
- user_id: checker.user_id(@current_user),
1667
- permission: permission,
1668
- resource_type: resource&.class&.name,
1669
- resource_id: resource&.id,
1670
- granted: false
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
- rescue StandardError
1673
- # If logging fails, continue with permission denial
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
- begin
1683
- self.class.access_audit_logger.log_permission_check(
1684
- user_id: checker.user_id(@current_user),
1685
- permission: permission,
1686
- resource_type: resource&.class&.name,
1687
- resource_id: resource&.id,
1688
- granted: true
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
- rescue StandardError
1691
- # If logging fails, continue - permission was granted
2308
+
2309
+ ctx.json({ success: result })
1692
2310
  end
1693
- end
1694
2311
 
1695
- def permissions_disabled?
1696
- # Check explicit environment variable first
1697
- # Make it case-insensitive and handle whitespace
1698
- disable_flag = ENV.fetch("DISABLE_WEBUI_PERMISSIONS", nil)
1699
- if disable_flag
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
- # Auto-disable in development environments if not explicitly set
1706
- env = ENV["RACK_ENV"] || ENV["RAILS_ENV"] || "development"
1707
- env == "development"
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
- def parse_validation_errors(error_message)
1711
- # Extract individual errors from the formatted error message
1712
- errors = []
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
- # The error message is formatted with numbered errors
1715
- lines = error_message.split("\n")
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
- lines.each do |line|
1718
- # Match lines like " 1. Error message"
1719
- if line.match?(/^\s*\d+\.\s+/)
1720
- error = line.gsub(/^\s*\d+\.\s+/, "").strip
1721
- errors << error unless error.empty?
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
- # If no errors were parsed, return the full message
1726
- errors.empty? ? [error_message] : errors
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
- set :port, port
1732
- set :bind, host
1733
- run!
1734
- end
1735
-
1736
- # Rack interface for mounting in Rails/Rack apps
1737
- # Example:
1738
- # # config/routes.rb
1739
- # mount DecisionAgent::Web::Server, at: "/decision_agent"
1740
- def self.call(env)
1741
- new.call(env)
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