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.
- checksums.yaml +4 -4
- data/.rubocop.yml +10 -2
- data/README.md +72 -3
- data/Rakefile +27 -2
- 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 +6 -2
- data/lib/ruby_reactor/context.rb +35 -9
- 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 +8 -0
- 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 +117 -39
- data/lib/ruby_reactor/executor/retry_manager.rb +1 -0
- 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 +0 -2
- data/lib/ruby_reactor/map/element_executor.rb +3 -0
- data/lib/ruby_reactor/map/execution.rb +28 -1
- data/lib/ruby_reactor/map/helpers.rb +44 -6
- 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 +30 -3
- data/lib/ruby_reactor/storage/adapter.rb +32 -0
- data/lib/ruby_reactor/storage/redis_adapter.rb +154 -11
- 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 +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].
|
|
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].
|
|
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].
|
|
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
|
|
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,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
|
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
|