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
@@ -4,12 +4,16 @@ module RubyReactor
4
4
  class AsyncRouter
5
5
  def self.perform_async(serialized_context, reactor_class_name = nil, intermediate_results: {})
6
6
  job_id = SidekiqWorkers::Worker.perform_async(serialized_context, reactor_class_name)
7
- RubyReactor::AsyncResult.new(job_id: job_id, intermediate_results: intermediate_results)
7
+ context = ContextSerializer.deserialize(serialized_context)
8
+ RubyReactor::AsyncResult.new(job_id: job_id, intermediate_results: intermediate_results,
9
+ execution_id: context.context_id)
8
10
  end
9
11
 
10
12
  def self.perform_in(delay, serialized_context, reactor_class_name = nil, intermediate_results: {})
11
13
  job_id = SidekiqWorkers::Worker.perform_in(delay, serialized_context, reactor_class_name)
12
- RubyReactor::AsyncResult.new(job_id: job_id, intermediate_results: intermediate_results)
14
+ context = ContextSerializer.deserialize(serialized_context)
15
+ RubyReactor::AsyncResult.new(job_id: job_id, intermediate_results: intermediate_results,
16
+ execution_id: context.context_id)
13
17
  end
14
18
 
15
19
  # rubocop:disable Metrics/ParameterLists
@@ -4,7 +4,8 @@ module RubyReactor
4
4
  class Context
5
5
  attr_accessor :inputs, :intermediate_results, :private_data, :current_step, :retry_count, :concurrency_key,
6
6
  :retry_context, :reactor_class, :execution_trace, :inline_async_execution, :undo_stack, :test_mode,
7
- :parent_context, :root_context, :composed_contexts, :context_id, :map_operations, :map_metadata
7
+ :parent_context, :root_context, :composed_contexts, :context_id, :map_operations, :map_metadata,
8
+ :cancelled, :cancellation_reason, :parent_context_id, :status, :failure_reason
8
9
 
9
10
  def initialize(inputs = {}, reactor_class = nil)
10
11
  @context_id = SecureRandom.uuid
@@ -23,7 +24,12 @@ module RubyReactor
23
24
  @inline_async_execution = false # Flag to prevent nested async calls
24
25
  @undo_stack = [] # Initialize the undo stack
25
26
  @test_mode = false
27
+ @cancelled = false
28
+ @cancellation_reason = nil
29
+ @status = "pending"
30
+ @failure_reason = nil
26
31
  @parent_context = nil
32
+ @parent_context_id = nil
27
33
  @root_context = nil
28
34
  end
29
35
 
@@ -37,6 +43,7 @@ module RubyReactor
37
43
  value
38
44
  end
39
45
  end
46
+ alias input get_input
40
47
 
41
48
  def get_result(step_name, path = nil)
42
49
  value = @intermediate_results[step_name.to_sym] || @intermediate_results[step_name.to_s]
@@ -48,6 +55,7 @@ module RubyReactor
48
55
  value
49
56
  end
50
57
  end
58
+ alias result get_result
51
59
 
52
60
  def set_result(step_name, value)
53
61
  @intermediate_results[step_name.to_sym] = value
@@ -73,7 +81,9 @@ module RubyReactor
73
81
  retry_context: @retry_context,
74
82
  reactor_class: @reactor_class,
75
83
  execution_trace: @execution_trace,
76
- test_mode: @test_mode
84
+ test_mode: @test_mode,
85
+ status: @status,
86
+ failure_reason: @failure_reason
77
87
  }
78
88
  end
79
89
 
@@ -95,14 +105,19 @@ module RubyReactor
95
105
  retry_context: @retry_context.serialize_for_retry,
96
106
  execution_trace: ContextSerializer.serialize_value(@execution_trace),
97
107
  undo_stack: serialize_undo_stack,
98
- test_mode: @test_mode
108
+ test_mode: @test_mode,
109
+ cancelled: @cancelled,
110
+ cancellation_reason: @cancellation_reason,
111
+ status: @status,
112
+ failure_reason: ContextSerializer.serialize_value(@failure_reason),
113
+ parent_context_id: @parent_context&.context_id || @parent_context_id
99
114
  }
100
115
  end
101
116
 
102
117
  def self.deserialize_from_retry(data)
103
118
  context = new
104
119
  context.context_id = data["context_id"] if data["context_id"]
