ruby_reactor 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +10 -2
  3. data/README.md +72 -3
  4. data/Rakefile +27 -2
  5. data/documentation/images/failed_order_processing.png +0 -0
  6. data/documentation/images/payment_workflow.png +0 -0
  7. data/documentation/interrupts.md +161 -0
  8. data/gui/.gitignore +24 -0
  9. data/gui/README.md +73 -0
  10. data/gui/eslint.config.js +23 -0
  11. data/gui/index.html +13 -0
  12. data/gui/package-lock.json +5925 -0
  13. data/gui/package.json +46 -0
  14. data/gui/postcss.config.js +6 -0
  15. data/gui/public/vite.svg +1 -0
  16. data/gui/src/App.css +42 -0
  17. data/gui/src/App.tsx +51 -0
  18. data/gui/src/assets/react.svg +1 -0
  19. data/gui/src/components/DagVisualizer.tsx +424 -0
  20. data/gui/src/components/Dashboard.tsx +163 -0
  21. data/gui/src/components/ErrorBoundary.tsx +47 -0
  22. data/gui/src/components/ReactorDetail.tsx +135 -0
  23. data/gui/src/components/StepInspector.tsx +492 -0
  24. data/gui/src/components/__tests__/DagVisualizer.test.tsx +140 -0
  25. data/gui/src/components/__tests__/ReactorDetail.test.tsx +111 -0
  26. data/gui/src/components/__tests__/StepInspector.test.tsx +408 -0
  27. data/gui/src/globals.d.ts +7 -0
  28. data/gui/src/index.css +14 -0
  29. data/gui/src/lib/utils.ts +13 -0
  30. data/gui/src/main.tsx +14 -0
  31. data/gui/src/test/setup.ts +11 -0
  32. data/gui/tailwind.config.js +11 -0
  33. data/gui/tsconfig.app.json +28 -0
  34. data/gui/tsconfig.json +7 -0
  35. data/gui/tsconfig.node.json +26 -0
  36. data/gui/vite.config.ts +8 -0
  37. data/gui/vitest.config.ts +13 -0
  38. data/lib/ruby_reactor/async_router.rb +6 -2
  39. data/lib/ruby_reactor/context.rb +35 -9
  40. data/lib/ruby_reactor/dependency_graph.rb +2 -0
  41. data/lib/ruby_reactor/dsl/compose_builder.rb +8 -0
  42. data/lib/ruby_reactor/dsl/interrupt_builder.rb +48 -0
  43. data/lib/ruby_reactor/dsl/interrupt_step_config.rb +21 -0
  44. data/lib/ruby_reactor/dsl/map_builder.rb +8 -0
  45. data/lib/ruby_reactor/dsl/reactor.rb +12 -0
  46. data/lib/ruby_reactor/dsl/step_builder.rb +4 -0
  47. data/lib/ruby_reactor/executor/compensation_manager.rb +60 -27
  48. data/lib/ruby_reactor/executor/graph_manager.rb +2 -0
  49. data/lib/ruby_reactor/executor/result_handler.rb +117 -39
  50. data/lib/ruby_reactor/executor/retry_manager.rb +1 -0
  51. data/lib/ruby_reactor/executor/step_executor.rb +38 -4
  52. data/lib/ruby_reactor/executor.rb +86 -13
  53. data/lib/ruby_reactor/interrupt_result.rb +20 -0
  54. data/lib/ruby_reactor/map/collector.rb +0 -2
  55. data/lib/ruby_reactor/map/element_executor.rb +3 -0
  56. data/lib/ruby_reactor/map/execution.rb +28 -1
  57. data/lib/ruby_reactor/map/helpers.rb +44 -6
  58. data/lib/ruby_reactor/reactor.rb +187 -1
  59. data/lib/ruby_reactor/registry.rb +25 -0
  60. data/lib/ruby_reactor/sidekiq_workers/worker.rb +1 -1
  61. data/lib/ruby_reactor/step/compose_step.rb +22 -6
  62. data/lib/ruby_reactor/step/map_step.rb +30 -3
  63. data/lib/ruby_reactor/storage/adapter.rb +32 -0
  64. data/lib/ruby_reactor/storage/redis_adapter.rb +154 -11
  65. data/lib/ruby_reactor/utils/code_extractor.rb +31 -0
  66. data/lib/ruby_reactor/version.rb +1 -1
  67. data/lib/ruby_reactor/web/api.rb +206 -0
  68. data/lib/ruby_reactor/web/application.rb +53 -0
  69. data/lib/ruby_reactor/web/config.ru +5 -0
  70. data/lib/ruby_reactor/web/public/assets/index-VdeLgH9k.js +19 -0
  71. data/lib/ruby_reactor/web/public/assets/index-_z-6BvuM.css +1 -0
  72. data/lib/ruby_reactor/web/public/index.html +14 -0
  73. data/lib/ruby_reactor/web/public/vite.svg +1 -0
  74. data/lib/ruby_reactor.rb +94 -28
  75. data/llms-full.txt +66 -0
  76. data/llms.txt +7 -0
  77. metadata +63 -2
