ruby_reactor 0.7.0 → 0.8.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 (91) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/demo-app-e2e-verify/SKILL.md +226 -0
  3. data/.claude/skills/speckit-demo-tests/SKILL.md +144 -0
  4. data/.release-please-manifest.json +1 -1
  5. data/.specify/feature.json +1 -1
  6. data/.specify/memory/constitution.md +92 -15
  7. data/.specify/templates/plan-template.md +4 -0
  8. data/.specify/templates/tasks-template.md +8 -1
  9. data/CHANGELOG.md +151 -0
  10. data/CLAUDE.md +2 -2
  11. data/README.md +149 -40
  12. data/lib/ruby_reactor/context.rb +9 -2
  13. data/lib/ruby_reactor/context_serializer.rb +13 -0
  14. data/lib/ruby_reactor/dsl/interrupt_builder.rb +6 -0
  15. data/lib/ruby_reactor/dsl/lockable.rb +2 -2
  16. data/lib/ruby_reactor/dsl/reactor.rb +36 -18
  17. data/lib/ruby_reactor/dsl/step_builder.rb +95 -2
  18. data/lib/ruby_reactor/dsl/template_helpers.rb +13 -5
  19. data/lib/ruby_reactor/dsl/validation_helpers.rb +17 -0
  20. data/lib/ruby_reactor/error/input_validation_error.rb +4 -0
  21. data/lib/ruby_reactor/error/step_failure_error.rb +10 -3
  22. data/lib/ruby_reactor/executor/compensation_manager.rb +58 -45
  23. data/lib/ruby_reactor/executor/ordered_lock_support.rb +9 -9
  24. data/lib/ruby_reactor/executor/result_handler.rb +44 -13
  25. data/lib/ruby_reactor/executor/retry_manager.rb +6 -2
  26. data/lib/ruby_reactor/executor/step_executor.rb +27 -14
  27. data/lib/ruby_reactor/executor.rb +20 -15
  28. data/lib/ruby_reactor/map/element_executor.rb +7 -1
  29. data/lib/ruby_reactor/map/helpers.rb +9 -7
  30. data/lib/ruby_reactor/map/result_enumerator.rb +2 -0
  31. data/lib/ruby_reactor/map/sweeper.rb +1 -1
  32. data/lib/ruby_reactor/max_retries_exhausted_failure.rb +3 -2
  33. data/lib/ruby_reactor/open_telemetry.rb +7 -4
  34. data/lib/ruby_reactor/ordered_lock.rb +3 -3
  35. data/lib/ruby_reactor/reactor.rb +9 -12
  36. data/lib/ruby_reactor/rspec/matchers.rb +64 -17
  37. data/lib/ruby_reactor/rspec/test_subject.rb +8 -8
  38. data/lib/ruby_reactor/step/async_reactor_step.rb +159 -162
  39. data/lib/ruby_reactor/step/compose_step.rb +56 -75
  40. data/lib/ruby_reactor/step/input_contract.rb +128 -0
  41. data/lib/ruby_reactor/step/map_step.rb +178 -215
  42. data/lib/ruby_reactor/step.rb +119 -18
  43. data/lib/ruby_reactor/step_signals.rb +37 -0
  44. data/lib/ruby_reactor/step_worker.rb +25 -10
  45. data/lib/ruby_reactor/storage/adapter.rb +4 -0
  46. data/lib/ruby_reactor/storage/redis_adapter.rb +1 -72
  47. data/lib/ruby_reactor/storage/redis_reactor_scan.rb +116 -0
  48. data/lib/ruby_reactor/template/result.rb +9 -2
  49. data/lib/ruby_reactor/utils/fetch_indifferent.rb +13 -0
  50. data/lib/ruby_reactor/version.rb +1 -1
  51. data/lib/ruby_reactor/web/api.rb +23 -6
  52. data/lib/ruby_reactor/web/public/assets/index-BQvIWPdx.css +1 -0
  53. data/lib/ruby_reactor/web/public/assets/index-Dw4KV4QY.js +22 -0
  54. data/lib/ruby_reactor/web/public/index.html +2 -2
  55. data/lib/ruby_reactor.rb +61 -9
  56. data/specs/002-step-input-contracts/checklists/requirements.md +49 -0
  57. data/specs/002-step-input-contracts/contracts/dsl-surface.md +193 -0
  58. data/specs/002-step-input-contracts/data-model.md +115 -0
  59. data/specs/002-step-input-contracts/plan.md +165 -0
  60. data/specs/002-step-input-contracts/quickstart.md +170 -0
  61. data/specs/002-step-input-contracts/research.md +233 -0
  62. data/specs/002-step-input-contracts/spec.md +359 -0
  63. data/specs/002-step-input-contracts/tasks.md +367 -0
  64. data/specs/004-inheritable-step-class/checklists/requirements.md +40 -0
  65. data/specs/004-inheritable-step-class/contracts/step-lifecycle.md +85 -0
  66. data/specs/004-inheritable-step-class/data-model.md +116 -0
  67. data/specs/004-inheritable-step-class/plan.md +174 -0
  68. data/specs/004-inheritable-step-class/quickstart.md +112 -0
  69. data/specs/004-inheritable-step-class/research.md +308 -0
  70. data/specs/004-inheritable-step-class/spec.md +316 -0
  71. data/specs/004-inheritable-step-class/tasks.md +258 -0
  72. data/specs/deferred-003-step-lock-declarations/checklists/requirements.md +51 -0
  73. data/specs/deferred-003-step-lock-declarations/contracts/dsl-surface.md +154 -0
  74. data/specs/deferred-003-step-lock-declarations/data-model.md +131 -0
  75. data/specs/deferred-003-step-lock-declarations/plan.md +166 -0
  76. data/specs/deferred-003-step-lock-declarations/quickstart.md +169 -0
  77. data/specs/deferred-003-step-lock-declarations/research.md +196 -0
  78. data/specs/deferred-003-step-lock-declarations/spec.md +447 -0
  79. data/specs/deferred-003-step-lock-declarations/tasks.md +572 -0
  80. data/specs/possible_feature.md +22 -0
  81. metadata +34 -11
  82. data/lib/ruby_reactor/web/public/assets/index-B46p-M6K.css +0 -1
  83. data/lib/ruby_reactor/web/public/assets/index-DPmP4yXT.js +0 -22
  84. data/specs/001-background-async-steps/checklists/requirements.md +0 -39
  85. data/specs/001-background-async-steps/contracts/public-dsl.md +0 -154
  86. data/specs/001-background-async-steps/data-model.md +0 -117
  87. data/specs/001-background-async-steps/plan.md +0 -168
  88. data/specs/001-background-async-steps/quickstart.md +0 -102
  89. data/specs/001-background-async-steps/research.md +0 -150
  90. data/specs/001-background-async-steps/spec.md +0 -146
  91. data/specs/001-background-async-steps/tasks.md +0 -271