105
- context.reactor_class = data["reactor_class"] ? Object.const_get(data["reactor_class"]) : nil
120
+ context.reactor_class = resolve_reactor_class(data["reactor_class"])
106
121
  context.inputs = ContextSerializer.deserialize_value(data["inputs"]) || {}
107
122
  context.intermediate_results = ContextSerializer.deserialize_value(data["intermediate_results"]) || {}
108
123
  context.private_data = ContextSerializer.deserialize_value(data["private_data"]) || {}
@@ -116,15 +131,26 @@ module RubyReactor
116
131
  context.execution_trace = ContextSerializer.deserialize_value(data["execution_trace"]) || []
117
132
  context.undo_stack = deserialize_undo_stack(data["undo_stack"] || [], context.reactor_class)
118
133
  context.test_mode = data["test_mode"] || false
119
-
120
- # Reconstruct parent/root relationships if nested contexts exist in private_data
121
- # This is tricky because private_data is just a hash.
122
- # We rely on the fact that nested contexts are stored in private_data by ComposeStep
123
- # But here we just deserialize the values.
134
+ context.cancelled = data["cancelled"] || false
135
+ context.cancellation_reason = data["cancellation_reason"]
136
+ context.status = data["status"] || "pending"
137
+ context.failure_reason = ContextSerializer.deserialize_value(data["failure_reason"])
138
+ context.parent_context_id = data["parent_context_id"]
124
139
 
125
140
  context
126
141
  end
127
142
 
143
+ def self.resolve_reactor_class(name)
144
+ return nil unless name
145
+
146
+ begin
147
+ Object.const_get(name)
148
+ rescue NameError
149
+ # Try finding in registry for anonymous classes
150
+ RubyReactor::Registry.find(name)
151
+ end
152
+ end
153
+
128
154
  private
129
155
 
130
156
  def extract_path(value, path)
@@ -2,6 +2,8 @@
2
2
 
3
3
  module RubyReactor
4
4
  class DependencyGraph
5
+ attr_reader :dependencies, :completed
6
+
5
7
  def initialize
6
8
  @nodes = {}
7
9
  @edges = {}
@@ -10,6 +10,14 @@ module RubyReactor
10
10
  def initialize(name, composed_reactor_class = nil, reactor = nil, &block)
11
11
  @name = name
12
12
  @composed_reactor_class = composed_reactor_class || (block ? Class.new(RubyReactor::Reactor) : nil)
13
+ if @composed_reactor_class && @composed_reactor_class.name.nil? && reactor
14
+ step_name_camel = name.to_s.split("_").map(&:capitalize).join
15
+ parent_name = reactor.name
16
+
17
+ full_name = "#{parent_name}::#{step_name_camel}"
18
+ @composed_reactor_class.define_singleton_method(:name) { full_name }
19
+ RubyReactor::Registry.register(full_name, @composed_reactor_class)
20
+ end
13
21
  @reactor = reactor
14
22
  @argument_mappings = {}
15
23
  @async = false
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ module Dsl
5
+ class InterruptBuilder < StepBuilder
6
+ def initialize(name, reactor = nil)
7
+ super(name, nil, reactor)
8
+ @correlation_id_block = nil
9
+ @timeout_config = nil
10
+ @validation_schema = nil
11
+ @max_attempts = 1
12
+ end
13
+
14
+ def max_attempts(count)
15
+ @max_attempts = count
16
+ end
17
+
18
+ def correlation_id(&block)
19
+ @correlation_id_block = block
20
+ end
21
+
22
+ def timeout(seconds, strategy: :lazy)
23
+ @timeout_config = { duration: seconds, strategy: strategy }
24
+ end
25
+
26
+ def validate(&block)
27
+ check_dry_validation_available!
28
+ @validation_schema = build_validation_schema(&block)
29
+ end
30
+
31
+ def build
32
+ step_config = {
33
+ name: @name,
34
+ correlation_id_block: @correlation_id_block,
35
+ timeout_config: @timeout_config,
36
+ validation_schema: @validation_schema,
37
+ max_attempts: @max_attempts,
38
+ dependencies: @dependencies,
39
+ async: false, # Interrupts are effectively boundaries, not async jobs themselves (until resumed)
40
+ conditions: @conditions,
41
+ guards: @guards
42
+ }
43
+
44
+ RubyReactor::Dsl::InterruptStepConfig.new(step_config)
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ module Dsl
5
+ class InterruptStepConfig < StepConfig
6
+ attr_reader :correlation_id_block, :timeout_config, :validation_schema, :strategy, :max_attempts
7
+
8
+ def initialize(config)
9
+ super
10
+ @correlation_id_block = config[:correlation_id_block]
11
+ @timeout_config = config[:timeout_config]
12
+ @validation_schema = config[:validation_schema]
13
+ @max_attempts = config[:max_attempts] || 1
14
+ end
15
+
16
+ def interrupt?
17
+ true
18
+ end
19
+ end
20
+ end
21
+ end
@@ -10,6 +10,14 @@ module RubyReactor
10
10
  def initialize(name, mapped_reactor_class = nil, reactor = nil, &block)