@@ -88,6 +88,25 @@ module RubyReactor
88
88
 
89
89
  link_contexts(child_context, context)
90
90
 
91
+ map_id = "#{context.context_id}:#{context.current_step}"
92
+ storage = RubyReactor.configuration.storage_adapter
93
+ storage.store_map_element_context_id(map_id, child_context.context_id, context.reactor_class.name)
94
+
95
+ # Set map metadata for failure handling
96
+ child_context.map_metadata = {
97
+ map_id: map_id,
98
+ parent_reactor_class_name: context.reactor_class.name,
99
+ index: nil # Inline map execution doesn't track index in metadata currently, but could
100
+ }
101
+
102
+ # Store reference in composed_contexts so the UI knows where to find elements
103
+ context.composed_contexts[context.current_step] = {
104
+ name: context.current_step,
105
+ type: :map_ref,
106
+ map_id: map_id,
107
+ element_reactor_class: arguments[:mapped_reactor_class].name
108
+ }
109
+
91
110
  executor = RubyReactor::Executor.new(arguments[:mapped_reactor_class], {}, child_context)
92
111
  executor.execute
93
112
  executor.result
@@ -134,7 +153,7 @@ module RubyReactor
134
153
  def run_async(arguments, context, step_name)
135
154
  map_id = "#{context.context_id}:#{step_name}"
136
155
  context.map_operations[step_name.to_s] = map_id
137
- prepare_async_execution(context, map_id, arguments[:source].count)
156
+ prepare_async_execution(context, map_id, arguments[:source].size)
138
157
 
139
158
  reactor_class_info = build_reactor_class_info(arguments[:mapped_reactor_class], context, step_name)
140
159
 
@@ -151,6 +170,14 @@ module RubyReactor
151
170
  reactor_class_info: reactor_class_info, step_name: step_name)
152
171
  end
153
172
 
173
+ # Store reference in composed_contexts so the UI knows where to find elements
174
+ context.composed_contexts[step_name.to_s] = {
175
+ name: step_name.to_s,
176
+ type: :map_ref,
177
+ map_id: map_id,
178
+ element_reactor_class: arguments[:mapped_reactor_class].name
179
+ }
180
+
154
181
  RubyReactor::AsyncResult.new(job_id: job_id, intermediate_results: context.intermediate_results)
155
182
  end
156
183
 
@@ -174,11 +201,11 @@ module RubyReactor
174
201
  # rubocop:enable Metrics/ParameterLists
175
202
  storage = RubyReactor.configuration.storage_adapter
176
203
  storage.initialize_map_operation(
177
- map_id, arguments[:source].count, context.reactor_class.name,
204
+ map_id, arguments[:source].size, context.reactor_class.name,
178
205
  strict_ordering: arguments[:strict_ordering], reactor_class_info: reactor_class_info
179
206
  )
180
207
 
181
- limit ||= arguments[:source].count
208
+ limit ||= arguments[:source].size
182
209
  first_job_id = nil
183
210
  arguments[:source].each_with_index do |element, index|
184
211
  break if index >= limit