@@ -25,17 +25,38 @@ module RubyReactor
25
25
  @args_validator = nil
26
26
  @output_validator = nil
27
27
  @retry_config = {}
28
+ @inline_contract = nil
29
+ @rule_sites = []
30
+ end
31
+
32
+ # Deprecation notices print once per declaration site for the process.
33
+ def self.deprecation_sites
34
+ @deprecation_sites ||= Set.new
28
35
  end
29
36
 
30
37
  def argument(name, source, type = nil, transform: nil, **predicates)
31
38
  @arguments[name] = {
32
39
  source: source,
33
- transform: transform
40
+ transform: transform,
41
+ origin: :explicit
34
42
  }
35
43
 
36
44
  return unless type || predicates.any?
37
45
 
38
46
  @arg_validations << [name, type, false, predicates]
47
+ @rule_sites << [name, caller_locations(1, 1).first]
48
+ end
49
+
50
+ # An inline step's input contract. Inside the block `input` declares, as
51
+ # in a step class; outside it `input(:x)` stays the template reference.
52
+ def inputs(&block)
53
+ if @impl
54
+ raise Error::ValidationError,
55
+ "step :#{@name}: `inputs` is for inline steps; declare `input` inside #{@impl} instead."
56
+ end
57
+
58
+ @inline_contract ||= RubyReactor::Step::InputContract.new(owner: @name)
59
+ @inline_contract.instance_eval(&block)
39
60
  end
40
61
 
41
62
  def run(&block)
@@ -67,6 +88,7 @@ module RubyReactor
67
88
  # pre-built schema) is applied last and wins on conflicts.
68
89
  def validate_args(schema_or_validator = nil, &block)
69
90
  @validate_args_input = block || schema_or_validator
91
+ @validate_args_site = caller_locations(1, 1).first
70
92
  end
71
93
 
72
94
  # Scalar-aware output validation.
@@ -114,6 +136,9 @@ module RubyReactor
114
136
  # unit rather than run inline — `:step` for `async_step`, `:reactor` for
115
137
  # `async_reactor`. Nil for an ordinary step.
116
138
  def build(async_dispatch: nil)
