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
@@ -1,36 +1,137 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyReactor
4
- module Step
5
- def self.included(base)
6
- base.extend(ClassMethods)
4
+ # The single inheritable base every class-based step derives from:
5
+ #
6
+ # class MyStep < RubyReactor::Step
7
+ # input :amount, :integer
8
+ # def run = Success(charged: inputs[:amount])
9
+ # end
10
+ #
11
+ # Lifecycle of every class-level call (`.run`/`.call`, `.undo`, `.compensate`):
12
+ #
13
+ # 1. Resolve `inputs`: the given arguments with the contract's defaults
14
+ # applied. `run`, `undo`, and `compensate` all see the same values.
15
+ # 2. `.run` ONLY: enforce the declared input contract, raising
16
+ # `Error::InputValidationError` before any instance exists. `.undo` and
17
+ # `.compensate` NEVER enforce it: rollback must not fail on the very
18
+ # inputs that may have caused the failure.
19
+ # 3. Build a FRESH instance, never reused across actions. An ivar set in
20
+ # `run` is gone by the time `undo` runs on its own instance, so async
21
+ # execution running `run` and `undo` in different processes behaves
22
+ # identically to running both in one.
23
+ # 4. Invoke the matching instance method, translating any `StepSignals`
24
+ # throw (`success!`/`skip!`/`fail!`/`halt!`) into its result wrapper.
25
+ #
26
+ # No `prepend`/`extend`/`define_method`/`method_missing` — every step in the
27
+ # class reads top to bottom as ordinary method calls.
28
+ class Step
29
+ include RubyReactor::StepSignals
30
+
31
+ attr_reader :inputs, :context, :result, :reason
32
+
33
+ def initialize(inputs, context, result: nil, reason: nil)
34
+ @inputs = inputs
35
+ @context = context
36
+ @result = result
37
+ @reason = reason
38
+ end
39
+
40
+ def run
41
+ raise NotImplementedError, "#{self.class} must implement #run"
42
+ end
43
+
44
+ def undo
45
+ RubyReactor.Skipped()
46
+ end
47
+
48
+ def compensate
49
+ RubyReactor.Skipped()
50
+ end
51
+
52
+ # rubocop:disable Naming/MethodName
53
+ def Success(value = nil)
54
+ RubyReactor.Success(value)
55
+ end
56
+
57
+ def Failure(...)
58
+ RubyReactor.Failure(...)
59
+ end
60
+
61
+ def Halt(reason: nil, **kwargs)
62
+ RubyReactor.Halt(reason: reason, **kwargs)
63
+ end
64
+
65
+ def Skipped(...)
66
+ RubyReactor.Skipped(...)
7
67
  end
68
+ # rubocop:enable Naming/MethodName
8
69
 
9
- module ClassMethods
10
- # rubocop:disable Naming/MethodName
11
- def Success(value = nil)
12
- RubyReactor::Success(value)
70
+ class << self
71
+ def run(arguments, context)
72
+ validated = enforce_contract!(arguments)
73
+ catch(StepSignals::TAG) { new(validated, context).run }
13
74
  end
75
+ alias call run
14
76
 
15
- def Failure(error = nil)
16
- RubyReactor::Failure(error)
77
+ # Same `inputs` as `.run` (defaults applied), but NEVER enforces the contract.
78
+ def undo(result, arguments, context)
79
+ catch(StepSignals::TAG) { new(with_defaults(arguments), context, result: result).undo }
17
80
  end
18
81
 
19
- def Skipped(reason: nil, **kwargs)
20
- RubyReactor.Skipped(reason: reason, **kwargs)
82
+ # Same `inputs` as `.run` (defaults applied), but NEVER enforces the contract.
83
+ def compensate(reason, arguments, context)
84
+ catch(StepSignals::TAG) { new(with_defaults(arguments), context, reason: reason).compensate }
21
85
  end
22
- # rubocop:enable Naming/MethodName
23
86
 
