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.
- checksums.yaml +4 -4
- data/.claude/skills/demo-app-e2e-verify/SKILL.md +226 -0
- data/.claude/skills/speckit-demo-tests/SKILL.md +144 -0
- data/.release-please-manifest.json +1 -1
- data/.specify/feature.json +1 -1
- data/.specify/memory/constitution.md +92 -15
- data/.specify/templates/plan-template.md +4 -0
- data/.specify/templates/tasks-template.md +8 -1
- data/CHANGELOG.md +151 -0
- data/CLAUDE.md +2 -2
- data/README.md +149 -40
- data/lib/ruby_reactor/context.rb +9 -2
- data/lib/ruby_reactor/context_serializer.rb +13 -0
- data/lib/ruby_reactor/dsl/interrupt_builder.rb +6 -0
- data/lib/ruby_reactor/dsl/lockable.rb +2 -2
- data/lib/ruby_reactor/dsl/reactor.rb +36 -18
- data/lib/ruby_reactor/dsl/step_builder.rb +95 -2
- data/lib/ruby_reactor/dsl/template_helpers.rb +13 -5
- data/lib/ruby_reactor/dsl/validation_helpers.rb +17 -0
- data/lib/ruby_reactor/error/input_validation_error.rb +4 -0
- data/lib/ruby_reactor/error/step_failure_error.rb +10 -3
- data/lib/ruby_reactor/executor/compensation_manager.rb +58 -45
- data/lib/ruby_reactor/executor/ordered_lock_support.rb +9 -9
- data/lib/ruby_reactor/executor/result_handler.rb +44 -13
- data/lib/ruby_reactor/executor/retry_manager.rb +6 -2
- data/lib/ruby_reactor/executor/step_executor.rb +27 -14
- data/lib/ruby_reactor/executor.rb +20 -15
- data/lib/ruby_reactor/map/element_executor.rb +7 -1
- data/lib/ruby_reactor/map/helpers.rb +9 -7
- data/lib/ruby_reactor/map/result_enumerator.rb +2 -0
- data/lib/ruby_reactor/map/sweeper.rb +1 -1
- data/lib/ruby_reactor/max_retries_exhausted_failure.rb +3 -2
- data/lib/ruby_reactor/open_telemetry.rb +7 -4
- data/lib/ruby_reactor/ordered_lock.rb +3 -3
- data/lib/ruby_reactor/reactor.rb +9 -12
- data/lib/ruby_reactor/rspec/matchers.rb +64 -17
- data/lib/ruby_reactor/rspec/test_subject.rb +8 -8
- data/lib/ruby_reactor/step/async_reactor_step.rb +159 -162
- data/lib/ruby_reactor/step/compose_step.rb +56 -75
- data/lib/ruby_reactor/step/input_contract.rb +128 -0
- data/lib/ruby_reactor/step/map_step.rb +178 -215
- data/lib/ruby_reactor/step.rb +119 -18
- data/lib/ruby_reactor/step_signals.rb +37 -0
- data/lib/ruby_reactor/step_worker.rb +25 -10
- data/lib/ruby_reactor/storage/adapter.rb +4 -0
- data/lib/ruby_reactor/storage/redis_adapter.rb +1 -72
- data/lib/ruby_reactor/storage/redis_reactor_scan.rb +116 -0
- data/lib/ruby_reactor/template/result.rb +9 -2
- data/lib/ruby_reactor/utils/fetch_indifferent.rb +13 -0
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor/web/api.rb +23 -6
- data/lib/ruby_reactor/web/public/assets/index-BQvIWPdx.css +1 -0
- data/lib/ruby_reactor/web/public/assets/index-Dw4KV4QY.js +22 -0
- data/lib/ruby_reactor/web/public/index.html +2 -2
- data/lib/ruby_reactor.rb +61 -9
- data/specs/002-step-input-contracts/checklists/requirements.md +49 -0
- data/specs/002-step-input-contracts/contracts/dsl-surface.md +193 -0
- data/specs/002-step-input-contracts/data-model.md +115 -0
- data/specs/002-step-input-contracts/plan.md +165 -0
- data/specs/002-step-input-contracts/quickstart.md +170 -0
- data/specs/002-step-input-contracts/research.md +233 -0
- data/specs/002-step-input-contracts/spec.md +359 -0
- data/specs/002-step-input-contracts/tasks.md +367 -0
- data/specs/004-inheritable-step-class/checklists/requirements.md +40 -0
- data/specs/004-inheritable-step-class/contracts/step-lifecycle.md +85 -0
- data/specs/004-inheritable-step-class/data-model.md +116 -0
- data/specs/004-inheritable-step-class/plan.md +174 -0
- data/specs/004-inheritable-step-class/quickstart.md +112 -0
- data/specs/004-inheritable-step-class/research.md +308 -0
- data/specs/004-inheritable-step-class/spec.md +316 -0
- data/specs/004-inheritable-step-class/tasks.md +258 -0
- data/specs/deferred-003-step-lock-declarations/checklists/requirements.md +51 -0
- data/specs/deferred-003-step-lock-declarations/contracts/dsl-surface.md +154 -0
- data/specs/deferred-003-step-lock-declarations/data-model.md +131 -0
- data/specs/deferred-003-step-lock-declarations/plan.md +166 -0
- data/specs/deferred-003-step-lock-declarations/quickstart.md +169 -0
- data/specs/deferred-003-step-lock-declarations/research.md +196 -0
- data/specs/deferred-003-step-lock-declarations/spec.md +447 -0
- data/specs/deferred-003-step-lock-declarations/tasks.md +572 -0
- data/specs/possible_feature.md +22 -0
- metadata +34 -11
- data/lib/ruby_reactor/web/public/assets/index-B46p-M6K.css +0 -1
- data/lib/ruby_reactor/web/public/assets/index-DPmP4yXT.js +0 -22
- data/specs/001-background-async-steps/checklists/requirements.md +0 -39
- data/specs/001-background-async-steps/contracts/public-dsl.md +0 -154
- data/specs/001-background-async-steps/data-model.md +0 -117
- data/specs/001-background-async-steps/plan.md +0 -168
- data/specs/001-background-async-steps/quickstart.md +0 -102
- data/specs/001-background-async-steps/research.md +0 -150
- data/specs/001-background-async-steps/spec.md +0 -146
- data/specs/001-background-async-steps/tasks.md +0 -271
|
@@ -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)
|
|
@@ -110,9 +113,9 @@ module RubyReactor
|
|
|
110
113
|
# is a fast path; this one closes the race where two callers both passed
|
|
111
114
|
# it and then serialized on the lock — without it the second caller would
|
|
112
115
|
# re-run work the first already marked. (No-op when no lock is configured.)
|
|
113
|
-
if (
|
|
116
|
+
if (halted = check_period_gate)
|
|
114
117
|
completed = true
|
|
115
|
-
return
|
|
118
|
+
return finalize_halt(halted)
|
|
116
119
|
end
|
|
117
120
|
|
|
118
121
|
@context.status = :running
|
|
@@ -218,9 +221,9 @@ module RubyReactor
|
|
|
218
221
|
|
|
219
222
|
# Post-lock re-check (see execute) — closes the period race for the
|
|
220
223
|
# first run of a locked async reactor.
|
|
221
|
-
if first_run && (
|
|
224
|
+
if first_run && (halted = check_period_gate)
|
|
222
225
|
completed = true
|
|
223
|
-
return
|
|
226
|
+
return finalize_halt(halted)
|
|
224
227
|
end
|
|
225
228
|
|
|
226
229
|
prepare_for_resume
|
|
@@ -393,16 +396,16 @@ module RubyReactor
|
|
|
393
396
|
@context.current_step.nil? && @context.intermediate_results.empty?
|
|
394
397
|
end
|
|
395
398
|
|
|
396
|
-
# Record and persist a
|
|
399
|
+
# Record and persist a Halt result, then return it. Shared by the
|
|
397
400
|
# pre-lock and post-lock period gates in both execute and resume.
|
|
398
|
-
def
|
|
399
|
-
@result =
|
|
401
|
+
def finalize_halt(halted)
|
|
402
|
+
@result = halted
|
|
400
403
|
update_context_status(@result)
|
|
401
404
|
save_context
|
|
402
405
|
@result
|
|
403
406
|
end
|
|
404
407
|
|
|
405
|
-
# Returns a
|
|
408
|
+
# Returns a Halt result if the period bucket is already marked, else nil.
|
|
406
409
|
# Consulted before AND after lock acquisition on a first execution; genuine
|
|
407
410
|
# resumes never re-check (a paused run must not skip itself when its own
|
|
408
411
|
# marker eventually appears).
|
|
@@ -413,13 +416,13 @@ module RubyReactor
|
|
|
413
416
|
key = period_key(config)
|
|
414
417
|
return nil unless RubyReactor.configuration.storage_adapter.period_seen?(key)
|
|
415
418
|
|
|
416
|
-
RubyReactor::
|
|
419
|
+
RubyReactor::Halt.new(reason: :period, period_key: key)
|
|
417
420
|
end
|
|
418
421
|
|
|
419
422
|
def mark_period_on_success(result)
|
|
420
423
|
return unless @reactor_class.respond_to?(:period_config) && @reactor_class.period_config
|
|
421
424
|
return unless result.is_a?(RubyReactor::Success)
|
|
422
|
-
return if result.is_a?(RubyReactor::
|
|
425
|
+
return if result.is_a?(RubyReactor::Halt)
|
|
423
426
|
|
|
424
427
|
config = @reactor_class.period_config
|
|
425
428
|
ttl = RubyReactor::Period.ttl_seconds(config[:every])
|
|
@@ -662,8 +665,8 @@ module RubyReactor
|
|
|
662
665
|
case result
|
|
663
666
|
when RubyReactor::DispatchResult
|
|
664
667
|
@context.status = :running
|
|
665
|
-
when RubyReactor::
|
|
666
|
-
@context.status = :
|
|
668
|
+
when RubyReactor::Halt
|
|
669
|
+
@context.status = :halted
|
|
667
670
|
when RubyReactor::Success
|
|
668
671
|
@context.status = :completed
|
|
669
672
|
when RubyReactor::Failure
|
|
@@ -696,14 +699,16 @@ module RubyReactor
|
|
|
696
699
|
@result = @step_executor.execute_all_steps
|
|
697
700
|
else
|
|
698
701
|
case result
|
|
699
|
-
#
|
|
702
|
+
# Halt must be listed before Success (Halt < Success) so the
|
|
700
703
|
# halt path wins over the "continue with remaining steps" path.
|
|
701
|
-
|
|
704
|
+
# Skipped is NOT listed here — it is a Success subclass and must
|
|
705
|
+
# continue with the remaining steps, same as a plain Success.
|
|
706
|
+
when RubyReactor::Halt,
|
|
702
707
|
RetryQueuedResult,
|
|
703
708
|
RubyReactor::Failure,
|
|
704
709
|
RubyReactor::DispatchResult,
|
|
705
710
|
RubyReactor::InterruptResult
|
|
706
|
-
# Terminal: step
|
|
711
|
+
# Terminal: step halted, requeued, failed, paused, or handed
|
|
707
712
|
# off to async. Return the result as-is.
|
|
708
713
|
@result = result
|
|
709
714
|
when RubyReactor::Success
|
|
@@ -141,7 +141,13 @@ module RubyReactor
|
|
|
141
141
|
index = arguments[:index]
|
|
142
142
|
parent_class = arguments[:parent_reactor_class_name] # Using short name for variable
|
|
143
143
|
|
|
144
|
-
if result.
|
|
144
|
+
if result.halted?
|
|
145
|
+
# A Halt must not be collected as a (nil) value indistinguishable
|
|
146
|
+
# from an ordinary success — mark it so the enumerator reconstructs
|
|
147
|
+
# a real Halt for the consumer.
|
|
148
|
+
storage.store_map_result(map_id, index, { _halt: true, reason: result.reason },
|
|
149
|
+
parent_class, strict_ordering: arguments[:strict_ordering])
|
|
150
|
+
elsif result.success?
|
|
145
151
|
storage.store_map_result(map_id, index, ContextSerializer.serialize_value(result.value),
|
|
146
152
|
parent_class, strict_ordering: arguments[:strict_ordering])
|
|
147
153
|
else
|
|
@@ -96,13 +96,15 @@ module RubyReactor
|
|
|
96
96
|
# Manually update execution trace to reflect completion
|
|
97
97
|
# This is necessary because resume_execution continues from the NEXT step
|
|
98
98
|
# and the async step (which returned DispatchResult) needs to be marked as done with actual value
|
|
99
|
-
parent_context.
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
99
|
+
parent_context.append_execution_trace(
|
|
100
|
+
{
|
|
101
|
+
type: :result,
|
|
102
|
+
step: step_name_sym,
|
|
103
|
+
timestamp: Time.now,
|
|
104
|
+
value: final_result.value,
|
|
105
|
+
status: :success
|
|
106
|
+
}
|
|
107
|
+
)
|
|
106
108
|
|
|
107
109
|
parent_context.current_step = nil
|
|
108
110
|
executor.resume_execution
|
|
@@ -101,6 +101,8 @@ module RubyReactor
|
|
|
101
101
|
# whoever happened to materialize the enumerator (the JSON encoder, in
|
|
102
102
|
# the dashboard's case) instead of the stack of the step that failed.
|
|
103
103
|
RubyReactor::Failure.new(result["_error"], backtrace: [])
|
|
104
|
+
elsif result.is_a?(Hash) && result.key?("_halt")
|
|
105
|
+
RubyReactor::Halt.new(reason: result["reason"])
|
|
104
106
|
else
|
|
105
107
|
RubyReactor::Success.new(ContextSerializer.deserialize_value(result))
|
|
106
108
|
end
|
|
@@ -92,7 +92,7 @@ module RubyReactor
|
|
|
92
92
|
|
|
93
93
|
results = data["intermediate_results"] || {}
|
|
94
94
|
status = data["status"].to_s
|
|
95
|
-
results.key?(meta["step_name"].to_s) || %w[completed failed skipped].include?(status)
|
|
95
|
+
results.key?(meta["step_name"].to_s) || %w[completed failed halted skipped].include?(status)
|
|
96
96
|
end
|
|
97
97
|
|
|
98
98
|
def retrigger_collector(meta)
|
|
@@ -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
|
|
@@ -502,9 +502,9 @@ module RubyReactor
|
|
|
502
502
|
|
|
503
503
|
case result
|
|
504
504
|
when RubyReactor::Success
|
|
505
|
-
if result.
|
|
506
|
-
span.set_attribute("reactor.status", "
|
|
507
|
-
span.set_attribute("reactor.
|
|
505
|
+
if result.halted?
|
|
506
|
+
span.set_attribute("reactor.status", "halted")
|
|
507
|
+
span.set_attribute("reactor.halt_reason", result.reason.to_s)
|
|
508
508
|
span.status = ::OpenTelemetry::Trace::Status.ok
|
|
509
509
|
else
|
|
510
510
|
span.set_attribute("reactor.status", "completed")
|
|
@@ -578,7 +578,10 @@ module RubyReactor
|
|
|
578
578
|
|
|
579
579
|
case result
|
|
580
580
|
when RubyReactor::Success
|
|
581
|
-
if result.
|
|
581
|
+
if result.halted?
|
|
582
|
+
span.set_attribute("step.status", "halted")
|
|
583
|
+
span.set_attribute("step.halt_reason", result.reason.to_s)
|
|
584
|
+
elsif result.skipped?
|
|
582
585
|
span.set_attribute("step.status", "skipped")
|
|
583
586
|
span.set_attribute("step.skipped_reason", result.reason.to_s)
|
|
584
587
|
else
|
|
@@ -62,11 +62,11 @@ module RubyReactor
|
|
|
62
62
|
# via the stored context status.
|
|
63
63
|
# - `:skip_chain_failed` — only in strict mode: an earlier nonce in this
|
|
64
64
|
# sequence terminated with a Failure, so this run is short-circuited
|
|
65
|
-
# with `
|
|
65
|
+
# with `Halt(reason: :ordered_lock_chain_failed)` without executing.
|
|
66
66
|
# - `:stale_batch` — this run's epoch no longer matches the key's current
|
|
67
67
|
# generation: its batch fully drained and the numbering was reused by a
|
|
68
68
|
# newer batch. The run is short-circuited with
|
|
69
|
-
# `
|
|
69
|
+
# `Halt(reason: :ordered_lock_stale_batch)` and must not participate.
|
|
70
70
|
# - `:poison_advance` is collapsed to `:go` from the caller's perspective.
|
|
71
71
|
def check!
|
|
72
72
|
raise ArgumentError, "OrderedLock#check! requires a nonce" unless @nonce
|
|
@@ -111,7 +111,7 @@ module RubyReactor
|
|
|
111
111
|
#
|
|
112
112
|
# `failed:` records this nonce as the chain-failure marker (only the FIRST
|
|
113
113
|
# failure sticks). In strict mode the marker causes subsequent nonces to
|
|
114
|
-
# short-circuit with
|
|
114
|
+
# short-circuit with Halt.
|
|
115
115
|
def advance!(failed: false)
|
|
116
116
|
raise ArgumentError, "OrderedLock#advance! requires a nonce" unless @nonce
|
|
117
117
|
|
data/lib/ruby_reactor/reactor.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
#
|
|
274
|
-
|
|
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
|
-
#
|
|
160
|
-
|
|
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)
|
|
@@ -264,18 +261,18 @@ module RubyReactor
|
|
|
264
261
|
RubyReactor.configuration.storage_adapter
|
|
265
262
|
end
|
|
266
263
|
|
|
267
|
-
# Distinguishes `RubyReactor::
|
|
268
|
-
# any object with a `
|
|
264
|
+
# Distinguishes `RubyReactor::Halt` (a clean halt) from a plain
|
|
265
|
+
# `Success`. Works on any object with a `halted?` predicate.
|
|
269
266
|
#
|
|
270
267
|
# Examples:
|
|
271
|
-
# expect(result).to
|
|
272
|
-
# expect(result).to
|
|
273
|
-
# expect(result).to
|
|
274
|
-
::RSpec::Matchers.define :
|
|
268
|
+
# expect(result).to be_halted
|
|
269
|
+
# expect(result).to be_halted.because(:period)
|
|
270
|
+
# expect(result).to be_halted.at_step(:second)
|
|
271
|
+
::RSpec::Matchers.define :be_halted do
|
|
275
272
|
match do |subject|
|
|
276
273
|
subject.ensure_executed! if subject.respond_to?(:ensure_executed!)
|
|
277
274
|
actual = subject.respond_to?(:result) ? subject.result : subject
|
|
278
|
-
next false unless actual.respond_to?(:
|
|
275
|
+
next false unless actual.respond_to?(:halted?) && actual.halted?
|
|
279
276
|
next false if @expected_reason && actual.reason != @expected_reason
|
|
280
277
|
next false if @expected_step && actual.step_name != @expected_step
|
|
281
278
|
|
|
@@ -292,12 +289,62 @@ module RubyReactor
|
|
|
292
289
|
|
|
293
290
|
failure_message do |subject|
|
|
294
291
|
actual = subject.respond_to?(:result) ? subject.result : subject
|
|
295
|
-
if !actual.respond_to?(:
|
|
296
|
-
"expected result to be
|
|
292
|
+
if !actual.respond_to?(:halted?) || !actual.halted?
|
|
293
|
+
"expected result to be Halt, got #{actual.class}"
|
|
297
294
|
elsif @expected_reason && actual.reason != @expected_reason
|
|
298
|
-
"expected
|
|
295
|
+
"expected Halt reason #{@expected_reason.inspect}, got #{actual.reason.inspect}"
|
|
296
|
+
else
|
|
297
|
+
"expected Halt at_step #{@expected_step.inspect}, got #{actual.step_name.inspect}"
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
failure_message_when_negated do
|
|
302
|
+
"expected result not to be Halt"
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# Asserts that a STEP was skipped — either the subject itself is a
|
|
307
|
+
# `RubyReactor::Skipped` result, or the execution trace records a
|
|
308
|
+
# `:skipped` entry (optionally for a specific step via `.at_step`). A
|
|
309
|
+
# halted run never satisfies this — halting and skipping are distinct.
|
|
310
|
+
#
|
|
311
|
+
# Examples:
|
|
312
|
+
# expect(result).to be_skipped
|
|
313
|
+
# expect(subject).to be_skipped.at_step(:maybe_sync)
|
|
314
|
+
::RSpec::Matchers.define :be_skipped do
|
|
315
|
+
match do |subject|
|
|
316
|
+
subject.ensure_executed! if subject.respond_to?(:ensure_executed!)
|
|
317
|
+
actual = subject.respond_to?(:result) ? subject.result : subject
|
|
318
|
+
next false if actual.respond_to?(:halted?) && actual.halted?
|
|
319
|
+
|
|
320
|
+
entries = skipped_trace_entries(subject)
|
|
321
|
+
next entries.any? { |e| e[:step].to_s == @expected_step.to_s } if @expected_step
|
|
322
|
+
next true if entries.any?
|
|
323
|
+
|
|
324
|
+
actual.respond_to?(:skipped?) && actual.skipped?
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
chain :at_step do |step|
|
|
328
|
+
@expected_step = step
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def skipped_trace_entries(subject)
|
|
332
|
+
trace = if subject.respond_to?(:reactor_instance)
|
|
333
|
+
subject.reactor_instance.context.execution_trace
|
|
334
|
+
elsif subject.respond_to?(:execution_trace)
|
|
335
|
+
subject.execution_trace
|
|
336
|
+
else
|
|
337
|
+
[]
|
|
338
|
+
end
|
|
339
|
+
trace.select { |e| e[:type].to_s == "skipped" }
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
failure_message do |subject|
|
|
343
|
+
actual = subject.respond_to?(:result) ? subject.result : subject
|
|
344
|
+
if @expected_step
|
|
345
|
+
"expected a skipped step named #{@expected_step.inspect}, found none in the execution trace"
|
|
299
346
|
else
|
|
300
|
-
"expected
|
|
347
|
+
"expected result to be Skipped, got #{actual.class}"
|
|
301
348
|
end
|
|
302
349
|
end
|
|
303
350
|
|
|
@@ -300,8 +300,8 @@ module RubyReactor
|
|
|
300
300
|
end
|
|
301
301
|
end
|
|
302
302
|
RubyReactor::Success.new(val)
|
|
303
|
-
when "skipped"
|
|
304
|
-
|
|
303
|
+
when "halted", "skipped" # "skipped" is the legacy name for a halt
|
|
304
|
+
halted_result(ctx)
|
|
305
305
|
when "running"
|
|
306
306
|
# Try to determine if it is truly running or if we just missed the completion
|
|
307
307
|
if @process_jobs && AsyncTestHelpers.active?
|
|
@@ -326,14 +326,14 @@ module RubyReactor
|
|
|
326
326
|
end
|
|
327
327
|
|
|
328
328
|
# A clean halt: either a `with_period` gate or a step returning
|
|
329
|
-
# `RubyReactor.
|
|
330
|
-
#
|
|
329
|
+
# `RubyReactor.Halt(...)`. The sync run already produced the exact
|
|
330
|
+
# Halt (reason/step intact) — surface it. For async runs the worker
|
|
331
331
|
# swallows the return value, so rebuild from the trace.
|
|
332
|
-
def
|
|
333
|
-
return @run_result if @run_result.is_a?(RubyReactor::
|
|
332
|
+
def halted_result(ctx)
|
|
333
|
+
return @run_result if @run_result.is_a?(RubyReactor::Halt)
|
|
334
334
|
|
|
335
|
-
entry = ctx.execution_trace.reverse.find { |t| t[:type].to_s == "
|
|
336
|
-
RubyReactor::
|
|
335
|
+
entry = ctx.execution_trace.reverse.find { |t| t[:type].to_s == "halt" }
|
|
336
|
+
RubyReactor::Halt.new(reason: entry&.dig(:reason), step_name: entry&.dig(:step))
|
|
337
337
|
end
|
|
338
338
|
|
|
339
339
|
def success?
|