139
+ check_contract_conflicts!
140
+ warn_deprecated_rules
141
+
117
142
  step_config = {
118
143
  async_dispatch: async_dispatch,
119
144
  name: @name,
@@ -127,16 +152,78 @@ module RubyReactor
127
152
  dependencies: @dependencies,
128
153
  args_validator: @args_validator || build_args_validator(@arg_validations, @validate_args_input),
129
154
  output_validator: @output_validator,
155
+ inline_contract: @inline_contract,
130
156
  retry_config: @retry_config.empty? ? (@reactor&.retry_defaults || {}) : @retry_config
131
157
  }
132
158
 
133
159
  RubyReactor::Dsl::StepConfig.new(step_config)
134
160
  end
161
+
162
+ private
163
+
164
+ # A step that owns its input contract takes wiring only from the
165
+ # reactor: rules here would be a second, overlapping rule set.
166
+ def check_contract_conflicts!
167
+ contract = owned_contract
168
+ return unless contract
169
+
170
+ owner = @impl || "its `inputs do ... end` block"
171
+ if (arg = @arg_validations.first&.first)
172
+ raise Error::ValidationError,
173
+ "#{reactor_label} step :#{@name} declares rules on argument :#{arg}, but #{owner} owns its input " \
174
+ "contract. Move the rule into #{owner} (`input :#{arg}, ...`) and keep only the wiring here: " \
175
+ "`argument :#{arg}, <source>`."
176
+ end
177
+ if @validate_args_input
178
+ raise Error::ValidationError,
179
+ "#{reactor_label} step :#{@name} declares `validate_args`, but #{owner} owns its input contract. " \
180
+ "Move the rule into #{owner} (`validate_inputs ...`) and keep only the wiring here."
181
+ end
182
+
183
+ unknown = @arguments.keys.find { |name| !contract.declares?(name) }
184
+ return unless unknown
185
+
186
+ raise Error::ValidationError,
187
+ "#{reactor_label} step :#{@name} wires argument :#{unknown}, which #{owner} does not declare. " \
188
+ "Declared inputs: #{contract.declarations.keys.join(", ")}."
189
+ end
190
+
191
+ # Rules on `argument` / `validate_args` still work for a step without a
192
+ # contract; they now belong on the step itself.
193
+ def warn_deprecated_rules
194
+ target = @impl || "an `inputs do ... end` block"
195
+ @rule_sites.each do |arg, site|
196
+ warn_deprecation(site, "step :#{@name} declares rules on `argument :#{arg}`. Declare them on the step " \
197
+ "instead (`input :#{arg}, ...` in #{target}) and keep `argument :#{arg}, <source>` " \
198
+ "for wiring.")
199
+ end
200
+ return unless @validate_args_site
201
+
202
+ warn_deprecation(@validate_args_site, "step :#{@name} declares rules with `validate_args`. Declare them on " \
203
+ "the step instead (`validate_inputs` in #{target}).")
204
+ end
205
+
206
+ def warn_deprecation(site, message)
207
+ location = "#{site.path}:#{site.lineno}"
208
+ return unless StepBuilder.deprecation_sites.add?(location)
209
+
210
+ warn "[RubyReactor] DEPRECATION: #{location} #{reactor_label} #{message} " \
211
+ "Removal no earlier than the next MAJOR."
212
+ end
213
+
214
+ def owned_contract
215
+ @inline_contract || (@impl.input_contract if @impl.respond_to?(:declares_inputs?) && @impl.declares_inputs?)
216
+ end
217
+
218
+ def reactor_label
219
+ @reactor&.name || @reactor.inspect
220
+ end
135
221
  end
136
222
 
137
223
  class StepConfig
138
224
  attr_reader :name, :impl, :arguments, :run_block, :compensate_block, :undo_block, :conditions, :guards,
139
- :dependencies, :args_validator, :output_validator, :retry_config, :async_dispatch
225
+ :dependencies, :args_validator, :output_validator, :retry_config, :async_dispatch,
226
+ :inline_contract
140
227
 
141
228
  def initialize(config)
142
229
  @async_dispatch = config[:async_dispatch]
@@ -151,6 +238,7 @@ module RubyReactor
151
238
  @dependencies = config[:dependencies] || []
152
239
  @args_validator = config[:args_validator]
153
240
  @output_validator = config[:output_validator]
241
+ @inline_contract = config[:inline_contract]
154
242
  @retry_config = { max_attempts: 1 }.merge(config[:retry_config] || {})
155
243
  end
156
244
 
@@ -165,6 +253,11 @@ module RubyReactor
165
253
  !@impl.nil?
166
254
  end
167
255
 