@@ -46,6 +46,38 @@ module RubyReactor
46
46
  def expire(key, seconds)
47
47
  raise NotImplementedError
48
48
  end
49
+
50
+ def store_correlation_id(correlation_id, context_id, reactor_class_name)
51
+ raise NotImplementedError
52
+ end
53
+
54
+ def retrieve_context_id_by_correlation_id(correlation_id, reactor_class_name)
55
+ raise NotImplementedError
56
+ end
57
+
58
+ def delete_correlation_id(correlation_id, reactor_class_name)
59
+ raise NotImplementedError
60
+ end
61
+
62
+ def delete_context(context_id, reactor_class_name)
63
+ raise NotImplementedError
64
+ end
65
+
66
+ def scan_reactors(pattern: "*", count: 50)
67
+ raise NotImplementedError
68
+ end
69
+
70
+ def find_context_by_id(context_id)
71
+ raise NotImplementedError
72
+ end
73
+
74
+ def store_map_element_context_id(map_id, context_id, reactor_class_name)
75
+ raise NotImplementedError
76
+ end
77
+
78
+ def retrieve_map_element_context_ids(map_id, reactor_class_name)
79
+ raise NotImplementedError
80
+ end
49
81
  end
50
82
  end
51
83
  end
@@ -13,14 +13,13 @@ module RubyReactor
13
13
 
14
14
  def store_context(context_id, serialized_context, reactor_class_name)
15
15
  key = context_key(context_id, reactor_class_name)
16
- # Use JSON.SET for efficient storage and retrieval
17
- @redis.call("JSON.SET", key, ".", serialized_context)
18
- @redis.expire(key, 86_400) # 24h TTL
16
+ # Use standard SET for compatibility (ReJSON not strictly required for full docs)
17
+ @redis.set(key, serialized_context, ex: 86_400) # 24h TTL
19
18
  end
20
19
 
21
20
  def retrieve_context(context_id, reactor_class_name)
22
21
  key = context_key(context_id, reactor_class_name)
23
- json = @redis.call("JSON.GET", key)
22
+ json = @redis.get(key)
24
23
  return nil unless json
25
24
 
26
25
  JSON.parse(json)
@@ -56,8 +55,7 @@ module RubyReactor
56
55
 
57
56
  def set_map_counter(map_id, count, reactor_class_name)
58
57
  key = map_counter_key(map_id, reactor_class_name)
59
- @redis.set(key, count)
60
- @redis.expire(key, 86_400)
58
+ @redis.set(key, count, ex: 86_400)
61
59
  end
62
60
 
63
61
  def initialize_map_operation(map_id, count, parent_reactor_class_name, reactor_class_info:, strict_ordering: true)
@@ -72,13 +70,12 @@ module RubyReactor
72
70
  reactor_class_info: reactor_class_info,
73
71
  created_at: Time.now.to_i
74
72
  }
75
- @redis.call("JSON.SET", key, ".", metadata.to_json)
76
- @redis.expire(key, 86_400)
73
+ @redis.set(key, metadata.to_json, ex: 86_400)
77
74
  end
78
75
 
79
76
  def retrieve_map_metadata(map_id, reactor_class_name)
80
77
  key = "reactor:#{reactor_class_name}:map:#{map_id}:metadata"
81
- json = @redis.call("JSON.GET", key)
78
+ json = @redis.get(key)
82
79
  return nil unless json
83
80
 
84
81
  JSON.parse(json)
@@ -97,8 +94,7 @@ module RubyReactor
97
94
 
98
95
  def set_last_queued_index(map_id, index, reactor_class_name)
99
96
  key = map_last_queued_index_key(map_id, reactor_class_name)
100
- @redis.set(key, index)
101
- @redis.expire(key, 86_400)
97
+ @redis.set(key, index, ex: 86_400)
102
98
  end
103
99
 
104
100
  def increment_last_queued_index(map_id, reactor_class_name)
@@ -106,6 +102,41 @@ module RubyReactor
106
102
  @redis.incr(key)
107
103
  end
108
104
 