11
11
  @name = name
12
12
  @mapped_reactor_class = mapped_reactor_class || (block ? Class.new(RubyReactor::Reactor) : nil)
13
+ if @mapped_reactor_class && @mapped_reactor_class.name.nil? && reactor
14
+ map_name_camel = name.to_s.split("_").map(&:capitalize).join
15
+ parent_name = reactor.name # Assuming reactor has a name method or is a class with a name.
16
+
17
+ full_name = "#{parent_name}::#{map_name_camel}"
18
+ @mapped_reactor_class.define_singleton_method(:name) { full_name }
19
+ RubyReactor::Registry.register(full_name, @mapped_reactor_class)
20
+ end
13
21
  @reactor = reactor
14
22
  @argument_mappings = {}
15
23
  @async = false
@@ -18,6 +18,9 @@ module RubyReactor
18
18
  include RubyReactor::Dsl::TemplateHelpers
19
19
  include RubyReactor::Dsl::ValidationHelpers
20
20
 
21
+ require_relative "interrupt_builder"
22
+ require_relative "interrupt_step_config"
23
+
21
24
  def inputs
22
25
  @inputs ||= {}
23
26
  end
@@ -106,6 +109,15 @@ module RubyReactor
106
109
  step_config
107
110
  end
108
111
 
112
+ def interrupt(name, &block)
113
+ builder = RubyReactor::Dsl::InterruptBuilder.new(name, self)
114
+ builder.instance_eval(&block) if block_given?
115
+
116
+ step_config = builder.build
117
+ steps[name] = step_config
118
+ step_config
119
+ end
120
+
109
121
  def returns(step_name)
110
122
  @return_step = step_name
111
123
  end
@@ -172,6 +172,10 @@ module RubyReactor
172
172
  @conditions.all? { |condition| condition.call(context) } &&
173
173
  @guards.all? { |guard| guard.call(context) }
174
174
  end
175
+
176
+ def interrupt?
177
+ false
178
+ end
175
179
  end
176
180
  end
177
181
  end
@@ -5,14 +5,17 @@ module RubyReactor
5
5
  class CompensationManager
6
6
  def initialize(context)
7
7
  @context = context
8
- @undo_stack = []
9
8
  @undo_trace = []
10
9
  end
11
10
 
12
- attr_reader :undo_stack, :undo_trace
11
+ def undo_stack
12
+ @context.undo_stack
13
+ end
14
+
15
+ attr_reader :undo_trace
13
16
 
14
17
  def add_to_undo_stack(step_info)
15
- @undo_stack << step_info
18
+ @context.undo_stack << step_info
16
19
  end
17
20
 
18
21
  def handle_step_failure(step_config, error, arguments)
@@ -36,43 +39,73 @@ module RubyReactor
36
39
  end
37
40
 
38
41
  def rollback_completed_steps
39
- @undo_stack.reverse_each do |step_info|
40
- result = undo_step(step_info[:step], step_info[:result], step_info[:arguments])
42
+ undo_stack.reverse_each do |step_info|
43
+ result = @context.with_step(step_info[:step].name) do
44
+ undo_step(step_info[:step], step_info[:result], step_info[:arguments])
45
+ end
41
46
  @undo_trace << { type: :undo, step: step_info[:step].name, result: result,
42
47
  arguments: step_info[:arguments] }
43
48
  end
44
- @undo_stack.clear
49
+ undo_stack.clear
45
50
  end
46
51
 
47
52
  private
48
53
 
49
54
  def compensate_step(step_config, error, arguments)