256
+ # The contract that governs this step's inputs, or nil when it has none.
257
+ def input_contract
258
+ @inline_contract || (@impl.input_contract if @impl.respond_to?(:declares_inputs?) && @impl.declares_inputs?)
259
+ end
260
+
168
261
  def has_run_block?
169
262
  !@run_block.nil?
170
263
  end
@@ -3,6 +3,8 @@
3
3
  module RubyReactor
4
4
  module Dsl
5
5
  module TemplateHelpers
6
+ include RubyReactor::StepSignals
7
+
6
8
  def input(name, path = nil)
7
9
  RubyReactor::Template::Input.new(name, path)
8
10
  end
@@ -19,7 +21,7 @@ module RubyReactor
19
21
  RubyReactor::Template::Element.new(map_name, path)
20
22
  end
21
23
 
22
- # Make Success, Failure, and Skipped available in DSL contexts
24
+ # Make Success, Failure, Halt, and Skipped available in DSL contexts
23
25
  # rubocop:disable Naming/MethodName
24
26
  def Success(value = nil)
25
27
  # rubocop:enable Naming/MethodName
@@ -27,15 +29,21 @@ module RubyReactor
27
29
  end
28
30
 
29
31
  # rubocop:disable Naming/MethodName
30
- def Failure(error)
32
+ def Failure(...)
33
+ # rubocop:enable Naming/MethodName
34
+ RubyReactor.Failure(...)
35
+ end
36
+
37
+ # rubocop:disable Naming/MethodName
38
+ def Halt(reason: nil, **kwargs)
31
39
  # rubocop:enable Naming/MethodName
32
- RubyReactor.Failure(error)
40
+ RubyReactor.Halt(reason: reason, **kwargs)
33
41
  end
34
42
 
35
43
  # rubocop:disable Naming/MethodName
36
- def Skipped(reason: nil, **kwargs)
44
+ def Skipped(...)
37
45
  # rubocop:enable Naming/MethodName
38
- RubyReactor.Skipped(reason: reason, **kwargs)
46
+ RubyReactor.Skipped(...)
39
47
  end
40
48
  end
41
49
  end
@@ -36,6 +36,23 @@ module RubyReactor
36
36
  RubyReactor::Validation::InputValidator.new(schema)
37
37
  end
38
38
 
39
+ # Dispatch across the layered `input` forms, shared by reactor inputs and
40
+ # step input contracts:
41
+ # Form 3 — pre-built schema / contract (`validate:`)
42
+ # Form 2 — block bound to the value macro (`do |i| ... end`)
43
+ # legacy — single-key schema block (`do required(:name)... end`)
44
+ # Form 1 / 1b — inline scalar or class type
45
+ # Form 0 — declaration only (no validator)
46
+ def build_declaration_validator(name, type, optional, validate, predicates, &block)
47
+ if validate
48
+ create_input_validator(validate)
49
+ elsif block
50
+ block.arity.nonzero? ? build_macro_validator(name, optional, &block) : create_input_validator(block)
51
+ elsif type || predicates.any?
52
+ build_inline_validator(name, type, optional, predicates)
53
+ end
54
+ end
55
+
39
56
  # Compose per-argument inline rules with an optional `validate_args`
40
57
  # block / pre-built schema. Returns nil when there is nothing to validate.
41
58
  def build_args_validator(inline_rules, validate_input)
@@ -28,6 +28,10 @@ module RubyReactor
28
28
  def to_s
29
29
  @message || build_message
30
30
  end
31
+
32
+ def retryable?
33
+ false
34
+ end
31
35
  end
32
36
  end
33
37
  end
@@ -3,18 +3,25 @@
3
3
  module RubyReactor
4
4
  module Error
5
5
  class StepFailureError < Base
6
- attr_reader :step_arguments, :exception_class
6
+ attr_reader :step_arguments, :exception_class, :validation_errors
7
7
 
8
8
  # rubocop:disable Metrics/ParameterLists
9
- def initialize(message, step: nil, context: nil, original_error: nil, step_arguments: {}, exception_class: nil)
9
+ def initialize(message, step: nil, context: nil, original_error: nil, step_arguments: {}, exception_class: nil,
10
+ validation_errors: nil)
10
11
  # rubocop:enable Metrics/ParameterLists
11
12
  super(message, step: step, context: context, original_error: original_error)
12
13
  @step_arguments = step_arguments
13
14
  @exception_class = exception_class
15
+ @validation_errors = validation_errors
14
16
  end
15
17
 
18
+ # Defers to the original error when it has an opinion (e.g. a step's own
19
+ # `Error::InputValidationError`, always non-retryable) so that opinion
20
+ # survives being wrapped into a StepFailureError — the same
21
+ # `respond_to?(:retryable?)` protocol `RubyReactor::Failure` itself uses.
22
+ # Defaults to `true` for an ordinary step failure, unchanged from before.
16
23
  def retryable?