105
+ def store_correlation_id(correlation_id, context_id, reactor_class_name)
106
+ key = correlation_id_key(correlation_id, reactor_class_name)
107
+ # Store mapping correlation_id -> context_id
108
+ # Try to set if not exists
109
+ success = @redis.set(key, context_id, nx: true, ex: 86_400) # 24h TTL
110
+
111
+ return if success
112
+
113
+ # If it exists, check if it's the same context_id
114
+ existing_context_id = @redis.get(key)
115
+
116
+ if existing_context_id == context_id
117
+ # Refresh TTL
118
+ @redis.expire(key, 86_400)
119
+ return
120
+ end
121
+
122
+ raise Error::ValidationError, "Correlation ID '#{correlation_id}' already exists"
123
+ end
124
+
125
+ def retrieve_context_id_by_correlation_id(correlation_id, reactor_class_name)
126
+ key = correlation_id_key(correlation_id, reactor_class_name)
127
+ @redis.get(key)
128
+ end
129
+
130
+ def delete_correlation_id(correlation_id, reactor_class_name)
131
+ key = correlation_id_key(correlation_id, reactor_class_name)
132
+ @redis.del(key)
133
+ end
134
+
135
+ def delete_context(context_id, reactor_class_name)
136
+ key = context_key(context_id, reactor_class_name)
137
+ @redis.del(key)
138
+ end
139
+
109
140
  def subscribe(channel, &block)
110
141
  @redis.subscribe(channel, &block)
111
142
  end
@@ -118,8 +149,108 @@ module RubyReactor
118
149
  @redis.expire(key, seconds)
119
150
  end
120
151
 
152
+ # New methods for API
153
+ def scan_reactors(pattern: "reactor:*:context:*", count: 50)
154
+ # Use SCAN to find keys matching the pattern
155
+ results = []
156
+ batch_keys = []
157
+
158
+ # scan_each yields keys. We buffer them to use MGET efficiently.
159
+ # We request a batch size from Redis (count: 100) to reduce roundtrips.
160
+ @redis.scan_each(match: pattern, count: 100) do |key|
161
+ batch_keys << key
162
+
163
+ # specific batch size for MGET processing
164
+ if batch_keys.size >= 50
165
+ results.concat(fetch_and_filter_reactors(batch_keys))
166
+ batch_keys = []
167
+
168
+ # Stop if we have enough results
169
+ return results.take(count) if results.size >= count
170
+ end
171
+ end
172
+
173
+ # Process remaining keys
174
+ results.concat(fetch_and_filter_reactors(batch_keys)) if batch_keys.any?
175
+
176
+ results.take(count)
177
+ end
178
+
179
+ def find_context_by_id(context_id)
180
+ # We don't know the reactor class, so we search for the ID
181
+ pattern = "reactor:*:context:#{context_id}"
182
+ keys = []
183
+ @redis.scan_each(match: pattern, count: 1) do |key|
184
+ keys << key
185
+ break
186
+ end
187
+ return nil if keys.empty?
188
+
189
+ key = keys.first
190
+ json = @redis.get(key)
191
+ return nil unless json
192
+
193
+ JSON.parse(json)
194
+ end
195
+
196
+ def determine_status(data)
197
+ return data["status"] if data["status"] && %w[failed paused completed running].include?(data["status"])
198
+ return "cancelled" if data["cancelled"]
199
+ # Heuristic
200
+ return "failed" if data["retry_count"]&.positive? && !data["current_step"].nil?
201
+ return "completed" unless data["current_step"]
202
+
203
+ "running"
204
+ end
205
+
206
+ def store_map_element_context_id(map_id, context_id, reactor_class_name)
207
+ key = map_element_contexts_key(map_id, reactor_class_name)
208
+ @redis.rpush(key, context_id)
209
+ @redis.expire(key, 86_400)
210
+ end
211
+
212
+ def retrieve_map_element_context_ids(map_id, reactor_class_name)
213
+ key = map_element_contexts_key(map_id, reactor_class_name)
214
+ @redis.lrange(key, 0, -1)
215
+ end
216
+
217
+ def retrieve_map_element_context_id(map_id, reactor_class_name, index: -1)
218
+ key = map_element_contexts_key(map_id, reactor_class_name)
219
+ @redis.lindex(key, index)
220
+ end
221
+
222
+ def store_map_failed_context_id(map_id, context_id, reactor_class_name)
223
+ key = map_failed_context_key(map_id, reactor_class_name)
224
+ # Only store the first failure (nx: true)
225
+ @redis.set(key, context_id, nx: true, ex: 86_400)
226
+ end
227
+
228
+ def retrieve_map_failed_context_id(map_id, reactor_class_name)
229
+ key = map_failed_context_key(map_id, reactor_class_name)
230
+ @redis.get(key)
231
+ end
232
+
121
233
  private