24
- def run(arguments, context)
25
- raise NotImplementedError, "#{self} must implement .run method"
87
+ def input(...)
88
+ own_input_contract.input(...)
89
+ @input_contract = nil
90
+ end
91
+
92
+ def validate_inputs(...)
93
+ own_input_contract.validate_inputs(...)
94
+ @input_contract = nil
95
+ end
96
+
97
+ def input_contract
98
+ @input_contract ||=
99
+ if superclass.respond_to?(:declares_inputs?) && superclass.declares_inputs?
100
+ superclass.input_contract.merge(own_input_contract)
101
+ else
102
+ own_input_contract
103
+ end
104
+ end
105
+
106
+ def declared_inputs
107
+ input_contract.declarations
26
108
  end
27
109
 
28
- def compensate(_reason, _arguments, _context)
29
- RubyReactor.Success() # Default: accept failure and continue rollback
110
+ def required_input_names
111
+ input_contract.required_names
30
112
  end
31
113
 
32
- def undo(_result, _arguments, _context)
33
- RubyReactor.Success() # Default: no-op undo
114
+ def declares_inputs?
115
+ !input_contract.empty?
116
+ end
117
+
118
+ private
119
+
120
+ def own_input_contract
121
+ @own_input_contract ||= Step::InputContract.new(owner: self)
122
+ end
123
+
124
+ def with_defaults(arguments)
125
+ input_contract.apply_defaults(arguments)
126
+ end
127
+
128
+ def enforce_contract!(arguments)
129
+ return arguments unless declares_inputs?
130
+
131
+ input_contract.enforce!(arguments)
132
+ rescue Error::InputValidationError => e
133
+ e.step_name = name
134
+ raise
34
135
  end
35
136
  end
36
137
  end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ # `success!` / `skip!` / `fail!` / `halt!` — end a step body immediately
5
+ # with the matching signal, from any call depth. Mixed into both authoring
6
+ # surfaces (class steps and inline `run` blocks) so the helpers behave
7
+ # identically in either style (contracts/step-helpers.md).
8
+ #
9
+ # Implemented as throw/catch rather than an exception: a `throw` passes
10
+ # straight through `rescue Exception` while `ensure` blocks still run
11
+ # (verified in research.md R2), so a step's own broad rescue cannot swallow
12
+ # the author's intended outcome. For a class-based step, the catching
13
+ # `catch(StepSignals::TAG)` lives on RubyReactor::Step's own class-level
14
+ # `run`/`undo`/`compensate` (so every caller — the executor, the async
15
+ # worker, a direct call — gets identical translation for free); for an
16
+ # inline `run_block`/`compensate_block`/`undo_block` step, it lives at the
17
+ # invocation site in step_executor.rb / compensation_manager.rb.
18
+ module StepSignals
19
+ TAG = :ruby_reactor_step_signal
20
+
21
+ def success!(value = nil)
22
+ throw TAG, RubyReactor.Success(value)
23
+ end
24
+
25
+ def skip!(...)
26
+ throw TAG, RubyReactor.Skipped(...)
27
+ end
28
+
29
+ def fail!(error, **opts)
30
+ throw TAG, RubyReactor.Failure(error, **opts)
31
+ end
32
+
33
+ def halt!(reason: nil, **kwargs)
34
+ throw TAG, RubyReactor.Halt(reason: reason, **kwargs)
35
+ end
36
+ end
37
+ end
@@ -56,6 +56,9 @@ module RubyReactor
56
56
  context = load_step_context
57
57
  return record_missing_parent unless context
58
58
 
59
+ # A fresh worker process has never run the reactor, so the inferred
60
+ # wiring for name-resolved inputs does not exist here yet.
61
+ context.reactor_class&.validate_definition!
59
62
  step_config = context.reactor_class&.steps&.[](@step_name)
60
63
  return record_missing_step unless step_config
61
64
 
@@ -113,6 +116,7 @@ module RubyReactor
113
116
  result =
114
117
  if step_config.has_run_block?
115
118
  args = arguments.empty? ? context.inputs : arguments
119
+ args = step_config.inline_contract.enforce!(args) if step_config.inline_contract
116
120
  step_config.run_block.call(args, context)