17
- true
24
+ original_error.respond_to?(:retryable?) ? original_error.retryable? : true
18
25
  end
19
26
  end
20
27
  end
@@ -55,33 +55,44 @@ module RubyReactor
55
55
  @context.middlewares || RubyReactor::MiddlewareRunner.new([])
56
56
  end
57
57
 
58
+ # Ensure we have a value to log (if it's a Success/Failure object, get the value or error)
59
+ def loggable_value(result)
60
+ if result.respond_to?(:value)
61
+ result.value
62
+ elsif result.respond_to?(:error)
63
+ result.error
64
+ else
65
+ result
66
+ end
67
+ end
68
+
69
+ def skipped_result?(result)
70
+ result.respond_to?(:skipped?) && result.skipped?
71
+ end
72
+
58
73
  def compensate_step(step_config, error, arguments)
59
74
  middlewares.on(:start_compensation, step_config.name, error, arguments, @context)
60
75
  begin
61
- compensate_result = if step_config.compensate_block
62
- step_config.compensate_block.call(error, arguments, @context)
63
- elsif step_config.has_impl?
64
- step_config.impl.compensate(error, arguments, @context)
65
- else
66
- RubyReactor.Success() # Default compensation
67
- end
68
-
69
- # Ensure we have a value to log
70
- logged_result = if compensate_result.respond_to?(:value)
71
- compensate_result.value
72
- elsif compensate_result.respond_to?(:error)
73
- compensate_result.error
74
- else
75
- compensate_result
76
- end
77
-
78
- @context.execution_trace << {
79
- type: :compensate,
80
- step: step_config.name,
81
- timestamp: Time.now,
82
- result: logged_result,
83
- arguments: arguments
84
- }
76
+ compensate_result = catch(StepSignals::TAG) do
77
+ if step_config.compensate_block
78
+ step_config.compensate_block.call(error, arguments, @context)
79
+ elsif step_config.has_impl?
80
+ step_config.impl.compensate(error, arguments, @context)
81
+ else
82
+ RubyReactor.Skipped() # Default: nothing defined, rollback continues
83
+ end
84
+ end
85
+
86
+ @context.append_execution_trace(
87
+ {
88
+ type: :compensate,
89
+ step: step_config.name,
90
+ timestamp: Time.now,
91
+ result: loggable_value(compensate_result),
92
+ arguments: arguments,
93
+ skipped: skipped_result?(compensate_result)
94
+ }
95
+ )
85
96
  @undo_trace << { type: :compensation, step: step_config.name, error: error, arguments: arguments }
86
97
 
87
98
  if compensate_result.is_a?(RubyReactor::Failure)
@@ -100,25 +111,26 @@ module RubyReactor
100
111
  def undo_step(step_config, result, arguments)
101
112
  middlewares.on(:start_undo, step_config.name, result, arguments, @context)
102
113
  begin
103
- undo_result = if step_config.undo_block
104
- step_config.undo_block.call(result.value, arguments, @context)
105
- elsif step_config.has_impl?
106
- step_config.impl.undo(result.value, arguments, @context)
107
- else
108
- RubyReactor.Success()
109
- end
110
-
111
- # Ensure we have a value to log (if it's a Success/Failure object, get the value or error)
112
- logged_result = if undo_result.respond_to?(:value)
113
- undo_result.value
114
- elsif undo_result.respond_to?(:error)
115
- undo_result.error
116
- else
117
- undo_result
118
- end
119
-
120
- @context.execution_trace << { type: :undo, step: step_config.name, timestamp: Time.now, result: logged_result,
121
- arguments: arguments }
114
+ undo_result = catch(StepSignals::TAG) do
115
+ if step_config.undo_block
116
+ step_config.undo_block.call(result.value, arguments, @context)
117
+ elsif step_config.has_impl?
118
+ step_config.impl.undo(result.value, arguments, @context)
119
+ else
120
+ RubyReactor.Skipped() # Default: nothing defined, rollback continues
121
+ end
122
+ end
123
+
124
+ @context.append_execution_trace(
125
+ {
126
+ type: :undo,
127
+ step: step_config.name,
128
+ timestamp: Time.now,
129
+ result: loggable_value(undo_result),
130
+ arguments: arguments,
131
+ skipped: skipped_result?(undo_result)
132
+ }
133
+ )
122
134
 
123
135
  if undo_result.is_a?(RubyReactor::Failure)
124
136
  middlewares.on(:failed_undo, step_config.name, undo_result, @context)