122
234
 
235
+ def fetch_and_filter_reactors(keys)
236
+ return [] if keys.empty?
237
+
238
+ json_results = @redis.mget(*keys)
239
+
240
+ json_results.compact.map do |json|
241
+ data = JSON.parse(json)
242
+ next if data["parent_context_id"] # Skip nested reactors
243
+
244
+ {
245
+ id: data["context_id"],
246
+ class: data["reactor_class"],
247
+ status: determine_status(data),
248
+ created_at: data["started_at"],
249
+ failure: data["failure_reason"]
250
+ }
251
+ end.compact
252
+ end
253
+
123
254
  def context_key(context_id, reactor_class_name)
124
255
  "reactor:#{reactor_class_name}:context:#{context_id}"
125
256
  end
@@ -135,6 +266,18 @@ module RubyReactor
135
266
  def map_last_queued_index_key(map_id, reactor_class_name)
136
267
  "reactor:#{reactor_class_name}:map:#{map_id}:last_queued_index"
137
268
  end
269
+
270
+ def correlation_id_key(correlation_id, reactor_class_name)
271
+ "reactor:#{reactor_class_name}:correlation:#{correlation_id}"
272
+ end
273
+
274
+ def map_element_contexts_key(map_id, reactor_class_name)
275
+ "reactor:#{reactor_class_name}:map:#{map_id}:element_contexts"
276
+ end
277
+
278
+ def map_failed_context_key(map_id, reactor_class_name)
279
+ "reactor:#{reactor_class_name}:map:#{map_id}:failed_context_id"
280
+ end
138
281
  end
139
282
  end
140
283
  end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ module Utils
