ruby_reactor 0.1.0 → 0.3.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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +10 -2
  3. data/README.md +177 -3
  4. data/Rakefile +25 -0
  5. data/documentation/data_pipelines.md +90 -84
  6. data/documentation/images/failed_order_processing.png +0 -0
  7. data/documentation/images/payment_workflow.png +0 -0
  8. data/documentation/interrupts.md +161 -0
  9. data/gui/.gitignore +24 -0
  10. data/gui/README.md +73 -0
  11. data/gui/eslint.config.js +23 -0
  12. data/gui/index.html +13 -0
  13. data/gui/package-lock.json +5925 -0
  14. data/gui/package.json +46 -0
  15. data/gui/postcss.config.js +6 -0
  16. data/gui/public/vite.svg +1 -0
  17. data/gui/src/App.css +42 -0
  18. data/gui/src/App.tsx +51 -0
  19. data/gui/src/assets/react.svg +1 -0
  20. data/gui/src/components/DagVisualizer.tsx +424 -0
  21. data/gui/src/components/Dashboard.tsx +163 -0
  22. data/gui/src/components/ErrorBoundary.tsx +47 -0
  23. data/gui/src/components/ReactorDetail.tsx +135 -0
  24. data/gui/src/components/StepInspector.tsx +492 -0
  25. data/gui/src/components/__tests__/DagVisualizer.test.tsx +140 -0
  26. data/gui/src/components/__tests__/ReactorDetail.test.tsx +111 -0
  27. data/gui/src/components/__tests__/StepInspector.test.tsx +408 -0
  28. data/gui/src/globals.d.ts +7 -0
  29. data/gui/src/index.css +14 -0
  30. data/gui/src/lib/utils.ts +13 -0
  31. data/gui/src/main.tsx +14 -0
  32. data/gui/src/test/setup.ts +11 -0
  33. data/gui/tailwind.config.js +11 -0
  34. data/gui/tsconfig.app.json +28 -0
  35. data/gui/tsconfig.json +7 -0
  36. data/gui/tsconfig.node.json +26 -0
  37. data/gui/vite.config.ts +8 -0
  38. data/gui/vitest.config.ts +13 -0
  39. data/lib/ruby_reactor/async_router.rb +12 -8
  40. data/lib/ruby_reactor/context.rb +35 -9
  41. data/lib/ruby_reactor/context_serializer.rb +15 -0
  42. data/lib/ruby_reactor/dependency_graph.rb +2 -0
  43. data/lib/ruby_reactor/dsl/compose_builder.rb +8 -0
  44. data/lib/ruby_reactor/dsl/interrupt_builder.rb +48 -0
  45. data/lib/ruby_reactor/dsl/interrupt_step_config.rb +21 -0
  46. data/lib/ruby_reactor/dsl/map_builder.rb +14 -2
  47. data/lib/ruby_reactor/dsl/reactor.rb +12 -0
  48. data/lib/ruby_reactor/dsl/step_builder.rb +4 -0
  49. data/lib/ruby_reactor/executor/compensation_manager.rb +60 -27
  50. data/lib/ruby_reactor/executor/graph_manager.rb +2 -0
  51. data/lib/ruby_reactor/executor/result_handler.rb +118 -39
  52. data/lib/ruby_reactor/executor/retry_manager.rb +12 -1
  53. data/lib/ruby_reactor/executor/step_executor.rb +38 -4
  54. data/lib/ruby_reactor/executor.rb +86 -13
  55. data/lib/ruby_reactor/interrupt_result.rb +20 -0
  56. data/lib/ruby_reactor/map/collector.rb +71 -35
  57. data/lib/ruby_reactor/map/dispatcher.rb +162 -0
  58. data/lib/ruby_reactor/map/element_executor.rb +62 -56
  59. data/lib/ruby_reactor/map/execution.rb +44 -4
  60. data/lib/ruby_reactor/map/helpers.rb +44 -6
  61. data/lib/ruby_reactor/map/result_enumerator.rb +105 -0
  62. data/lib/ruby_reactor/reactor.rb +187 -1
  63. data/lib/ruby_reactor/registry.rb +25 -0
  64. data/lib/ruby_reactor/sidekiq_workers/worker.rb +1 -1
  65. data/lib/ruby_reactor/step/compose_step.rb +22 -6
  66. data/lib/ruby_reactor/step/map_step.rb +78 -19
  67. data/lib/ruby_reactor/storage/adapter.rb +32 -0
  68. data/lib/ruby_reactor/storage/redis_adapter.rb +213 -11
  69. data/lib/ruby_reactor/template/dynamic_source.rb +32 -0
  70. data/lib/ruby_reactor/utils/code_extractor.rb +31 -0
  71. data/lib/ruby_reactor/version.rb +1 -1
  72. data/lib/ruby_reactor/web/api.rb +206 -0
  73. data/lib/ruby_reactor/web/application.rb +53 -0
  74. data/lib/ruby_reactor/web/config.ru +5 -0
  75. data/lib/ruby_reactor/web/public/assets/index-VdeLgH9k.js +19 -0
  76. data/lib/ruby_reactor/web/public/assets/index-_z-6BvuM.css +1 -0
  77. data/lib/ruby_reactor/web/public/index.html +14 -0
  78. data/lib/ruby_reactor/web/public/vite.svg +1 -0
  79. data/lib/ruby_reactor.rb +94 -28
  80. data/llms-full.txt +66 -0
  81. data/llms.txt +7 -0
  82. metadata +66 -2