@@ -130,8 +142,9 @@ module RubyReactor
130
142
  rescue StandardError => e
131
143
  middlewares.on(:failed_undo, step_config.name, e, @context)
132
144
  # Log undo failure but don't halt the rollback process
133
- @context.execution_trace << { type: :undo_failure, step: step_config.name, timestamp: Time.now,
134
- error: e.message }
145
+ @context.append_execution_trace(
146
+ { type: :undo_failure, step: step_config.name, timestamp: Time.now, error: e.message }
147
+ )
135
148
  RubyReactor.Failure(e)
136
149
  end
137
150
  end
@@ -117,14 +117,14 @@ module RubyReactor
117
117
  @ordered_lock_drained_replay == true
118
118
  end
119
119
 
120
- # Terminal Skipped result when the ordered-lock gate short-circuits this
120
+ # Terminal Halt result when the ordered-lock gate short-circuits this
121
121
  # run (stale batch, strict chain failure, or a drained-batch redelivery of
122
122
  # an already-terminal context), or nil to continue. Shared by `execute`
123
123
  # and `resume_execution`.
124
124
  def ordered_lock_short_circuit
125
- return RubyReactor::Skipped.new(reason: :ordered_lock_stale_batch) if ordered_lock_stale_batch?
126
- return RubyReactor::Skipped.new(reason: :ordered_lock_drained_replay) if ordered_lock_drained_replay?
127
- return RubyReactor::Skipped.new(reason: :ordered_lock_chain_failed) if ordered_lock_chain_skip?
125
+ return RubyReactor::Halt.new(reason: :ordered_lock_stale_batch) if ordered_lock_stale_batch?
126
+ return RubyReactor::Halt.new(reason: :ordered_lock_drained_replay) if ordered_lock_drained_replay?
127
+ return RubyReactor::Halt.new(reason: :ordered_lock_chain_failed) if ordered_lock_chain_skip?
128
128
 
129
129
  nil
130
130
  end
@@ -138,12 +138,12 @@ module RubyReactor
138
138
  def short_circuit!(result)
139
139
  @result = result
140
140
 
141
- # A stale-batch or drained-batch-redelivery skip means this run's epoch
141
+ # A stale-batch or drained-batch-redelivery halt means this run's epoch
142
142
  # belongs to a drained generation — typically a slow straggler or a
143
143
  # Sidekiq at-least-once redelivery. If the redelivery is of a job that
144
144
  # ALREADY reached a terminal status, its stored context is the source of
145
- # truth; writing :skipped over a :completed/:failed record would silently
146
- # corrupt the outcome. Return the skip to the worker (so it stops)
145
+ # truth; writing :halted over a :completed/:failed record would silently
146
+ # corrupt the outcome. Return the halt to the worker (so it stops)
147
147
  # without saving. The `@skip_context_persist` flag also suppresses the
148
148
  # ensure-block save in execute / resume_execution, which would otherwise
149
149
  # clobber the stored terminal record with this run's stale in-memory
@@ -169,14 +169,14 @@ module RubyReactor
169
169
  # set when the status was terminal, but re-checking keeps both paths
170
170
  # uniform and self-guarding.
171
171
  def redelivery_of_terminal?(result)
172
- return false unless result.is_a?(RubyReactor::Skipped)
172
+ return false unless result.is_a?(RubyReactor::Halt)
173
173
  return false unless %i[ordered_lock_stale_batch ordered_lock_drained_replay].include?(result.reason)
174
174
 
175
175
  stored_status_terminal?
176
176
  end
177
177
 
178
178
  def stored_status_terminal?
179
- %w[completed failed skipped].include?(stored_context_status)
179
+ %w[completed failed halted skipped].include?(stored_context_status)
180
180
  end
181
181
 
182
182
  def stored_context_status
@@ -14,9 +14,13 @@ module RubyReactor
14
14
 
15
15
  def handle_step_result(step_config, result, resolved_arguments)
16
16
  case result
17
+ when RubyReactor::Halt
18
+ # Important: must come before Skipped and Success — both are Halt's
19
+ # siblings under Success, and Halt takes precedence over either.
20
+ handle_halt(step_config, result)
17
21
  when RubyReactor::Skipped
18
- # Important: must come before the Success branch — Skipped < Success.
19
- handle_skipped(step_config, result)
22
+ # Must come before the Success branch — Skipped < Success.
23
+ handle_skipped(step_config, result, resolved_arguments)
20
24
  when RubyReactor::Success
21
25
  handle_success(step_config, result, resolved_arguments)
22
26
  when RubyReactor::MaxRetriesExhaustedFailure
