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
@@ -36,6 +36,9 @@ module RubyReactor
36
36
  # If a step returns Failure, we need to stop execution and return it
37
37
  return result if result.is_a?(RubyReactor::Failure)
38
38
 
39
+ # If a step returns InterruptResult, we need to stop execution and return it
40
+ return result if result.is_a?(RubyReactor::InterruptResult)
41
+
39
42
  # If result is nil, it means async was executed inline (test mode), continue
40
43
  next if result.nil?
41
44
  end
@@ -49,7 +52,13 @@ module RubyReactor
49
52
  # If we're already in inline async execution mode (inside Worker),
50
53
  # treat async steps as sync to avoid infinite recursion
51
54
 
52
- if step_config.async? && !@context.inline_async_execution
55
+ if @dependency_graph.completed.include?(step_config.name)
56
+ return RubyReactor.Success(@context.get_result(step_config.name))
57
+ end
58
+
59
+ if step_config.interrupt?
60
+ handle_interrupt_step(step_config)
61
+ elsif step_config.async? && !@context.inline_async_execution
53
62
  handle_async_step(step_config)
54
63
  else
55
64
  execute_step_with_retry(step_config)
@@ -77,8 +86,10 @@ module RubyReactor
77
86
  # Update retry context
78
87
  @context.retry_context = other_executor.context.retry_context
79
88
 
80
- # Clear current_step since we've completed it
81
- @context.current_step = nil
89
+ # Update current_step:
90
+ # If the other executor has a current_step, it means it paused/interrupted there. We should adopt it.
91
+ # If it's nil, it means it completed successfully, so we clear our current_step (which was the async step).
92
+ @context.current_step = other_executor.context.current_step
82
93
 
83
94
  # Update our dependency graph to reflect completed steps
84
95
  other_executor.context.intermediate_results.each_key do |step_name|
@@ -91,7 +102,8 @@ module RubyReactor
91
102
  # Merge any undo stack items
92
103
  other_executor.undo_stack.each do |item|
93
104
  # Avoid duplicates by checking if this step is already in the undo stack
94
- unless @compensation_manager.undo_stack.any? { |existing| existing[:step].name == item[:step].name }
105
+ # Use string comparison for step names to avoid symbol/string mismatch issues
106
+ unless @compensation_manager.undo_stack.any? { |existing| existing[:step].name.to_s == item[:step].name.to_s }
95
107
  @compensation_manager.add_to_undo_stack(item)
96
108
  end
97
109
  end
@@ -248,6 +260,28 @@ module RubyReactor
248
260
  result.result
249
261
  end
250
262
 
263
+ def handle_interrupt_step(step_config)
264
+ # Check if we have a result for this step (resuming)
265
+ if @context.intermediate_results.key?(step_config.name)
266
+ # We are resuming
267
+ result = @context.get_result(step_config.name)
268
+ return RubyReactor.Success(result)
269
+ end
270
+
271
+ # We are pausing
272
+ correlation_id = nil
273
+ correlation_id = step_config.correlation_id_block.call(@context) if step_config.correlation_id_block
274
+
275
+ # Store current step as the one we are paused at
276
+ @context.current_step = step_config.name
277
+
278
+ RubyReactor::InterruptResult.new(
279
+ execution_id: @context.context_id,
280
+ correlation_id: correlation_id,
281
+ intermediate_results: @context.intermediate_results
282
+ )
283
+ end
284
+
251
285
  def configuration
252
286
  RubyReactor::Configuration.instance
253
287
  end
@@ -40,24 +40,49 @@ module RubyReactor
40
40
  input_validator = InputValidator.new(@reactor_class, @context)
41
41
  input_validator.validate!
42
42
 
43
+ save_context
44
+
43
45
  graph_manager = GraphManager.new(@reactor_class, @dependency_graph, @context)
44
46
  graph_manager.build_and_validate!
47
+ graph_manager.mark_completed_steps_from_context
45
48
 
46
49
  @result = @step_executor.execute_all_steps
50
+ update_context_status(@result)
51
+ handle_interrupt(@result) if @result.is_a?(RubyReactor::InterruptResult)
52
+ @result
47
53
  rescue StandardError => e
48
54
  @result = @result_handler.handle_execution_error(e)
55
+ update_context_status(@result)
56
+ @result
57
+ ensure
58
+ save_context
49
59
  end
50
60
 
51
61
  def resume_execution
52
62
  prepare_for_resume
63
+ save_context
53
64
 
