ruby_reactor 0.7.1 → 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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/.specify/feature.json +1 -1
  4. data/.specify/memory/constitution.md +26 -16
  5. data/.specify/templates/plan-template.md +4 -0
  6. data/.specify/templates/tasks-template.md +1 -1
  7. data/CHANGELOG.md +140 -0
  8. data/CLAUDE.md +1 -1
  9. data/README.md +125 -31
  10. data/lib/ruby_reactor/context.rb +2 -2
  11. data/lib/ruby_reactor/dsl/interrupt_builder.rb +6 -0
  12. data/lib/ruby_reactor/dsl/reactor.rb +36 -18
  13. data/lib/ruby_reactor/dsl/step_builder.rb +95 -2
  14. data/lib/ruby_reactor/dsl/template_helpers.rb +2 -2
  15. data/lib/ruby_reactor/dsl/validation_helpers.rb +17 -0
  16. data/lib/ruby_reactor/error/input_validation_error.rb +4 -0
  17. data/lib/ruby_reactor/error/step_failure_error.rb +10 -3
  18. data/lib/ruby_reactor/executor/result_handler.rb +9 -3
  19. data/lib/ruby_reactor/executor/retry_manager.rb +2 -1
  20. data/lib/ruby_reactor/executor/step_executor.rb +9 -2
  21. data/lib/ruby_reactor/executor.rb +3 -0
  22. data/lib/ruby_reactor/max_retries_exhausted_failure.rb +3 -2
  23. data/lib/ruby_reactor/reactor.rb +9 -12
  24. data/lib/ruby_reactor/rspec/matchers.rb +3 -6
  25. data/lib/ruby_reactor/step/async_reactor_step.rb +159 -162
  26. data/lib/ruby_reactor/step/compose_step.rb +56 -75
  27. data/lib/ruby_reactor/step/input_contract.rb +128 -0
  28. data/lib/ruby_reactor/step/map_step.rb +177 -218
  29. data/lib/ruby_reactor/step.rb +116 -21
  30. data/lib/ruby_reactor/step_signals.rb +6 -2
  31. data/lib/ruby_reactor/step_worker.rb +25 -10
  32. data/lib/ruby_reactor/template/result.rb +9 -2
  33. data/lib/ruby_reactor/utils/fetch_indifferent.rb +13 -0
  34. data/lib/ruby_reactor/version.rb +1 -1
  35. data/lib/ruby_reactor.rb +5 -2
  36. data/specs/002-step-input-contracts/checklists/requirements.md +49 -0
  37. data/specs/002-step-input-contracts/contracts/dsl-surface.md +193 -0
  38. data/specs/002-step-input-contracts/data-model.md +115 -0
  39. data/specs/002-step-input-contracts/plan.md +165 -0
  40. data/specs/002-step-input-contracts/quickstart.md +170 -0
  41. data/specs/002-step-input-contracts/research.md +233 -0
  42. data/specs/002-step-input-contracts/spec.md +359 -0
  43. data/specs/002-step-input-contracts/tasks.md +367 -0
  44. data/specs/004-inheritable-step-class/checklists/requirements.md +40 -0
  45. data/specs/004-inheritable-step-class/contracts/step-lifecycle.md +85 -0
  46. data/specs/004-inheritable-step-class/data-model.md +116 -0
  47. data/specs/004-inheritable-step-class/plan.md +174 -0
  48. data/specs/004-inheritable-step-class/quickstart.md +112 -0
  49. data/specs/004-inheritable-step-class/research.md +308 -0
  50. data/specs/004-inheritable-step-class/spec.md +316 -0
  51. data/specs/004-inheritable-step-class/tasks.md +258 -0
  52. data/specs/deferred-003-step-lock-declarations/checklists/requirements.md +51 -0
  53. data/specs/deferred-003-step-lock-declarations/contracts/dsl-surface.md +154 -0
  54. data/specs/deferred-003-step-lock-declarations/data-model.md +131 -0
  55. data/specs/deferred-003-step-lock-declarations/plan.md +166 -0
  56. data/specs/deferred-003-step-lock-declarations/quickstart.md +169 -0
  57. data/specs/deferred-003-step-lock-declarations/research.md +196 -0
  58. data/specs/deferred-003-step-lock-declarations/spec.md +447 -0
  59. data/specs/deferred-003-step-lock-declarations/tasks.md +572 -0
  60. data/specs/possible_feature.md +22 -0
  61. metadata +28 -1