@@ -78,19 +82,40 @@ module RubyReactor
78
82
  )
79
83
  end
80
84
 
81
- # A step returned `RubyReactor.Skipped(...)`. Halt cleanly: record the
85
+ # A step returned `RubyReactor.Halt(...)`. Halt cleanly: record the
82
86
  # event in the trace, do NOT push to the undo stack (so existing
83
87
  # completed steps stay as-is — no compensation), and stamp the step
84
88
  # name on the result so the caller can see who halted.
85
- def handle_skipped(step_config, result)
89
+ def handle_halt(step_config, result)
86
90
  @step_results[step_config.name] = result
87
91
  result.instance_variable_set(:@step_name, step_config.name) if result.step_name.nil?
88
- @context.execution_trace << {
89
- type: :skipped,
90
- step: step_config.name,
91
- timestamp: Time.now,
92
- reason: result.reason
93
- }
92
+ @context.append_execution_trace(
93
+ {
94
+ type: :halt,
95
+ step: step_config.name,
96
+ timestamp: Time.now,
97
+ reason: result.reason
98
+ }
99
+ )
100
+ result
101
+ end
102
+
103
+ # A step returned `RubyReactor.Skipped(...)`. The reactor continues:
104
+ # validate and record the value exactly like a Success, but do NOT push
105
+ # to the undo stack (nothing happened, so there is nothing to undo).
106
+ def handle_skipped(step_config, result, resolved_arguments)
107
+ validate_step_output(step_config, result.value, resolved_arguments)
108
+ @step_results[step_config.name] = result
109
+ @context.set_result(step_config.name, result.value)
110
+ @dependency_graph.complete_step(step_config.name)
111
+ @context.append_execution_trace(
112
+ {
113
+ type: :skipped,
114
+ step: step_config.name,
115
+ timestamp: Time.now,
116
+ reason: result.reason
117
+ }
118
+ )
94
119
  result
95
120
  end
96
121
 
@@ -118,7 +143,8 @@ module RubyReactor
118
143
  orig_err = result.original_error.is_a?(Exception) ? result.original_error : nil
119
144
  error = Error::StepFailureError.new(result.error, step: step_config.name, context: @context,
120
145
  original_error: orig_err,
121
- step_arguments: resolved_arguments)
146
+ step_arguments: resolved_arguments,
147
+ validation_errors: result.validation_errors)
122
148
  if result.respond_to?(:backtrace) && result.backtrace
123
149
  error.set_backtrace(result.backtrace)
124
150
  elsif orig_err
@@ -130,9 +156,12 @@ module RubyReactor
130
156
  def handle_failure(step_config, result, resolved_arguments)
131
157
  failure_result = @compensation_manager.handle_step_failure(step_config, result.error, resolved_arguments)
132
158
  orig_err = result.error.is_a?(Exception) ? result.error : nil
159
+ # A step that propagates another unit's validation failure (an
160
+ # async_step reader) keeps its field errors on the reactor's failure.
133
161
  error = Error::StepFailureError.new(failure_result.error, step: step_config.name, context: @context,
134
162
  original_error: orig_err,
135
- step_arguments: resolved_arguments)
163
+ step_arguments: resolved_arguments,
164
+ validation_errors: result.validation_errors)
136
165
  if result.respond_to?(:backtrace) && result.backtrace
137
166
  error.set_backtrace(result.backtrace)
138
167
  elsif orig_err
@@ -197,7 +226,9 @@ module RubyReactor
197
226
  exception_class: exception_class,
198
227
  file_path: file_path,
199
228
  line_number: line_number,
200
- code_snippet: code_snippet
229
+ code_snippet: code_snippet,
230
+ validation_errors: error.validation_errors,
231
+ retryable: error.retryable?
201
232
  )
202
233
  end
203
234
 
@@ -94,7 +94,10 @@ module RubyReactor
94
94
 
95
95
  def handle_retry_result(step_config, reactor_class, result)
96
96
  case result
97
- when RubyReactor::Success
97
+ when RubyReactor::Halt, RubyReactor::Skipped, RubyReactor::Success
98
+ # Halt and Skipped are Success subclasses, so they already take this
99
+ # path via inheritance; the explicit arms are readability plus a
100
+ # guard against a future hierarchy change (R5).
98
101
  clear_retry_state
99
102
  result
100
103
  when RubyReactor::Failure
@@ -182,7 +185,8 @@ module RubyReactor
182
185
  []
183
186
  end,
184
187
  reactor_name: reactor_class.name,