117
121
  elsif step_config.has_impl?
118
122
  step_config.impl.run(arguments, context)
@@ -121,6 +125,12 @@ module RubyReactor
121
125
  end
122
126
 
123
127
  normalize(result)
128
+ rescue Error::InputValidationError => e
129
+ # Same shape the executor builds, and never retried: the same arguments
130
+ # fail the same contract on every attempt.
131
+ RubyReactor.Failure(e, validation_errors: e.field_errors, step_name: @step_name,
132
+ step_arguments: e.step_arguments || {}, reactor_name: @reactor_class_name,
133
+ retryable: false)
124
134
  rescue StandardError => e
125
135
  RubyReactor.Failure(e, step_name: @step_name, reactor_name: @reactor_class_name)
126
136
  end
@@ -158,16 +168,21 @@ module RubyReactor
158
168
  # Write first, publish second. The record is the answer; the signal only
159
169
  # saves the reader a fallback interval.
160
170
  def complete(result, context)
161
- storage.store_step_result(
162
- @step_context_id, @step_name,
163
- {
164
- "status" => "completed",
165
- "success" => result.success?,
166
- "result" => ContextSerializer.serialize_value(result.success? ? result.value : result.to_h),
167
- "completed_at" => Time.now.iso8601
168
- },
169
- @reactor_class_name
170
- )
171
+ record = {
172
+ "status" => "completed",
173
+ "success" => result.success?,
174
+ "result" => ContextSerializer.serialize_value(result.success? ? result.value : result.to_h),
175
+ "completed_at" => Time.now.iso8601
176
+ }
177
+ # A `halt!` reports success? == true but carries no value, so without
178
+ # this the reader cannot tell it from an ordinary success returning nil.
179
+ # `skip!` needs nothing extra: Skipped keeps its value, and the reader is
180
+ # meant to see that value exactly as a same-process step would.
181
+ if result.is_a?(RubyReactor::Halt)
182
+ record["signal"] = "halt"
183
+ record["reason"] = result.reason
184
+ end
185
+ storage.store_step_result(@step_context_id, @step_name, record, @reactor_class_name)
171
186
  log(result.success? ? :info : :warn, result.success? ? "completed" : "completed_with_failure")
172
187
  storage.publish(RubyReactor.async_step_channel(@step_context_id, @step_name), "done")
173
188
  result
@@ -88,6 +88,10 @@ module RubyReactor
88
88
  raise NotImplementedError
89
89
  end
90
90
 
91
+ def scan_reactors_page(pattern: "*", cursor: "0", count: 50, include_dispatched_children: false)
92
+ raise NotImplementedError
93
+ end
94
+
91
95
  def find_context_by_id(context_id)
92
96
  raise NotImplementedError
93
97
  end
@@ -10,6 +10,7 @@ module RubyReactor
10
10
  include RedisOrderedLocking
11
11
  include RedisStepResults
12
12
  include RedisPubSub
13
+ include RedisReactorScan
13
14
 
14
15
  def initialize(redis_config)
15
16
  super()
@@ -181,33 +182,6 @@ module RubyReactor
181
182
  @redis.expire(key, seconds)
182
183
  end
183
184
 
184
- # New methods for API
185
- def scan_reactors(pattern: "reactor:*:context:*", count: 50, include_dispatched_children: false)
186
- # Use SCAN to find keys matching the pattern
187
- results = []
188
- batch_keys = []
189
-
190
- # scan_each yields keys. We buffer them to use MGET efficiently.
191
- # We request a batch size from Redis (count: 100) to reduce roundtrips.
192
- @redis.scan_each(match: pattern, count: 100) do |key|
193
- batch_keys << key
194
-
195
- # specific batch size for MGET processing
196
- if batch_keys.size >= 50
197
- results.concat(fetch_and_filter_reactors(batch_keys, include_dispatched_children))
198
- batch_keys = []
199
-
200
- # Stop if we have enough results
201
- return results.take(count) if results.size >= count
202
- end
203
- end
204
-
205
- # Process remaining keys
206
- results.concat(fetch_and_filter_reactors(batch_keys, include_dispatched_children)) if batch_keys.any?
207
-
208
- results.take(count)
209
- end
210
-
211
185
  def find_context_by_id(context_id)
