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.
- checksums.yaml +4 -4
- data/.rubocop.yml +10 -2
- data/README.md +177 -3
- data/Rakefile +25 -0
- data/documentation/data_pipelines.md +90 -84
- data/documentation/images/failed_order_processing.png +0 -0
- data/documentation/images/payment_workflow.png +0 -0
- data/documentation/interrupts.md +161 -0
- data/gui/.gitignore +24 -0
- data/gui/README.md +73 -0
- data/gui/eslint.config.js +23 -0
- data/gui/index.html +13 -0
- data/gui/package-lock.json +5925 -0
- data/gui/package.json +46 -0
- data/gui/postcss.config.js +6 -0
- data/gui/public/vite.svg +1 -0
- data/gui/src/App.css +42 -0
- data/gui/src/App.tsx +51 -0
- data/gui/src/assets/react.svg +1 -0
- data/gui/src/components/DagVisualizer.tsx +424 -0
- data/gui/src/components/Dashboard.tsx +163 -0
- data/gui/src/components/ErrorBoundary.tsx +47 -0
- data/gui/src/components/ReactorDetail.tsx +135 -0
- data/gui/src/components/StepInspector.tsx +492 -0
- data/gui/src/components/__tests__/DagVisualizer.test.tsx +140 -0
- data/gui/src/components/__tests__/ReactorDetail.test.tsx +111 -0
- data/gui/src/components/__tests__/StepInspector.test.tsx +408 -0
- data/gui/src/globals.d.ts +7 -0
- data/gui/src/index.css +14 -0
- data/gui/src/lib/utils.ts +13 -0
- data/gui/src/main.tsx +14 -0
- data/gui/src/test/setup.ts +11 -0
- data/gui/tailwind.config.js +11 -0
- data/gui/tsconfig.app.json +28 -0
- data/gui/tsconfig.json +7 -0
- data/gui/tsconfig.node.json +26 -0
- data/gui/vite.config.ts +8 -0
- data/gui/vitest.config.ts +13 -0
- data/lib/ruby_reactor/async_router.rb +12 -8
- data/lib/ruby_reactor/context.rb +35 -9
- data/lib/ruby_reactor/context_serializer.rb +15 -0
- data/lib/ruby_reactor/dependency_graph.rb +2 -0
- data/lib/ruby_reactor/dsl/compose_builder.rb +8 -0
- data/lib/ruby_reactor/dsl/interrupt_builder.rb +48 -0
- data/lib/ruby_reactor/dsl/interrupt_step_config.rb +21 -0
- data/lib/ruby_reactor/dsl/map_builder.rb +14 -2
- data/lib/ruby_reactor/dsl/reactor.rb +12 -0
- data/lib/ruby_reactor/dsl/step_builder.rb +4 -0
- data/lib/ruby_reactor/executor/compensation_manager.rb +60 -27
- data/lib/ruby_reactor/executor/graph_manager.rb +2 -0
- data/lib/ruby_reactor/executor/result_handler.rb +118 -39
- data/lib/ruby_reactor/executor/retry_manager.rb +12 -1
- data/lib/ruby_reactor/executor/step_executor.rb +38 -4
- data/lib/ruby_reactor/executor.rb +86 -13
- data/lib/ruby_reactor/interrupt_result.rb +20 -0
- data/lib/ruby_reactor/map/collector.rb +71 -35
- data/lib/ruby_reactor/map/dispatcher.rb +162 -0
- data/lib/ruby_reactor/map/element_executor.rb +62 -56
- data/lib/ruby_reactor/map/execution.rb +44 -4
- data/lib/ruby_reactor/map/helpers.rb +44 -6
- data/lib/ruby_reactor/map/result_enumerator.rb +105 -0
- data/lib/ruby_reactor/reactor.rb +187 -1
- data/lib/ruby_reactor/registry.rb +25 -0
- data/lib/ruby_reactor/sidekiq_workers/worker.rb +1 -1
- data/lib/ruby_reactor/step/compose_step.rb +22 -6
- data/lib/ruby_reactor/step/map_step.rb +78 -19
- data/lib/ruby_reactor/storage/adapter.rb +32 -0
- data/lib/ruby_reactor/storage/redis_adapter.rb +213 -11
- data/lib/ruby_reactor/template/dynamic_source.rb +32 -0
- data/lib/ruby_reactor/utils/code_extractor.rb +31 -0
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor/web/api.rb +206 -0
- data/lib/ruby_reactor/web/application.rb +53 -0
- data/lib/ruby_reactor/web/config.ru +5 -0
- data/lib/ruby_reactor/web/public/assets/index-VdeLgH9k.js +19 -0
- data/lib/ruby_reactor/web/public/assets/index-_z-6BvuM.css +1 -0
- data/lib/ruby_reactor/web/public/index.html +14 -0
- data/lib/ruby_reactor/web/public/vite.svg +1 -0
- data/lib/ruby_reactor.rb +94 -28
- data/llms-full.txt +66 -0
- data/llms.txt +7 -0
- 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
|
|
17
|
-
@redis.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
data/lib/ruby_reactor/version.rb
CHANGED
|
@@ -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
|