185
- step_arguments: result.respond_to?(:step_arguments) ? result.step_arguments : {}
188
+ step_arguments: result.respond_to?(:step_arguments) ? result.step_arguments : {},
189
+ validation_errors: result.validation_errors
186
190
  )
187
191
  end
188
192
 
@@ -37,10 +37,10 @@ module RubyReactor
37
37
  # If a step returns RetryQueuedResult, we need to stop and return it
38
38
  return result if result.is_a?(RetryQueuedResult)
39
39
 
40
- # If a step returns Skipped, halt the reactor cleanly (no
40
+ # If a step returns Halt, stop the reactor cleanly (no
41
41
  # compensation). Must be checked BEFORE Failure / Success because
42
- # Skipped is a Success subclass.
43
- return result if result.is_a?(RubyReactor::Skipped)
42
+ # Halt is a Success subclass.
43
+ return result if result.is_a?(RubyReactor::Halt)
44
44
 
45
45
  # If a step returns Failure, we need to stop execution and return it
46
46
  return result if result.is_a?(RubyReactor::Failure)
@@ -48,13 +48,17 @@ module RubyReactor
48
48
  # If a step returns InterruptResult, we need to stop execution and return it
49
49
  return result if result.is_a?(RubyReactor::InterruptResult)
50
50
 
51
- # Only a continue-Success reaches here (Async/Retry/Skipped/Failure/
52
- # Interrupt all returned above; nil is inline-async test mode). It is
53
- # the one outcome where the loop proceeds to more steps with no other
54
- # save in between — every terminal/handoff result persists via its own
55
- # path. Write a durable checkpoint so a crash re-runs at most this one
56
- # step. Ordering: side-effect -> record result (inside execute_step) ->
57
- # checkpoint here.
51
+ # A Skipped step (or a plain Success) continues the loop — Skipped
52
+ # is a Success subclass, so this also fires the durable checkpoint
53
+ # for it, same as a plain success.
54
+ #
55
+ # Only a continue-Success/Skipped reaches here (Async/Retry/Halt/
56
+ # Failure/Interrupt all returned above; nil is inline-async test
57
+ # mode). It is the one outcome where the loop proceeds to more
58
+ # steps with no other save in between — every terminal/handoff
59
+ # result persists via its own path. Write a durable checkpoint so
60
+ # a crash re-runs at most this one step. Ordering: side-effect ->
61
+ # record result (inside execute_step) -> checkpoint here.
58
62
  @on_step_complete&.call if result.is_a?(RubyReactor::Success)
59
63
  end
60
64
  end
@@ -179,9 +183,13 @@ module RubyReactor
179
183
  def safe_execute_step_sync(step_config, resolved_arguments = nil)
180
184
  resolved_arguments ||= resolve_arguments(step_config)
181
185
  execute_step_sync_without_result_handling(step_config, resolved_arguments)
182
- rescue Error::InputValidationError
186
+ rescue Error::InputValidationError => e
183
187
  # Validation failures are not retryable and must surface as a structured
184
188
  # InputValidationError (with field_errors), so let them propagate.
189
+ # A step class stamps its own class name; inside a reactor the step's
190
+ # name there is the useful attribution, so it overwrites.
191
+ e.step_name = step_config.name
192
+ e.step_arguments ||= resolved_arguments
185
193
  raise
186
194
  rescue StandardError => e
187
195
  # Identify redacted inputs
@@ -336,15 +344,20 @@ module RubyReactor
336
344
  end
337
345
 
338
346
  def run_step_implementation(step_config, arguments)
339
- @context.execution_trace << { type: :run, step: step_config.name, timestamp: Time.now, arguments: arguments }
347
+ contract = step_config.input_contract
348
+ @context.append_execution_trace(
349
+ { type: :run, step: step_config.name, timestamp: Time.now,
350
+ arguments: contract ? contract.redact(arguments) : arguments }
351
+ )
340
352
  if step_config.has_run_block?
341
353
  # Execute inline block
342
354
  # If no arguments are defined for the step, pass the reactor inputs as arguments
343
355
  args_to_pass = arguments.empty? ? @context.inputs : arguments
344
- step_config.run_block.call(args_to_pass, @context)
356
+ args_to_pass = step_config.inline_contract.enforce!(args_to_pass) if step_config.inline_contract
357
+ catch(StepSignals::TAG) { step_config.run_block.call(args_to_pass, @context) }
345
358
  elsif step_config.has_impl?
346
359
  # Execute step class
347
- step_config.impl.run(arguments, @context)
360
+ catch(StepSignals::TAG) { step_config.impl.run(arguments, @context) }
348
361
  else
349
362
  raise Error::ValidationError.new(
350
363
  "Step '#{step_config.name}' has no implementation",