@@ -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
@@ -29,9 +29,9 @@ module RubyReactor
29
29
  end
30
30
 
31
31
  # rubocop:disable Naming/MethodName
32
- def Failure(error)
32
+ def Failure(...)
33
33
  # rubocop:enable Naming/MethodName
34
- RubyReactor.Failure(error)
34
+ RubyReactor.Failure(...)
35
35
  end
36
36
 
37
37
  # rubocop:disable Naming/MethodName
@@ -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
@@ -143,7 +143,8 @@ module RubyReactor
143
143
  orig_err = result.original_error.is_a?(Exception) ? result.original_error : nil
144
144
  error = Error::StepFailureError.new(result.error, step: step_config.name, context: @context,
145
145
  original_error: orig_err,
146
- step_arguments: resolved_arguments)
146
+ step_arguments: resolved_arguments,
147
+ validation_errors: result.validation_errors)
147
148
  if result.respond_to?(:backtrace) && result.backtrace
148
149
  error.set_backtrace(result.backtrace)
149
150
  elsif orig_err
@@ -155,9 +156,12 @@ module RubyReactor
155
156
  def handle_failure(step_config, result, resolved_arguments)
156
157
  failure_result = @compensation_manager.handle_step_failure(step_config, result.error, resolved_arguments)
157
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.
158
161
  error = Error::StepFailureError.new(failure_result.error, step: step_config.name, context: @context,
159
162
  original_error: orig_err,
160
- step_arguments: resolved_arguments)
163
+ step_arguments: resolved_arguments,
164
+ validation_errors: result.validation_errors)
161
165
  if result.respond_to?(:backtrace) && result.backtrace
162
166
  error.set_backtrace(result.backtrace)
163
167
  elsif orig_err
@@ -222,7 +226,9 @@ module RubyReactor
222
226
  exception_class: exception_class,
223
227
  file_path: file_path,
224
228
  line_number: line_number,
225
- code_snippet: code_snippet
229
+ code_snippet: code_snippet,
230
+ validation_errors: error.validation_errors,
231
+ retryable: error.retryable?
226
232
  )
227
233
  end
228
234
 
@@ -185,7 +185,8 @@ module RubyReactor
185
185
  []
186
186
  end,
187
187
  reactor_name: reactor_class.name,
188
- 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
189
190
  )
190
191
  end
191
192
 
@@ -183,9 +183,13 @@ module RubyReactor
183
183
  def safe_execute_step_sync(step_config, resolved_arguments = nil)
184
184
  resolved_arguments ||= resolve_arguments(step_config)
185
185
  execute_step_sync_without_result_handling(step_config, resolved_arguments)
186
- rescue Error::InputValidationError
186
+ rescue Error::InputValidationError => e
187
187
  # Validation failures are not retryable and must surface as a structured
188
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
189
193
  raise
190
194
  rescue StandardError => e
191
195
  # Identify redacted inputs
@@ -340,13 +344,16 @@ module RubyReactor
340
344
  end
341
345
 
342
346
  def run_step_implementation(step_config, arguments)
347
+ contract = step_config.input_contract
343
348
  @context.append_execution_trace(
344
- { type: :run, step: step_config.name, timestamp: Time.now, arguments: arguments }
349
+ { type: :run, step: step_config.name, timestamp: Time.now,
350
+ arguments: contract ? contract.redact(arguments) : arguments }
345
351
  )
346
352
  if step_config.has_run_block?
347
353
  # Execute inline block
348
354
  # If no arguments are defined for the step, pass the reactor inputs as arguments
349
355
  args_to_pass = arguments.empty? ? @context.inputs : arguments
356
+ args_to_pass = step_config.inline_contract.enforce!(args_to_pass) if step_config.inline_contract
350
357
  catch(StepSignals::TAG) { step_config.run_block.call(args_to_pass, @context) }
351
358
  elsif step_config.has_impl?
352
359
  # Execute step class
@@ -19,6 +19,9 @@ module RubyReactor
19
19
  :step_executor, :result, :middlewares
20
20
 
21
21
  def initialize(reactor_class, inputs = {}, context = nil)