212
186
  # We don't know the reactor class, so we search for the ID
213
187
  pattern = "reactor:*:context:#{context_id}"
@@ -225,23 +199,6 @@ module RubyReactor
225
199
  JSON.parse(json)
226
200
  end
227
201
 
228
- def determine_status(data)
229
- status = data["status"].to_s
230
- return status if status && %w[failed paused completed running skipped pending].include?(status)
231
- return "cancelled" if data["cancelled"]
232
- # Heuristic
233
- return "failed" if data["retry_count"]&.positive? && !data["current_step"].nil?
234
- return "running" if data["current_step"]
235
- return "completed" if execution_evidence?(data)
236
-
237
- "pending"
238
- end
239
-
240
- def execution_evidence?(data)
241
- (data["execution_trace"] || []).any? ||
242
- (data["intermediate_results"] || {}).any?
243
- end
244
-
245
202
  def store_map_element_context_id(map_id, context_id, reactor_class_name)
246
203
  key = map_element_contexts_key(map_id, reactor_class_name)
247
204
  @redis.rpush(key, context_id)
@@ -315,34 +272,6 @@ module RubyReactor
315
272
  RubyReactor.configuration.context_ttl
316
273
  end
317
274
 
318
- def fetch_and_filter_reactors(keys, include_dispatched_children = false)
319
- return [] if keys.empty?
320
-
321
- json_results = @redis.mget(*keys)
322
-
323
- json_results.compact.map do |json|
324
- data = JSON.parse(json)
325
- next if data["parent_context_id"] && !(include_dispatched_children && dispatched_child?(data))
326
- # Skip non-context records (e.g. async_step Step Result Records) whose
327
- # keys are a "reactor:*:context:*" substring match on the SCAN glob
328
- # (context:#{id}:step_result:#{name}) but aren't a reactor context.
329
- next unless data["reactor_class"]
330
-
331
- {
332
- id: data["context_id"],
333
- class: data["reactor_class"],
334
- status: determine_status(data),
335
- created_at: data["started_at"],
336
- failure: data["failure_reason"]
337
- }
338
- end.compact
339
- end
340
-
341
- # An `async_reactor` child owns its own job, so a lost job strands it like
342
- # a top-level reactor. Compose children (inline) and map elements
343
- # (Map::Sweeper's) carry no marker, so neither is swept.
344
- def dispatched_child?(data) = data.dig("private_data", "async_dispatched")
345
-
346
275
  def context_key(context_id, reactor_class_name)
347
276
  "reactor:#{reactor_class_name}:context:#{context_id}"
348
277
  end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ module Storage