54
- if @context.current_step
55
- execute_current_step_and_continue
56
- else
57
- execute_remaining_steps
58
- end
65
+ @result = if @context.current_step
66
+ execute_current_step_and_continue
67
+ else
68
+ execute_remaining_steps
69
+ end
70
+
71
+ update_context_status(@result)
72
+
73
+ handle_interrupt(@result) if @result.is_a?(RubyReactor::InterruptResult)
74
+
75
+ @result
59
76
  rescue StandardError => e
60
77
  handle_resume_error(e)
78
+ update_context_status(@result)
79
+ @result
80
+ ensure
81
+ save_context
82
+ end
83
+
84
+ def undo_all
85
+ @compensation_manager.rollback_completed_steps
61
86
  end
62
87
 
63
88
  def undo_stack
@@ -72,8 +97,45 @@ module RubyReactor
72
97
  @context.execution_trace
73
98
  end
74
99
 
100
+ def save_context
101
+ storage = RubyReactor::Configuration.instance.storage_adapter
102
+ reactor_class_name = @reactor_class.name || "AnonymousReactor-#{@reactor_class.object_id}"
103
+
104
+ # Serialize context
105
+ serialized_context = ContextSerializer.serialize(@context)
106
+ storage.store_context(@context.context_id, serialized_context, reactor_class_name)
107
+ end
108
+
75
109
  private
76
110
 
111
+ def update_context_status(result)
112
+ return unless result
113
+
114
+ case result
115
+ when RubyReactor::AsyncResult
116
+ @context.status = :running
117
+ when RubyReactor::Success
118
+ @context.status = :completed
119
+ when RubyReactor::Failure
120
+ @context.status = :failed
121
+ @context.failure_reason = {
122
+ message: result.error.is_a?(Exception) ? result.error.message : result.error.to_s,
123
+ step_name: result.step_name,
124
+ inputs: result.inputs,
125
+ backtrace: result.backtrace,
126
+ reactor_name: result.reactor_name,
127
+ step_arguments: result.step_arguments,
128
+ exception_class: result.exception_class,
129
+ file_path: result.file_path,
130
+ line_number: result.line_number,
131
+ code_snippet: result.code_snippet,
132
+ validation_errors: result.validation_errors
133
+ }
134
+ when RubyReactor::InterruptResult
135
+ @context.status = :paused
136
+ end
137
+ end
138
+
77
139
  def prepare_for_resume
78
140
  # Build dependency graph and mark completed steps
79
141
  graph_manager = GraphManager.new(@reactor_class, @dependency_graph, @context)
@@ -85,6 +147,9 @@ module RubyReactor
85
147
  step_config = @reactor_class.steps[@context.current_step]
86
148
  return RubyReactor::Failure("Step '#{@context.current_step}' not found in reactor") unless step_config
87
149
 
150
+ # If current step is already in intermediate_results, skip directly to execute_all_steps
151
+ return @step_executor.execute_all_steps if @context.intermediate_results.key?(@context.current_step.to_sym)
152
+
88
153
  # Use execute_step (not execute_step_with_retry) so that async steps can be handled properly in inline mode
89
154
  result = @step_executor.execute_step(step_config)
90
155
 
@@ -93,7 +158,7 @@ module RubyReactor
93
158
  @result = @step_executor.execute_all_steps
94
159
  else
95
160
  case result
96
- when RetryQueuedResult, RubyReactor::Failure, RubyReactor::AsyncResult
161
+ when RetryQueuedResult, RubyReactor::Failure, RubyReactor::AsyncResult, RubyReactor::InterruptResult
97
162
  # Step was requeued, failed, or handed off to async - return the result
98
163
  @result = result
99
164
  when RubyReactor::Success
@@ -110,14 +175,22 @@ module RubyReactor
110
175
  end
111
176
 
112
177
  def handle_resume_error(error)
113
- # Only handle errors that haven't already triggered compensation
114
- # StepFailureError means compensation already happened, just convert to Failure
115
- @result = if error.is_a?(Error::StepFailureError)
116
- RubyReactor.Failure(error.message)
117
- else
118
- @result_handler.handle_execution_error(error)
119
- end
178
+ @result = @result_handler.handle_execution_error(error)
120
179
  @result
121
180
  end
181
+
182
+ def handle_interrupt(interrupt_result)
183
+ save_context
184
+
185
+ # Store correlation ID mapping if present
186
+ return unless interrupt_result.correlation_id
187
+
188
+ storage = RubyReactor::Configuration.instance.storage_adapter
189
+ storage.store_correlation_id(
190
+ interrupt_result.correlation_id,
191
+ @context.context_id,
192
+ @reactor_class.name
193
+ )
194
+ end
122
195
  end