50
- if step_config.compensate_block
51
- @context.execution_trace << { type: :compensate, step: step_config.name, timestamp: Time.now, error: error,
52
- arguments: arguments }
53
- @undo_trace << { type: :compensation, step: step_config.name, error: error, arguments: arguments }
54
- step_config.compensate_block.call(error, arguments, @context)
55
- elsif step_config.has_impl?
56
- @context.execution_trace << { type: :compensate, step: step_config.name, timestamp: Time.now, error: error,
57
- arguments: arguments }
58
- @undo_trace << { type: :compensation, step: step_config.name, error: error, arguments: arguments }
59
- step_config.impl.compensate(error, arguments, @context)
60
- else
61
- RubyReactor.Success() # Default compensation
62
- end
55
+ compensate_result = if step_config.compensate_block
56
+ step_config.compensate_block.call(error, arguments, @context)
57
+ elsif step_config.has_impl?
58
+ step_config.impl.compensate(error, arguments, @context)
59
+ else
60
+ RubyReactor.Success() # Default compensation
61
+ end
62
+
63
+ # Ensure we have a value to log
64
+ logged_result = if compensate_result.respond_to?(:value)
65
+ compensate_result.value
66
+ elsif compensate_result.respond_to?(:error)
67
+ compensate_result.error
68
+ else
69
+ compensate_result
70
+ end
71
+
72
+ @context.execution_trace << {
73
+ type: :compensate,
74
+ step: step_config.name,
75
+ timestamp: Time.now,
76
+ result: logged_result,
77
+ arguments: arguments
78
+ }
79
+ @undo_trace << { type: :compensation, step: step_config.name, error: error, arguments: arguments }
80
+ compensate_result
63
81
  end
64
82
 
65
83
  def undo_step(step_config, result, arguments)