5
+ # Listing/scanning reactor contexts for the dashboard/API: a capped
6
+ # single-shot scan (`scan_reactors`, used by the sweeper) and a
7
+ # cursor-paginated variant (`scan_reactors_page`, used by `GET /reactors`
8
+ # so a client can page through a large result set in batches instead of
9
+ # one capped call).
10
+ module RedisReactorScan
11
+ def scan_reactors(pattern: "reactor:*:context:*", count: 50, include_dispatched_children: false)
12
+ # Use SCAN to find keys matching the pattern
13
+ results = []
14
+ batch_keys = []
15
+
16
+ # scan_each yields keys. We buffer them to use MGET efficiently.
17
+ # We request a batch size from Redis (count: 100) to reduce roundtrips.
18
+ @redis.scan_each(match: pattern, count: 100) do |key|
19
+ batch_keys << key
20
+
21
+ # specific batch size for MGET processing
22
+ if batch_keys.size >= 50
23
+ results.concat(fetch_and_filter_reactors(batch_keys, include_dispatched_children))
24
+ batch_keys = []
25
+
26
+ # Stop if we have enough results
27
+ return results.take(count) if results.size >= count
28
+ end
29
+ end
30
+
31
+ # Process remaining keys
32
+ results.concat(fetch_and_filter_reactors(batch_keys, include_dispatched_children)) if batch_keys.any?
33
+
34
+ results.take(count)
35
+ end
36
+
37
+ # `cursor` is an offset into a freshly re-sorted full key scan ("0" for
38
+ # the first page); the returned `cursor` is "0" once there is nothing
39
+ # left to fetch.
40
+ #
41
+ # Redis's own SCAN cursor can't be windowed to exactly `count` items:
42
+ # one `SCAN` call is free to return far more matches than its `count`
43
+ # hint once the keyspace is small (as it is here), so slicing that
44
+ # single batch down to `count` and reporting the raw cursor as the next
45
+ # page silently drops the overflow — a real bug this replaced. Instead
46
+ # we scan the full matching keyspace every call (cheap at dashboard
47
+ # scale), sort it for a stable order across calls, and slice a plain
48
+ # offset window out of it. A page can come back shorter than `count`
49
+ # when some keys in its window get filtered out by
50
+ # `fetch_and_filter_reactors` (dispatched children, non-context keys)
51
+ # — the cursor still advances correctly since it tracks raw key
52
+ # position, not filtered result count.
53
+ def scan_reactors_page(pattern: "reactor:*:context:*", cursor: "0", count: 50, include_dispatched_children: false)
54
+ offset = cursor.to_i
55
+ offset = 0 if offset.negative?
56
+
57
+ all_keys = []
58
+ @redis.scan_each(match: pattern, count: 100) { |key| all_keys << key }
59
+ all_keys.sort!
60
+
61
+ window = all_keys[offset, count] || []
62
+ next_offset = offset + window.size
63
+ next_cursor = next_offset < all_keys.size ? next_offset.to_s : "0"
64
+
65
+ { reactors: fetch_and_filter_reactors(window, include_dispatched_children), cursor: next_cursor }
66
+ end
67
+
68
+ def determine_status(data)
69
+ status = data["status"].to_s == "skipped" ? "halted" : data["status"].to_s # "skipped" is the legacy halt name
70
+ return status if %w[failed paused completed running halted pending].include?(status)
71
+ return "cancelled" if data["cancelled"]
72
+ # Heuristic
73
+ return "failed" if data["retry_count"]&.positive? && !data["current_step"].nil?
74
+ return "running" if data["current_step"]
75
+ return "completed" if execution_evidence?(data)
76
+
77
+ "pending"
78
+ end
79
+
80
+ def execution_evidence?(data)
81
+ (data["execution_trace"] || []).any? ||
82
+ (data["intermediate_results"] || {}).any?
83
+ end
84
+
85
+ private
86
+
87
+ def fetch_and_filter_reactors(keys, include_dispatched_children = false)
88
+ return [] if keys.empty?
89
+
90
+ json_results = @redis.mget(*keys)
91
+
92
+ json_results.compact.map do |json|
93
+ data = JSON.parse(json)
94
+ next if data["parent_context_id"] && !(include_dispatched_children && dispatched_child?(data))
95
+ # Skip non-context records (e.g. async_step Step Result Records) whose
96
+ # keys are a "reactor:*:context:*" substring match on the SCAN glob
97
+ # (context:#{id}:step_result:#{name}) but aren't a reactor context.
98
+ next unless data["reactor_class"]
99
+
100
+ {
101
+ id: data["context_id"],
102
+ class: data["reactor_class"],
103
+ status: determine_status(data),
104
+ created_at: data["started_at"],
105
+ failure: data["failure_reason"]
106
+ }
107
+ end.compact
108
+ end
109
+
110
+ # An `async_reactor` child owns its own job, so a lost job strands it like
111
+ # a top-level reactor. Compose children (inline) and map elements
112
+ # (Map::Sweeper's) carry no marker, so neither is swept.
113
+ def dispatched_child?(data) = data.dig("private_data", "async_dispatched")
114
+ end
115
+ end
116
+ end
@@ -76,7 +76,14 @@ module RubyReactor
76
76
  end
77
77
 
78
78
  value = ContextSerializer.deserialize_value(fetch(record, :result))