5
+ class CodeExtractor
6
+ def self.extract(file_path, line_number, radius: 2)
7
+ return nil unless file_path && line_number && File.exist?(file_path)
8
+
9
+ lines = File.readlines(file_path)
10
+ total_lines = lines.size
11
+ target_index = line_number - 1
12
+
13
+ return nil if target_index.negative? || target_index >= total_lines
14
+
15
+ start_index = [0, target_index - radius].max
16
+ end_index = [total_lines - 1, target_index + radius].min
17
+
18
+ (start_index..end_index).map do |i|
19
+ {
20
+ line_number: i + 1,
21
+ content: lines[i].chomp,
22
+ target: i == target_index
23
+ }
24
+ end
25
+ rescue StandardError
26
+ # Fail gracefully if file reading fails
27
+ nil
28
+ end
29
+ end
30
+ end
31
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyReactor
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "roda"
4
+
5
+ module RubyReactor
6
+ module Web
7
+ # rubocop:disable Metrics/BlockLength
8
+ class API < Roda
9
+ plugin :json
10
+ plugin :all_verbs
11
+
12
+ route do |r|
13
+ r.on "reactors" do
14
+ r.is do
15
+ # GET /api/reactors
16
+ r.get do
17
+ RubyReactor::Configuration.instance.storage_adapter.scan_reactors
18
+ rescue StandardError => e
19
+ response.status = 500
20
+ { error: e.message, backtrace: e.backtrace.first(5) }
21
+ end
22
+ end
23
+
24
+ r.on String do |reactor_id|
25
+ # GET /api/reactors/:id
26
+ r.get do
27
+ data = RubyReactor::Configuration.instance.storage_adapter.find_context_by_id(reactor_id)
28
+ return { error: "Reactor not found" } unless data
29
+
30
+ reactor_class = data["reactor_class"] ? Object.const_get(data["reactor_class"]) : nil
31
+ structure = {}
32
+
33
+ structure = self.class.build_structure(reactor_class) if reactor_class.respond_to?(:steps)
34
+
35
+ {
36
+ id: data["context_id"],
37
+ class: data["reactor_class"],
38
+ status: if %w[failed paused completed running].include?(data["status"])
39
+ data["status"]
40
+ elsif data["cancelled"]
41
+ "cancelled"
42
+ else
43
+ (data["current_step"] ? "running" : "completed")
44
+ end,
45
+ current_step: data["current_step"],
46
+ retry_count: data["retry_count"] || 0,
47
+ undo_stack: data["undo_stack"] || [],
48
+ step_attempts: data.dig("retry_context", "step_attempts") || {},
49
+ created_at: data["started_at"],
50
+ inputs: data["inputs"],
51
+ intermediate_results: data["intermediate_results"],
52
+ structure: structure,
53
+ steps: data["execution_trace"] || [],
54
+ composed_contexts: self.class.hydrate_composed_contexts(
55
+ data["composed_contexts"] || {},
56
+ data["reactor_class"]
57
+ ),
58
+ error: data["failure_reason"]
59
+ }
60
+ end
61
+
62
+ # POST /api/reactors/:id/retry
63
+ r.post "retry" do
64
+ { success: true, message: "Retry scheduled" }
65
+ end
66
+
67
+ # POST /api/reactors/:id/cancel
68
+ r.post "cancel" do
69
+ { success: true, message: "Cancelled" }
70
+ end
71
+
72
+ # POST /api/reactors/:id/continue
73
+ r.post "continue" do
74
+ data = RubyReactor::Configuration.instance.storage_adapter.find_context_by_id(reactor_id)
75
+ return { error: "Reactor not found" } unless data
76
+
77
+ reactor_class = Object.const_get(data["reactor_class"])
78
+
79
+ params = JSON.parse(r.body.read)
80
+ payload = params["payload"]
81
+ step_name = params["step_name"]
82
+
83
+ begin
84
+ result = reactor_class.continue(
85
+ id: reactor_id,
86
+ payload: payload,
87
+ step_name: step_name
88
+ )
89
+
90
+ if result.is_a?(RubyReactor::Failure)
91
+ response.status = 422
92
+ { error: result.error }
93
+ else
94
+ { success: true, message: "Reactor resumed" }
95
+ end
96
+ rescue StandardError => e
97
+ response.status = 422
98
+ { error: e.message }
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
104
+
105
+ def self.build_structure(reactor_class)
106
+ return {} unless reactor_class.respond_to?(:steps)
107
+
108
+ steps_config = reactor_class.steps
109
+ return {} unless steps_config.is_a?(Hash)
110
+
111
+ # Use DependencyGraph to calculate dependencies effectively
112
+ graph = RubyReactor::DependencyGraph.new
113
+ steps_config.each_value { |config| graph.add_step(config) }
114
+
115
+ steps_config.to_h do |name, config|
116
+ type = determine_step_type(config)
117
+
118
+ step_data = {
119
+ name: name,
120
+ type: type,
121
+ depends_on: graph.dependencies[name],
122
+ async: config.async?
123
+ }
124
+
125
+ if type == "compose"
126
+ inner_class = extract_inner_class(config, :composed_reactor_class)
127
+ step_data[:nested_structure] = build_structure(inner_class) if inner_class
128
+ elsif type == "map"
129
+ inner_class = extract_inner_class(config, :mapped_reactor_class)
130
+ step_data[:nested_structure] = build_structure(inner_class) if inner_class
131
+ end
132
+
133
+ [name, step_data]
134
+ end
135
+ end
136
+
137
+ def self.determine_step_type(config)
138
+ if config.respond_to?(:interrupt?) && config.interrupt?
139
+ "interrupt"
140
+ elsif config.arguments&.key?(:composed_reactor_class)
141
+ "compose"
142
+ elsif config.arguments&.key?(:mapped_reactor_class)
143
+ "map"
144
+ elsif config.async?
145
+ "async"
146
+ else
147
+ "step"
148
+ end
149
+ end
150
+
151
+ def self.extract_inner_class(config, param_name)
152
+ val = config.arguments.dig(param_name, :source)
153
+ val.is_a?(RubyReactor::Template::Value) ? val.value : nil
154
+ rescue StandardError
155
+ nil
156
+ end
157
+
158
+ def self.hydrate_composed_contexts(composed_contexts, reactor_class_name)
159
+ return {} unless composed_contexts.is_a?(Hash)
160
+
161
+ composed_contexts.transform_values do |value|
162
+ if ["map_ref", :map_ref].include?(value["type"])
163
+ hydrate_map_ref(value, reactor_class_name)
164
+ else
165
+ value
166
+ end
167
+ end
168
+ end
169
+
170
+ def self.hydrate_map_ref(ref_data, reactor_class_name)
171
+ storage = RubyReactor.configuration.storage_adapter
172
+ map_id = ref_data["map_id"]
173
+
174
+ # Use the specific element reactor class if available, otherwise fallback to parent
175
+ target_reactor_class = ref_data["element_reactor_class"] || reactor_class_name
176
+
177
+ # 1. Check for specific failure (O(1))
178
+ # Stored by ResultHandler when a map element fails
179
+ failed_context_id = storage.retrieve_map_failed_context_id(map_id, reactor_class_name)
180
+
181
+ target_context_id = if failed_context_id
182
+ failed_context_id
183
+ else
184
+ # 2. Fallback to representative sample (last element) (O(1))
185
+ # If no failure, the last element gives a good idea of progress/completion
186
+ target_id = storage.retrieve_map_element_context_id(map_id, reactor_class_name, index: -1)
187
+ target_id
188
+ end
189
+
190
+ return ref_data unless target_context_id
191
+
192
+ # Retrieve the actual context data for the target ID
193
+ representative_data = storage.retrieve_context(target_context_id, target_reactor_class)
194
+
195
+ return ref_data unless representative_data
196
+
197
+ {
198
+ "name" => ref_data["name"],
199
+ "type" => "map_element",
200
+ "context" => representative_data
201
+ }
202
+ end
203
+ end
204
+ # rubocop:enable Metrics/BlockLength
205
+ end
206
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "roda"
4
+ require "ruby_reactor"
5
+ require "json"
6
+ require_relative "api"
7
+
8
+ module RubyReactor
9
+ module Web
10
+ class Application < Roda
11
+ plugin :static, ["/assets"], root: File.expand_path("public", __dir__)
12
+ # rubocop:disable Naming/MethodParameterName
13
+ def serve_index(r)
14
+ # rubocop:enable Naming/MethodParameterName
15
+ content = File.read(File.expand_path("public/index.html", __dir__))
16
+ # Inject the base path for React Router
17
+ # request.script_name contains the mount path (e.g. "/ruby_reactor")
18
+ base_path = r.script_name.empty? ? "/" : r.script_name
19
+
20
+ # Ensure trailing slash for base href to support relative assets (base: './')
21
+ href = base_path.end_with?("/") ? base_path : "#{base_path}/"
22
+
23
+ # Inject config and base tag
24
+ content.sub!("<head>", "<head><base href='#{href}'><script>window.RUBY_REACTOR_BASE = '#{base_path}';</script>")
25
+ content
26
+ rescue Errno::ENOENT
27
+ "UI not built. Please run `rake build:ui`"
28
+ end
29
+ plugin :json
30
+ plugin :public, root: File.expand_path("public", __dir__)
31
+
32
+ route do |r|
33
+ # 1. Handle Root (Inject config) - MUST be before r.public
34
+ r.root do
35
+ serve_index(r)
36
+ end
37
+
38
+ # 2. Serve Static Assets (e.g. /assets/...) from public folder
39
+ r.public
40
+
41
+ # 3. API
42
+ r.on "api" do
43
+ r.run API
44
+ end
45
+
46
+ # 4. Fallback (Inject config for deep links)
47
+ r.get do
48
+ serve_index(r)
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "application"
4
+
5
+ run RubyReactor::Web::Application