ruby_reactor 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +10 -2
  3. data/README.md +177 -3
  4. data/Rakefile +25 -0
  5. data/documentation/data_pipelines.md +90 -84
  6. data/documentation/images/failed_order_processing.png +0 -0
  7. data/documentation/images/payment_workflow.png +0 -0
  8. data/documentation/interrupts.md +161 -0
  9. data/gui/.gitignore +24 -0
  10. data/gui/README.md +73 -0
  11. data/gui/eslint.config.js +23 -0
  12. data/gui/index.html +13 -0
  13. data/gui/package-lock.json +5925 -0
  14. data/gui/package.json +46 -0
  15. data/gui/postcss.config.js +6 -0
  16. data/gui/public/vite.svg +1 -0
  17. data/gui/src/App.css +42 -0
  18. data/gui/src/App.tsx +51 -0
  19. data/gui/src/assets/react.svg +1 -0
  20. data/gui/src/components/DagVisualizer.tsx +424 -0
  21. data/gui/src/components/Dashboard.tsx +163 -0
  22. data/gui/src/components/ErrorBoundary.tsx +47 -0
  23. data/gui/src/components/ReactorDetail.tsx +135 -0
  24. data/gui/src/components/StepInspector.tsx +492 -0
  25. data/gui/src/components/__tests__/DagVisualizer.test.tsx +140 -0
  26. data/gui/src/components/__tests__/ReactorDetail.test.tsx +111 -0
  27. data/gui/src/components/__tests__/StepInspector.test.tsx +408 -0
  28. data/gui/src/globals.d.ts +7 -0
  29. data/gui/src/index.css +14 -0
  30. data/gui/src/lib/utils.ts +13 -0
  31. data/gui/src/main.tsx +14 -0
  32. data/gui/src/test/setup.ts +11 -0
  33. data/gui/tailwind.config.js +11 -0
  34. data/gui/tsconfig.app.json +28 -0
  35. data/gui/tsconfig.json +7 -0
  36. data/gui/tsconfig.node.json +26 -0
  37. data/gui/vite.config.ts +8 -0
  38. data/gui/vitest.config.ts +13 -0
  39. data/lib/ruby_reactor/async_router.rb +12 -8
  40. data/lib/ruby_reactor/context.rb +35 -9
  41. data/lib/ruby_reactor/context_serializer.rb +15 -0
  42. data/lib/ruby_reactor/dependency_graph.rb +2 -0
  43. data/lib/ruby_reactor/dsl/compose_builder.rb +8 -0
  44. data/lib/ruby_reactor/dsl/interrupt_builder.rb +48 -0
  45. data/lib/ruby_reactor/dsl/interrupt_step_config.rb +21 -0
  46. data/lib/ruby_reactor/dsl/map_builder.rb +14 -2
  47. data/lib/ruby_reactor/dsl/reactor.rb +12 -0
  48. data/lib/ruby_reactor/dsl/step_builder.rb +4 -0
  49. data/lib/ruby_reactor/executor/compensation_manager.rb +60 -27
  50. data/lib/ruby_reactor/executor/graph_manager.rb +2 -0
  51. data/lib/ruby_reactor/executor/result_handler.rb +118 -39
  52. data/lib/ruby_reactor/executor/retry_manager.rb +12 -1
  53. data/lib/ruby_reactor/executor/step_executor.rb +38 -4
  54. data/lib/ruby_reactor/executor.rb +86 -13
  55. data/lib/ruby_reactor/interrupt_result.rb +20 -0
  56. data/lib/ruby_reactor/map/collector.rb +71 -35
  57. data/lib/ruby_reactor/map/dispatcher.rb +162 -0
  58. data/lib/ruby_reactor/map/element_executor.rb +62 -56
  59. data/lib/ruby_reactor/map/execution.rb +44 -4
  60. data/lib/ruby_reactor/map/helpers.rb +44 -6
  61. data/lib/ruby_reactor/map/result_enumerator.rb +105 -0
  62. data/lib/ruby_reactor/reactor.rb +187 -1
  63. data/lib/ruby_reactor/registry.rb +25 -0
  64. data/lib/ruby_reactor/sidekiq_workers/worker.rb +1 -1
  65. data/lib/ruby_reactor/step/compose_step.rb +22 -6
  66. data/lib/ruby_reactor/step/map_step.rb +78 -19
  67. data/lib/ruby_reactor/storage/adapter.rb +32 -0
  68. data/lib/ruby_reactor/storage/redis_adapter.rb +213 -11
  69. data/lib/ruby_reactor/template/dynamic_source.rb +32 -0
  70. data/lib/ruby_reactor/utils/code_extractor.rb +31 -0
  71. data/lib/ruby_reactor/version.rb +1 -1
  72. data/lib/ruby_reactor/web/api.rb +206 -0
  73. data/lib/ruby_reactor/web/application.rb +53 -0
  74. data/lib/ruby_reactor/web/config.ru +5 -0
  75. data/lib/ruby_reactor/web/public/assets/index-VdeLgH9k.js +19 -0
  76. data/lib/ruby_reactor/web/public/assets/index-_z-6BvuM.css +1 -0
  77. data/lib/ruby_reactor/web/public/index.html +14 -0
  78. data/lib/ruby_reactor/web/public/vite.svg +1 -0
  79. data/lib/ruby_reactor.rb +94 -28
  80. data/llms-full.txt +66 -0
  81. data/llms.txt +7 -0
  82. metadata +66 -2