79
- return value if fetch(record, :success)
79
+ if fetch(record, :success)
80
+ # Same rule as Failure below: a halt has no same-process equivalent
81
+ # for a reader to mirror, so hand over the signal itself.
82
+ return RubyReactor.Halt(reason: fetch(record, :reason), step_name: @step_name) if
83
+ fetch(record, :signal).to_s == "halt"
84
+
85
+ return value
86
+ end
80
87
 
81
88
  RubyReactor::Failure.new(value)
82
89
  end
@@ -175,7 +182,7 @@ module RubyReactor
175
182
 
176
183
  # Records round-trip through JSON, so a key may come back as a string.
177
184
  def fetch(hash, key)
178
- hash[key] || hash[key.to_s]
185
+ Utils::FetchIndifferent.call(hash, key)
179
186
  end
180
187
 
181
188
  def extract_path(value, path)
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ module Utils
5
+ # Presence-aware symbol/string lookup: a supplied `false` is returned as
6
+ # `false`, never swallowed by an `a || b` fallback into `nil`.
7
+ class FetchIndifferent
8
+ def self.call(hash, key)
9
+ hash.key?(key.to_sym) ? hash[key.to_sym] : hash[key.to_s]
10
+ end
11
+ end
12
+ end
13
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyReactor
4
- VERSION = "0.7.0"
4
+ VERSION = "0.8.0"
5
5
  end
@@ -5,7 +5,7 @@ require_relative "coordination_serializer"
5
5
 
6
6
  module RubyReactor
7
7
  module Web
8
- # rubocop:disable Metrics/BlockLength
8
+ # rubocop:disable Metrics/BlockLength, Metrics/ClassLength
9
9
  class API < Roda
10
10
  plugin :json
11
11
  plugin :all_verbs
@@ -13,9 +13,19 @@ module RubyReactor
13
13
  route do |r|
14
14
  r.on "reactors" do
15
15
  r.is do
16
- # GET /api/reactors
16
+ # GET /api/reactors?limit=N&cursor=C (default limit 50, capped at 500).
17
+ # Body stays a bare array (existing clients keep working unchanged);
18
+ # the next page's cursor rides in the X-Next-Cursor header ("0" means
19
+ # there is no next page). A client that wants every reactor pages
20
+ # through by re-requesting with ?cursor=<X-Next-Cursor> until it gets "0".
17
21
  r.get do
18
- RubyReactor::Configuration.instance.storage_adapter.scan_reactors
22
+ limit = self.class.scan_limit(r.params["limit"])
23
+ cursor = r.params["cursor"] || "0"
24
+
25
+ adapter = RubyReactor::Configuration.instance.storage_adapter
26
+ page = adapter.scan_reactors_page(cursor: cursor, count: limit)
27
+ response.headers["X-Next-Cursor"] = page[:cursor]
28
+ page[:reactors]
19
29
  rescue StandardError => e
20
30
  response.status = 500
21
31
  { error: e.message, backtrace: e.backtrace.first(5) }
@@ -150,9 +160,16 @@ module RubyReactor
150
160
  end
151
161
  end
152
162
 
163
+ def self.scan_limit(raw)
164
+ limit = raw.to_i
165
+ return 50 unless limit.positive?
166
+
167
+ [limit, 500].min
168
+ end
169
+
153
170
  def self.reactor_status(data)
154
- status = data[:status].to_s
155
- return status if %w[failed paused completed running skipped pending].include?(status)
171
+ status = data[:status].to_s == "skipped" ? "halted" : data[:status].to_s
172
+ return status if %w[failed paused completed running halted pending].include?(status)
156
173
  return "cancelled" if data[:cancelled]
157
174
  return "running" if data[:current_step]
158
175
  return "completed" if execution_evidence?(data)
@@ -347,6 +364,6 @@ module RubyReactor
347
364
  }
348
365
  end
349
366
  end
350
- # rubocop:enable Metrics/BlockLength
367
+ # rubocop:enable Metrics/BlockLength, Metrics/ClassLength
351
368
  end
352
369
  end