123
196
  end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ class InterruptResult
5
+ attr_reader :execution_id, :correlation_id, :status, :timeout_at, :intermediate_results, :error
6
+
7
+ def initialize(execution_id:, correlation_id: nil, timeout_at: nil, intermediate_results: {}, error: nil)
8
+ @execution_id = execution_id
9
+ @correlation_id = correlation_id
10
+ @status = :paused
11
+ @timeout_at = timeout_at
12
+ @intermediate_results = intermediate_results
13
+ @error = error
14
+ end
15
+
16
+ def paused?
17
+ true
18
+ end
19
+ end
20
+ end
@@ -5,7 +5,6 @@ module RubyReactor
5
5
  class Collector
6
6
  extend Helpers
7
7
 
8
- # rubocop:disable Metrics/MethodLength
9
8
  def self.perform(arguments)
10
9
  arguments = arguments.transform_keys(&:to_sym)
11
10
  parent_context_id = arguments[:parent_context_id]
@@ -59,7 +58,6 @@ module RubyReactor
59
58
  # Resume execution
60
59
  resume_parent_execution(parent_context, step_name, final_result, storage)
61
60
  end
62
- # rubocop:enable Metrics/MethodLength
63
61
  end
64
62
  end
65
63
  end
@@ -34,9 +34,12 @@ module RubyReactor
34
34
 
35
35
  # Create context
36
36
  context = Context.new(inputs, reactor_class)
37
+ context.parent_context_id = parent_context_id
37
38
  context.map_metadata = arguments
38
39
  end
40
+
39
41
  storage = RubyReactor.configuration.storage_adapter
42
+ storage.store_map_element_context_id(map_id, context.context_id, parent_reactor_class_name)
40
43
 
41
44
  # Execute
42
45
  executor = Executor.new(reactor_class, {}, context)
@@ -32,7 +32,27 @@ module RubyReactor
32
32
  def self.execute_all_elements(source:, mappings:, reactor_class:, parent_context:, storage_options:)
33
33
  source.map.with_index do |element, index|
34
34
  element_inputs = build_element_inputs(mappings, parent_context, element)
35
- result = reactor_class.run(element_inputs)
35
+
36
+ # Manually create and link context to ensure parent_context_id is set
37
+ child_context = RubyReactor::Context.new(element_inputs, reactor_class)
38
+ link_contexts(child_context, parent_context)
39
+
40
+ # Ensure we store the element context linkage
41
+ storage_options[:storage].store_map_element_context_id(
42
+ storage_options[:map_id], child_context.context_id, storage_options[:parent_reactor_class_name]
43
+ )
44
+
45
+ # Set map metadata for failure handling
46
+ metadata = {
47
+ map_id: storage_options[:map_id],
48
+ parent_reactor_class_name: storage_options[:parent_reactor_class_name],
49
+ index: index
50
+ }
51
+ child_context.map_metadata = metadata
52
+
53
+ executor = RubyReactor::Executor.new(reactor_class, {}, child_context)
54
+ executor.execute
55
+ result = executor.result
36
56
 
37
57
  store_result(result, index, storage_options)
38
58
 
@@ -40,6 +60,13 @@ module RubyReactor
40
60
  end
41
61
  end
42
62
 
63
+ def self.link_contexts(child_context, parent_context)
64
+ child_context.parent_context = parent_context
65
+ child_context.root_context = parent_context.root_context || parent_context
66
+ child_context.test_mode = parent_context.test_mode
67
+ child_context.inline_async_execution = parent_context.inline_async_execution
68
+ end
69
+
43
70
  def self.store_result(result, index, options)
44
71
  value = result.success? ? result.value : { _error: result.error }