@@ -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,163 @@ 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
+
233
+ def set_map_offset(map_id, offset, reactor_class_name)
234
+ key = map_offset_key(map_id, reactor_class_name)
235
+ @redis.set(key, offset, ex: 86_400)
236
+ end
237
+
238
+ def set_map_offset_if_not_exists(map_id, offset, reactor_class_name)
239
+ key = map_offset_key(map_id, reactor_class_name)
240
+ @redis.set(key, offset, nx: true, ex: 86_400)
241
+ end
242
+
243
+ def retrieve_map_offset(map_id, reactor_class_name)
244
+ key = map_offset_key(map_id, reactor_class_name)
245
+ @redis.get(key)
246
+ end
247
+
248
+ def increment_map_offset(map_id, increment, reactor_class_name)
249
+ key = map_offset_key(map_id, reactor_class_name)
250
+ @redis.incrby(key, increment)
251
+ end
252
+
253
+ def retrieve_map_results_batch(map_id, reactor_class_name, offset:, limit:, strict_ordering: true)
254
+ key = map_results_key(map_id, reactor_class_name)
255
+
256
+ if strict_ordering
257
+ # For Hash based results (indexed), we can use HMGET if we know the keys.
258
+ # Since we use 0-based index keys, we can generate the keys for the batch.
259
+ fields = (offset...(offset + limit)).map(&:to_s)
260
+ results = @redis.hmget(key, *fields)
261
+
262
+ # HMGET returns nil for missing fields, compact them?
263
+ # Or should we respect the holes?
264
+ # Map results are usually dense.
265
+ results.compact.map { |r| JSON.parse(r) }
266
+ else
267
+ # For List based results
268
+ # LRANGE uses inclusive ending index
269
+ end_index = offset + limit - 1
270
+ results = @redis.lrange(key, offset, end_index)
271
+ results.map { |r| JSON.parse(r) }
272
+ end
273
+ end
274
+
275
+ def count_map_results(map_id, reactor_class_name)
276
+ key = map_results_key(map_id, reactor_class_name)
277
+ type = @redis.type(key)
278
+
279
+ if type == "hash"
280
+ @redis.hlen(key)
281
+ elsif type == "list"
282
+ @redis.llen(key)
283
+ else
284
+ 0
285
+ end
286
+ end
287
+
121
288
  private
122
289
 
290
+ def fetch_and_filter_reactors(keys)
291
+ return [] if keys.empty?
292
+
293
+ json_results = @redis.mget(*keys)
294
+
295
+ json_results.compact.map do |json|
296
+ data = JSON.parse(json)
297
+ next if data["parent_context_id"] # Skip nested reactors
298
+
299
+ {
300
+ id: data["context_id"],
301
+ class: data["reactor_class"],
302
+ status: determine_status(data),
303
+ created_at: data["started_at"],
304
+ failure: data["failure_reason"]
305
+ }
306
+ end.compact
307
+ end
308
+
123
309
  def context_key(context_id, reactor_class_name)
124
310
  "reactor:#{reactor_class_name}:context:#{context_id}"
125
311
  end
@@ -135,6 +321,22 @@ module RubyReactor
135
321
  def map_last_queued_index_key(map_id, reactor_class_name)
136
322
  "reactor:#{reactor_class_name}:map:#{map_id}:last_queued_index"
137
323
  end
324
+
325
+ def map_offset_key(map_id, reactor_class_name)
326
+ "reactor:#{reactor_class_name}:map:#{map_id}:offset"
327
+ end
328
+
329
+ def correlation_id_key(correlation_id, reactor_class_name)
330
+ "reactor:#{reactor_class_name}:correlation:#{correlation_id}"
331
+ end
332
+
333
+ def map_element_contexts_key(map_id, reactor_class_name)
334
+ "reactor:#{reactor_class_name}:map:#{map_id}:element_contexts"
335
+ end
336
+
337
+ def map_failed_context_key(map_id, reactor_class_name)
338
+ "reactor:#{reactor_class_name}:map:#{map_id}:failed_context_id"
339
+ end
138
340
  end
139
341
  end
140
342
  end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ module Template
5
+ class DynamicSource < Base
6
+ attr_reader :block, :argument_mappings
7
+
8
+ def initialize(argument_mappings, &block)
9
+ super()
10
+ @block = block
11
+ @argument_mappings = argument_mappings
12
+ end
13
+
14
+ def resolve(context)
15
+ args = {}
16
+ @argument_mappings.each do |name, source|
17
+ # Handle serialized template objects if necessary, similar to MapStep logic
18
+ # But here we assume source is a Template object that responds to resolve
19
+ next if source.is_a?(RubyReactor::Template::Element)
20
+
21
+ args[name] = if source.respond_to?(:resolve)
22
+ source.resolve(context)
23
+ else
24
+ source
25
+ end
26
+ end
27
+
28
+ @block.call(args, context)
29
+ end
30
+ end
31
+ end
32
+ 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.3.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