@@ -4,18 +4,22 @@ 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
16
20
  def self.perform_map_element_async(map_id:, element_id:, index:, serialized_inputs:, reactor_class_info:,
17
21
  strict_ordering:, parent_context_id:, parent_reactor_class_name:, step_name:,
18
- batch_size: nil, serialized_context: nil)
22
+ batch_size: nil, serialized_context: nil, fail_fast: nil)
19
23
  RubyReactor::SidekiqWorkers::MapElementWorker.perform_async(
20
24
  {
21
25
  "map_id" => map_id,
@@ -28,7 +32,8 @@ module RubyReactor
28
32
  "parent_reactor_class_name" => parent_reactor_class_name,
29
33
  "step_name" => step_name,
30
34
  "batch_size" => batch_size,
31
- "serialized_context" => serialized_context
35
+ "serialized_context" => serialized_context,
36
+ "fail_fast" => fail_fast
32
37
  }
33
38
  )
34
39
  end
@@ -71,9 +76,8 @@ module RubyReactor
71
76
  end
72
77
  # rubocop:enable Metrics/ParameterLists
73
78
 
74
- # rubocop:disable Metrics/ParameterLists
75
79
  def self.perform_map_execution_async(map_id:, serialized_inputs:, reactor_class_info:, strict_ordering:,
76
- parent_context_id:, parent_reactor_class_name:, step_name:)
80
+ parent_context_id:, parent_reactor_class_name:, step_name:, fail_fast: nil)
77
81
  RubyReactor::SidekiqWorkers::MapExecutionWorker.perform_async(
78
82
  {
79
83
  "map_id" => map_id,
@@ -82,10 +86,10 @@ module RubyReactor
82
86
  "strict_ordering" => strict_ordering,
83
87
  "parent_context_id" => parent_context_id,
84
88
  "parent_reactor_class_name" => parent_reactor_class_name,
85
- "step_name" => step_name
89
+ "step_name" => step_name,
90
+ "fail_fast" => fail_fast
86
91
  }
87
92
  )
88
93
  end
89
- # rubocop:enable Metrics/ParameterLists
90
94
  end
91
95
  end
@@ -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)
@@ -68,6 +68,14 @@ module RubyReactor
68
68
  { "_type" => "Template::Value", "value" => serialize_value(value.instance_variable_get(:@value)) }
69
69
  when RubyReactor::Template::Result
70
70
  { "_type" => "Template::Result", "step_name" => value.step_name.to_s, "path" => value.path }
71
+ when RubyReactor::Map::ResultEnumerator
72
+ {
73
+ "_type" => "Map::ResultEnumerator",
74
+ "map_id" => value.map_id,
75
+ "reactor_class_name" => value.reactor_class_name,
76
+ "strict_ordering" => value.strict_ordering,
77
+ "batch_size" => value.batch_size
78
+ }
71
79
  when Hash
72
80
  value.transform_keys(&:to_s).transform_values { |v| serialize_value(v) }
73
81
  when Array
@@ -115,6 +123,13 @@ module RubyReactor
115
123
  RubyReactor::Template::Value.new(deserialize_value(value["value"]))
116
124
  when "Template::Result"
117
125
  RubyReactor::Template::Result.new(value["step_name"], value["path"])
126
+ when "Map::ResultEnumerator"
127
+ RubyReactor::Map::ResultEnumerator.new(
128
+ value["map_id"],
129
+ value["reactor_class_name"],
130
+ strict_ordering: value["strict_ordering"],
131
+ batch_size: value["batch_size"]
132
+ )
118
133
  else
119
134
  value
120
135
  end
@@ -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
@@ -24,8 +32,12 @@ module RubyReactor
24
32
  @argument_mappings[mapped_input_name] = source
25
33
  end
26
34
 
27
- def source(enumerable)
28
- @source_enumerable = enumerable
35
+ def source(enumerable = nil, &block)
36
+ @source_enumerable = if block
37
+ RubyReactor::Template::DynamicSource.new(@argument_mappings, &block)
38
+ else
39
+ enumerable
40
+ end
29
41
  end
30
42
 
31
43
  def async(async = true, batch_size: nil)
@@ -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