66
- @context.execution_trace << { type: :undo, step: step_config.name, timestamp: Time.now, result: result.value,
84
+ undo_result = if step_config.undo_block
85
+ step_config.undo_block.call(result.value, arguments, @context)
86
+ elsif step_config.has_impl?
87
+ step_config.impl.undo(result.value, arguments, @context)
88
+ else
89
+ RubyReactor.Success()
90
+ end
91
+
92
+ # Ensure we have a value to log (if it's a Success/Failure object, get the value or error)
93
+ logged_result = if undo_result.respond_to?(:value)
94
+ undo_result.value
95
+ elsif undo_result.respond_to?(:error)
96
+ undo_result.error
97
+ else
98
+ undo_result
99
+ end
100
+
101
+ @context.execution_trace << { type: :undo, step: step_config.name, timestamp: Time.now, result: logged_result,
67
102
  arguments: arguments }
68
- if step_config.undo_block
69
- step_config.undo_block.call(result.value, arguments, @context)
70
- elsif step_config.has_impl?
71
- step_config.impl.undo(result.value, arguments, @context)
72
- end
73
- rescue StandardError
103
+ undo_result
104
+ rescue StandardError => e
74
105
  # Log undo failure but don't halt the rollback process
75
- # In a real implementation, this would use a logger
106
+ @context.execution_trace << { type: :undo_failure, step: step_config.name, timestamp: Time.now,
107
+ error: e.message }
108
+ RubyReactor.Failure(e)
76
109
  end
77
110
  end
78
111
  end
@@ -3,6 +3,8 @@
3
3
  module RubyReactor
4
4
  class Executor
5
5
  class GraphManager
6
+ attr_reader :dependency_graph
7
+
6
8
  def initialize(reactor_class, dependency_graph, context)
7
9
  @reactor_class = reactor_class
8
10
  @dependency_graph = dependency_graph
@@ -15,62 +15,30 @@ module RubyReactor
15
15
  def handle_step_result(step_config, result, resolved_arguments)
16
16
  case result
17
17
  when RubyReactor::Success
18
- validate_step_output(step_config, result.value, resolved_arguments)
19
- @step_results[step_config.name] = result
20
- @compensation_manager.add_to_undo_stack({ step: step_config, arguments: resolved_arguments, result: result })
21
- @context.set_result(step_config.name, result.value)
22
- @dependency_graph.complete_step(step_config.name)
18
+ handle_success(step_config, result, resolved_arguments)
23
19
  when RubyReactor::MaxRetriesExhaustedFailure
24
- # For MaxRetriesExhaustedFailure, use the original error to avoid double-wrapping the message
25
- # The error message from MaxRetriesExhaustedFailure already includes "failed after N attempts"
26
- @compensation_manager.handle_step_failure(step_config, result.original_error, resolved_arguments)
27
- # Use the MaxRetriesExhaustedFailure error message for the final error
28
- raise Error::StepFailureError.new(result.error, step: step_config.name, context: @context,
29
- step_arguments: resolved_arguments)
20
+ handle_retries_exhausted(step_config, result, resolved_arguments)
30
21
  when RubyReactor::Failure
31
- failure_result = @compensation_manager.handle_step_failure(step_config, result.error, resolved_arguments)
32
- raise Error::StepFailureError.new(failure_result.error, step: step_config.name, context: @context,
33
- step_arguments: resolved_arguments)
22
+ handle_failure(step_config, result, resolved_arguments)
34
23
  else
35
- # Treat non-Success/Failure results as success with that value
36
- validate_step_output(step_config, result, resolved_arguments)
37
- success_result = RubyReactor.Success(result)
38
- @step_results[step_config.name] = success_result
39
- @compensation_manager.add_to_undo_stack({ step: step_config, arguments: resolved_arguments,
40
- result: success_result })
41
- @context.set_result(step_config.name, result)
42
- @dependency_graph.complete_step(step_config.name)
24
+ handle_unknown_result(step_config, result, resolved_arguments)
43
25
  end
44
26
  end
45
27
 
46
28
  def handle_execution_error(error)
47
29
  case error
48
30
  when Error::StepFailureError
49
- # Step failure has already been handled (compensation and rollback for the failed step)
50
- # But we need to rollback all completed steps
51
- @compensation_manager.rollback_completed_steps
52
-
53
- redact_inputs = error.context.reactor_class.inputs.select { |_, config| config[:redact] }.keys
54
-
55
- RubyReactor::Failure(
56
- error.message,
57
- step_name: error.step,
58
- inputs: error.context.inputs,
59
- redact_inputs: redact_inputs,
60
- backtrace: error.backtrace,
61
- reactor_name: error.context.reactor_class.name,
62
- step_arguments: error.step_arguments
63
- )
31
+ handle_step_failure_error(error)
64
32
  when Error::InputValidationError
65
33
  # Preserve validation errors as-is for proper error handling
66
34
  RubyReactor.Failure(error)
67
35
  when Error::Base
68
36
  # Other errors need rollback
69
37
  @compensation_manager.rollback_completed_steps
70
- RubyReactor.Failure("Execution error: #{error.message}")
38
+ RubyReactor.Failure("Execution error: #{error.message}", exception_class: error.class.name)
71
39
  else
72
40
  # Unknown errors - don't rollback as they may not be reactor-related
73
- RubyReactor.Failure("Execution failed: #{error.message}")
41
+ RubyReactor.Failure("Execution failed: #{error.message}", exception_class: error.class.name)
74
42
  end
75
43
  end
76
44
 
@@ -85,6 +53,101 @@ module RubyReactor
85
53
 
86
54
  private
87
55
 
56
+ def handle_success(step_config, result, resolved_arguments)
57
+ validate_step_output(step_config, result.value, resolved_arguments)
58
+ @step_results[step_config.name] = result
59
+ @compensation_manager.add_to_undo_stack({ step: step_config, arguments: resolved_arguments, result: result })
60
+ @context.set_result(step_config.name, result.value)
61
+ @dependency_graph.complete_step(step_config.name)
62
+ end
63
+
64
+ def handle_retries_exhausted(step_config, result, resolved_arguments)
65
+ @compensation_manager.handle_step_failure(step_config, result.original_error, resolved_arguments)
66
+ orig_err = result.original_error.is_a?(Exception) ? result.original_error : nil
67
+ error = Error::StepFailureError.new(result.error, step: step_config.name, context: @context,
68
+ original_error: orig_err,
69
+ step_arguments: resolved_arguments)
70
+ if result.respond_to?(:backtrace) && result.backtrace
71
+ error.set_backtrace(result.backtrace)
72
+ elsif orig_err
73
+ error.set_backtrace(orig_err.backtrace)
74
+ end
75
+ raise error
76
+ end
77
+
78
+ def handle_failure(step_config, result, resolved_arguments)
79
+ failure_result = @compensation_manager.handle_step_failure(step_config, result.error, resolved_arguments)
80
+ orig_err = result.error.is_a?(Exception) ? result.error : nil
81
+ error = Error::StepFailureError.new(failure_result.error, step: step_config.name, context: @context,
82
+ original_error: orig_err,
83
+ step_arguments: resolved_arguments)
84
+ if result.respond_to?(:backtrace) && result.backtrace
85
+ error.set_backtrace(result.backtrace)
86
+ elsif orig_err
87
+ error.set_backtrace(orig_err.backtrace)
88
+ end
89
+ raise error
90
+ end
91
+
92
+ def handle_unknown_result(step_config, result, resolved_arguments)
93
+ validate_step_output(step_config, result, resolved_arguments)
94
+ success_result = RubyReactor.Success(result)
95
+ @step_results[step_config.name] = success_result
96
+ @compensation_manager.add_to_undo_stack({ step: step_config, arguments: resolved_arguments,
97
+ result: success_result })
98
+ @context.set_result(step_config.name, result)
99
+ @dependency_graph.complete_step(step_config.name)
100
+ end
101
+
102
+ def handle_step_failure_error(error)
103
+ current_context = error.context || @context
104
+ current_context.current_step = error.step
105
+
106
+ store_failed_map_context(current_context) if current_context.map_metadata
107
+
108
+ @compensation_manager.rollback_completed_steps
109
+
110
+ redact_inputs = []
111
+ if error.context&.reactor_class
112
+ redact_inputs = error.context.reactor_class.inputs.select { |_, config| config[:redact] }.keys
113
+ end
114
+
115
+ create_failure_from_error(error, redact_inputs)
116
+ end
117
+
118
+ def store_failed_map_context(context)
119
+ return unless context.map_metadata && context.map_metadata[:map_id]
120
+
121
+ storage = RubyReactor.configuration.storage_adapter
122
+ storage.store_map_failed_context_id(
123
+ context.map_metadata[:map_id],
124
+ context.context_id,
125
+ context.map_metadata[:parent_reactor_class_name]
126
+ )
127
+ end
128
+
129
+ def create_failure_from_error(error, redact_inputs)
130
+ original_error = error.original_error
131
+ exception_class = original_error&.class&.name
132
+ backtrace = original_error&.backtrace || error.backtrace
133
+ file_path, line_number = extract_location(backtrace)
134
+ code_snippet = RubyReactor::Utils::CodeExtractor.extract(file_path, line_number) if file_path
135
+
136
+ RubyReactor.Failure(
137
+ error.message,
138
+ step_name: error.step,
139
+ inputs: error.context.inputs,
140
+ redact_inputs: redact_inputs,
141
+ backtrace: backtrace,
142
+ reactor_name: error.context.reactor_class.name,
143
+ step_arguments: error.step_arguments,
144
+ exception_class: exception_class,
145
+ file_path: file_path,
146
+ line_number: line_number,
147
+ code_snippet: code_snippet
148
+ )
149
+ end
150
+
88
151
  def validate_step_output(step_config, value, resolved_arguments = {})
89
152
  return unless step_config.output_validator
90
153
 
@@ -98,6 +161,21 @@ module RubyReactor
98
161
  step_arguments: resolved_arguments
99
162
  )
100
163
  end
164
+
165
+ def extract_location(backtrace)
166
+ return [nil, nil] unless backtrace && !backtrace.empty?
167
+
168
+ # Filter out internal reactor frames if needed, or just take the first one
169
+ # For now, let's take the first line of the backtrace which should be the error source
170
+ # But we might want to skip our own internal frames if we want to point to user code
171
+ # Let's start with the top frame, assuming backtrace is already correct (from original error)
172
+
173
+ first_line = backtrace.first
174
+ match = first_line.match(/^(.+?):(\d+)(?::in `.*')?$/)
175
+ return [nil, nil] unless match
176
+
177
+ [match[1], match[2].to_i]
178
+ end
101
179
  end
102
180
  end
103
181
  end
@@ -34,6 +34,7 @@ module RubyReactor
34
34
  end
35
35
 
36
36
  def requeue_job_for_step_retry(step_config, error, reactor_class)
37
+ @context.current_step = step_config.name
37
38
  delay = calculate_backoff_delay(step_config, error, reactor_class)
38
39
 
39
40
  # Serialize context and requeue the job