22
+ # Resume, map, compose and background workers build an Executor without
23
+ # going through Reactor#run; the inferred wiring must exist there too.
24
+ reactor_class.validate_definition! if reactor_class.respond_to?(:validate_definition!)
22
25
  @reactor_class = reactor_class
23
26
  @context = context || Context.new(inputs, reactor_class)
24
27
  @middlewares = Executor.middlewares_for(reactor_class)
@@ -7,11 +7,12 @@ module RubyReactor
7
7
  # rubocop:disable Metrics/ParameterLists
8
8
  def initialize(message, step:, attempts:, original_error: nil,
9
9
  inputs: {}, backtrace: nil, redact_inputs: [],
10
- reactor_name: nil, step_arguments: {})
10
+ reactor_name: nil, step_arguments: {}, validation_errors: nil)
11
11
  # rubocop:enable Metrics/ParameterLists
12
12
  super(message,
13
13
  step_name: step, inputs: inputs, backtrace: backtrace,
14
- redact_inputs: redact_inputs, reactor_name: reactor_name, step_arguments: step_arguments)
14
+ redact_inputs: redact_inputs, reactor_name: reactor_name, step_arguments: step_arguments,
15
+ validation_errors: validation_errors)
15
16
  @attempts = attempts
16
17
  @original_error = original_error
17
18
  end
@@ -84,21 +84,17 @@ module RubyReactor
84
84
  end
85
85
  end
86
86
 
87
- # rubocop:disable Metrics/MethodLength
88
87
  def run(inputs = {})
88
+ # Before the context exists, so an incomplete definition never saves one.
89
+ self.class.validate_definition!
90
+
89
91
  # For all reactors, initialize context first to capture execution ID
90
92
  @context = @context.is_a?(Context) ? @context : Context.new(inputs, self.class)
91
93
 
92
94
  # Validate inputs
93
95
  validation_result = self.class.validate_inputs(inputs)
94
96
  if validation_result.failure?
95
- @result = validation_result
96
- @context.status = "failed"
97
- @context.failure_reason = {
98
- message: validation_result.error.message,
99
- validation_errors: validation_result.error.field_errors
100
- }
101
- save_context
97
+ handle_validation_failure(validation_result)
102
98
  return validation_result
103
99
  end
104
100
 
@@ -144,7 +140,6 @@ module RubyReactor
144
140
  end
145
141
  @result
146
142
  end
147
- # rubocop:enable Metrics/MethodLength, Metrics/AbcSize
148
143
 
149
144
  def continue(payload:, step_name:, idempotency_key: nil)
150
145
  _ = idempotency_key
@@ -270,8 +265,9 @@ module RubyReactor
270
265
  reason = @context.failure_reason || {}
271
266
  return reason if reason.is_a?(RubyReactor::Failure)
272
267
 
273
- # Use string keys preferred, fallback to symbol
274
- r = ->(k) { reason[k.to_s] || reason[k.to_sym] }
268
+ # Presence-aware: a stored `retryable: false` must not be swallowed by an
269
+ # `||` fallback and silently default back to retryable.
270
+ r = ->(k) { Utils::FetchIndifferent.call(reason, k) }
275
271
 
276
272
  Failure.new(
277
273
  r[:message],
@@ -314,7 +310,8 @@ module RubyReactor
314
310
  @context.status = "failed"
315
311
  @context.failure_reason = {
316
312
  message: result.error.message,
317
- validation_errors: result.error.field_errors
313
+ validation_errors: result.error.field_errors,
314
+ retryable: result.retryable?
318
315
  }
319
316
  save_context
320
317
  end
@@ -156,12 +156,9 @@ module RubyReactor
156
156
  subject.ensure_executed!
157
157
  return false unless subject.failure?
158
158
 
159
- # Try to get validation errors from failure reason
160
- reason = subject.reactor_instance.context.failure_reason || {}
161
-
162
- # If failure is InputValidationError, it might be serialized differently
163
- # Or stored in validation_errors key
164
- errors = reason["validation_errors"] || reason[:validation_errors]
159
+ # `result` normalizes both failure shapes a reactor-input failure
160
+ # stored as a hash, and a step failure stored as a Failure.
161
+ errors = subject.result.validation_errors
165
162
 
166
163
  if errors
167
164
  errors.key?(field.to_s) || errors.key?(field.to_sym)