45
72
  options[:storage].store_map_result(
@@ -7,7 +7,11 @@ module RubyReactor
7
7
  # Resolves the reactor class from reactor_class_info
8
8
  def resolve_reactor_class(info)
9
9
  if info["type"] == "class"
10
- Object.const_get(info["name"])
10
+ begin
11
+ Object.const_get(info["name"])
12
+ rescue NameError
13
+ RubyReactor::Registry.find(info["name"])
14
+ end
11
15
  elsif info["type"] == "inline"
12
16
  parent_class = Object.const_get(info["parent"])
13
17
  step_config = parent_class.steps[info["step"].to_sym]
@@ -49,12 +53,46 @@ module RubyReactor
49
53
 
50
54
  # Resumes parent reactor execution after map completion
51
55
  def resume_parent_execution(parent_context, step_name, final_result, storage)
52
- value = final_result.success? ? final_result.value : final_result
53
- parent_context.set_result(step_name.to_sym, value)
54
- parent_context.current_step = nil
55
-
56
56
  executor = RubyReactor::Executor.new(parent_context.reactor_class, {}, parent_context)
57
- executor.resume_execution
57
+
58
+ if final_result.failure?
59
+ step_name_sym = step_name.to_sym
60
+ parent_context.current_step = step_name_sym
61
+
62
+ error = RubyReactor::Error::StepFailureError.new(
63
+ final_result.error,
64
+ step: step_name_sym,
65
+ context: parent_context,
66
+ original_error: final_result.error.is_a?(Exception) ? final_result.error : nil
67
+ )
68
+
69
+ # Pass backtrace if available
70
+ if final_result.respond_to?(:backtrace) && final_result.backtrace
71
+ error.set_backtrace(final_result.backtrace)
72
+ elsif final_result.error.respond_to?(:backtrace)
73
+ error.set_backtrace(final_result.error.backtrace)
74
+ end
75
+
76
+ failure_response = executor.result_handler.handle_execution_error(error)
77
+ # Manually update context status since we're not running executor loop
78
+ executor.send(:update_context_status, failure_response)
79
+ else
80
+ parent_context.set_result(step_name.to_sym, final_result.value)
81
+
82
+ # Manually update execution trace to reflect completion
83
+ # This is necessary because resume_execution continues from the NEXT step
84
+ # and the async step (which returned AsyncResult) needs to be marked as done with actual value
85
+ parent_context.execution_trace << {
86
+ type: :result,
87
+ step: step_name_sym,
88
+ timestamp: Time.now,
89
+ value: final_result.value,
90
+ status: :success
91
+ }
92
+
93
+ parent_context.current_step = nil
94
+ executor.resume_execution
95
+ end
58
96
 
59
97
  storage.store_context(
60
98
  parent_context.context_id,
@@ -6,6 +6,61 @@ module RubyReactor
6
6
 
7
7
  attr_reader :context, :result, :undo_trace, :execution_trace
8
8
 
9
+ def self.find(id)
10
+ reactor_class_name = name
11
+ serialized_context = configuration.storage_adapter.retrieve_context(id, reactor_class_name)
12
+ raise Error::ValidationError, "Context '#{id}' not found" unless serialized_context
13
+
14
+ context = Context.deserialize_from_retry(serialized_context)
15
+ new(context)
16
+ end
17
+
18
+ def self.find_by_correlation_id(correlation_id)
19
+ reactor_class_name = name
20
+ context_id = configuration.storage_adapter.retrieve_context_id_by_correlation_id(
21
+ correlation_id,
22
+ reactor_class_name
23
+ )
24
+ raise Error::ValidationError, "Correlation ID '#{correlation_id}' not found" unless context_id
25
+
26
+ find(context_id)
27
+ end
28
+
29
+ def self.continue(id:, payload:, step_name:, idempotency_key: nil)
30
+ reactor = find(id)
31
+ result = reactor.continue(payload: payload, step_name: step_name, idempotency_key: idempotency_key)
32
+
33
+ if result.is_a?(RubyReactor::Failure) && result.respond_to?(:invalid_payload?) && result.invalid_payload?
34
+ # Raise exception to match expected behavior (strict mode for class method)
35
+ # We do NOT cancel the reactor, allowing the user to retry with valid payload
36
+ raise Error::InputValidationError, result.error
37
+ end
38
+
39
+ result
40
+ end
41
+
42
+ def self.continue_by_correlation_id(correlation_id:, payload:, step_name:, idempotency_key: nil)
43
+ reactor = find_by_correlation_id(correlation_id)
44
+ # We delegate to the class-level continue method to ensure auto-compensation logic applies
45
+ # by using the context ID found by find_by_correlation_id
46
+ continue(id: reactor.context.context_id, payload: payload, step_name: step_name, idempotency_key: idempotency_key)
47
+ end
48
+
49
+ def self.cancel(id:, reason:)
50
+ reactor = find(id)
51
+ reactor.cancel(reason)
52
+ end
53
+
54
+ def self.undo(id)
55
+ reactor = find(id)
56
+ reactor.undo
57
+ cancel(id: id, reason: "Undo triggered")
58
+ end
59
+
60
+ def self.configuration
61
+ RubyReactor::Configuration.instance
62
+ end
63
+
9
64
  def initialize(context = {})
10
65
  @context = context
11
66
  @result = :unexecuted
@@ -21,11 +76,13 @@ module RubyReactor
21
76
  configuration.async_router.perform_async(serialized_context)
22
77
  else
23
78
  # For sync reactors (potentially with async steps), execute normally
24
- executor = Executor.new(self.class, inputs)
79
+ context = @context.is_a?(Context) ? @context : nil
80
+ executor = Executor.new(self.class, inputs, context)
25
81
  @result = executor.execute
26
82
 
27
83
  @context = executor.context
28
84
 
85
+ # Merge traces
29
86
  @undo_trace = executor.undo_trace
30
87
  @execution_trace = executor.execution_trace
31
88
 
@@ -36,6 +93,55 @@ module RubyReactor
36
93
  end
37
94
  end
38
95
 
96
+ def continue(payload:, step_name:, idempotency_key: nil)
97
+ _ = idempotency_key
98
+
99
+ unless @context.current_step
100
+ raise Error::ValidationError, "Cannot resume: context does not have a current step (was it interrupted?)"
101
+ end
102
+
103
+ if @context.cancelled
104
+ raise Error::ValidationError,
105
+ "Cannot resume: reactor has been cancelled (Reason: #{@context.cancellation_reason})"
106
+ end
107
+
108
+ validate_continue_step!(step_name)
109
+
110
+ if (failure = validate_continue_payload(payload, step_name))
111
+ return failure
112
+ end
113
+
114
+ target_step = step_name
115
+ @context.set_result(target_step, payload)
116
+
117
+ # Resume execution
118
+ executor = Executor.new(self.class, {}, @context)
119
+ @result = executor.resume_execution
120
+
121
+ @context = executor.context
122
+ @undo_trace = executor.undo_trace
123
+ @execution_trace = executor.execution_trace
124
+
125
+ @result
126
+ rescue Error::InputValidationError => e
127
+ # This might catch other validations, but here we specifically want payload validation.
128
+ # The block above handles payload validation explicitly.
129
+ RubyReactor::Failure(e.message, invalid_payload: true)
130
+ end
131
+
132
+ def undo
133
+ executor = Executor.new(self.class, {}, @context)
134
+ executor.undo_all
135
+ executor.save_context
136
+ end
137
+
138
+ def cancel(reason)
139
+ @context.cancelled = true
140
+ @context.cancellation_reason = reason
141
+ @context.status = "cancelled"
142
+ save_context
143
+ end
144
+
39
145
  def validate!
40
146
  # Validate reactor configuration
41
147
  validate_steps!
@@ -71,5 +177,85 @@ module RubyReactor
71
177
 
72
178
  raise Error::DependencyError, "Dependency graph contains cycles"
73
179
  end
180
+
181
+ def validate_continue_step!(step_name)
182
+ return if step_name.to_s == @context.current_step.to_s
183
+
184
+ # Build graph to check if step is ready
185
+ graph_manager = Executor::GraphManager.new(self.class, DependencyGraph.new, @context)
186
+ graph_manager.build_and_validate!
187
+ graph_manager.mark_completed_steps_from_context
188
+ ready_steps = graph_manager.dependency_graph.ready_steps.map(&:name).map(&:to_s)
189
+
190
+ return if ready_steps.include?(step_name.to_s)
191
+
192
+ raise Error::ValidationError,
193
+ "Cannot resume: expected step '#{@context.current_step}' " \
194
+ "or ready steps #{ready_steps} but got '#{step_name}'"
195
+ end
196
+
197
+ def validate_continue_payload(payload, step_name)
198
+ step_config = self.class.steps[step_name.to_sym]
199
+ return unless step_config&.validation_schema
200
+
201
+ validation = step_config.validation_schema.call(payload)
202
+
203
+ return unless validation.failure?
204
+
205
+ # Track attempts
206
+ step_key = step_name.to_sym
207
+ @context.private_data[:interrupt_attempts] ||= {}
208
+ @context.private_data[:interrupt_attempts][step_key] ||= 0
209
+ @context.private_data[:interrupt_attempts][step_key] += 1
210
+
211
+ save_context # Persist the attempt count
212
+
213
+ current_attempts = @context.private_data[:interrupt_attempts][step_key]
214
+ max_attempts = step_config.max_attempts
215
+
216
+ if max_attempts != :infinity && current_attempts >= max_attempts
217
+ # Max attempts reached - Fail and Compensate
218
+ undo
219
+
220
+ # Instead of cancelling, we mark as failed so it shows up as failed in UI
221
+ @context.status = "failed"
222
+ @context.failure_reason = {
223
+ message: "Validation failed after #{max_attempts} attempts",
224
+ step_name: step_name,
225
+ errors: validation.errors.to_h,
226
+ payload: payload,
227
+ step_arguments: payload,
228
+ attempts: current_attempts,
229
+ validation_errors: validation.errors.to_h
230
+ }
231
+
232
+ save_context
233
+
234
+ return RubyReactor::Failure(
235
+ "Validation failed after #{max_attempts} attempts",
236
+ step_name: step_name,
237
+ step_arguments: payload,
238
+ validation_errors: validation.errors.to_h
239
+ )
240
+ end
241
+
242
+ failure = RubyReactor::Failure(validation.errors.to_h)
243
+ # We need a way to mark this failure as a validation failure
244
+ # For now, we rely on the error object inside Failure or just return Failure
245
+ # The PRD requires `result.invalid_payload?` to be true.
246
+ # Since we don't have that method on Failure yet, we might need to enhance Failure
247
+ # OR wrap it. For now, let's assume Failure wraps the error and we can check it.
248
+ # We'll use a specific error type to identify it.
249
+ failure.instance_variable_set(:@type, :input_validation)
250
+ def failure.invalid_payload? = true
251
+ failure
252
+ end
253
+
254
+ def save_context
255
+ storage = configuration.storage_adapter
256
+ reactor_class_name = self.class.name || "AnonymousReactor-#{self.class.object_id}"
257
+ serialized_context = ContextSerializer.serialize(@context)
258
+ storage.store_context(@context.context_id, serialized_context, reactor_class_name)
259
+ end
74
260
  end
75
261
  end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent"
4
+
5
+ module RubyReactor
6
+ # Registry for dynamically created reactor classes (e.g. from inline map/compose)
7
+ # This avoids polluting the global constant namespace with runtime-generated constants.
8
+ class Registry
9
+ @reactors = Concurrent::Map.new
10
+
11
+ class << self
12
+ def register(name, klass)
13
+ @reactors[name] = klass
14
+ end
15
+
16
+ def find(name)
17
+ @reactors[name]
18
+ end
19
+
20
+ def clear!
21
+ @reactors.clear
22
+ end
23
+ end
24
+ end
25
+ end
@@ -37,8 +37,8 @@ module RubyReactor
37
37
 
38
38
  # Resume execution from the failed step
39
39
  executor = Executor.new(context.reactor_class, {}, context)
40
- executor.compensation_manager.undo_stack.concat(context.undo_stack)
41
40
  executor.resume_execution
41
+ executor.save_context
42
42
 
43
43
  # Return the executor (which now has the result stored in it)
44
44
  executor
@@ -29,12 +29,28 @@ module RubyReactor
29
29
  handle_execution_result(result)
30
30
  end
31
31
 
32
- def self.compensate(_reason, _arguments, _context)
33
- # TODO: Implement proper compensation for composed reactors
34
- # This requires tracking the execution state of the composed reactor
35
- # and being able to trigger compensation on its completed steps.
36
- # For now, we assume the composed reactor handles its own compensation
37
- # or that compensation is not needed for composed steps.
32
+ def self.compensate(_reason, arguments, context)
33
+ step_name = context.current_step
34
+ composed_data = context.composed_contexts[step_name]
35
+ return RubyReactor.Success() unless composed_data && composed_data[:context]
36
+
37
+ child_context = composed_data[:context]
38
+ executor = RubyReactor::Executor.new(arguments[:composed_reactor_class], {}, child_context)
39
+ executor.undo_all
40
+ executor.save_context
41
+
42
+ RubyReactor.Success()
43
+ end
44
+
45
+ def self.undo(_result, arguments, context)
46
+ step_name = context.current_step
47
+ composed_data = context.composed_contexts[step_name]
48
+ return RubyReactor.Success() unless composed_data && composed_data[:context]
49
+
50
+ child_context = composed_data[:context]
51
+ executor = RubyReactor::Executor.new(arguments[:composed_reactor_class], {}, child_context)
52
+ executor.undo_all
53
+ executor.save_context
38
54
 
39
55
  RubyReactor.Success()
40
56
  end