ruby_reactor 0.7.0 → 0.7.1

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 (51) 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 +79 -12
  7. data/.specify/templates/tasks-template.md +7 -0
  8. data/CHANGELOG.md +11 -0
  9. data/CLAUDE.md +2 -2
  10. data/README.md +24 -9
  11. data/lib/ruby_reactor/context.rb +7 -0
  12. data/lib/ruby_reactor/context_serializer.rb +13 -0
  13. data/lib/ruby_reactor/dsl/lockable.rb +2 -2
  14. data/lib/ruby_reactor/dsl/template_helpers.rb +11 -3
  15. data/lib/ruby_reactor/executor/compensation_manager.rb +58 -45
  16. data/lib/ruby_reactor/executor/ordered_lock_support.rb +9 -9
  17. data/lib/ruby_reactor/executor/result_handler.rb +35 -10
  18. data/lib/ruby_reactor/executor/retry_manager.rb +4 -1
  19. data/lib/ruby_reactor/executor/step_executor.rb +19 -13
  20. data/lib/ruby_reactor/executor.rb +17 -15
  21. data/lib/ruby_reactor/map/element_executor.rb +7 -1
  22. data/lib/ruby_reactor/map/helpers.rb +9 -7
  23. data/lib/ruby_reactor/map/result_enumerator.rb +2 -0
  24. data/lib/ruby_reactor/map/sweeper.rb +1 -1
  25. data/lib/ruby_reactor/open_telemetry.rb +7 -4
  26. data/lib/ruby_reactor/ordered_lock.rb +3 -3
  27. data/lib/ruby_reactor/rspec/matchers.rb +61 -11
  28. data/lib/ruby_reactor/rspec/test_subject.rb +8 -8
  29. data/lib/ruby_reactor/step/map_step.rb +5 -1
  30. data/lib/ruby_reactor/step.rb +10 -4
  31. data/lib/ruby_reactor/step_signals.rb +33 -0
  32. data/lib/ruby_reactor/storage/adapter.rb +4 -0
  33. data/lib/ruby_reactor/storage/redis_adapter.rb +1 -72
  34. data/lib/ruby_reactor/storage/redis_reactor_scan.rb +116 -0
  35. data/lib/ruby_reactor/version.rb +1 -1
  36. data/lib/ruby_reactor/web/api.rb +23 -6
  37. data/lib/ruby_reactor/web/public/assets/index-BQvIWPdx.css +1 -0
  38. data/lib/ruby_reactor/web/public/assets/index-Dw4KV4QY.js +22 -0
  39. data/lib/ruby_reactor/web/public/index.html +2 -2
  40. data/lib/ruby_reactor.rb +56 -7
  41. metadata +7 -11
  42. data/lib/ruby_reactor/web/public/assets/index-B46p-M6K.css +0 -1
  43. data/lib/ruby_reactor/web/public/assets/index-DPmP4yXT.js +0 -22
  44. data/specs/001-background-async-steps/checklists/requirements.md +0 -39
  45. data/specs/001-background-async-steps/contracts/public-dsl.md +0 -154
  46. data/specs/001-background-async-steps/data-model.md +0 -117
  47. data/specs/001-background-async-steps/plan.md +0 -168
  48. data/specs/001-background-async-steps/quickstart.md +0 -102
  49. data/specs/001-background-async-steps/research.md +0 -150
  50. data/specs/001-background-async-steps/spec.md +0 -146
  51. data/specs/001-background-async-steps/tasks.md +0 -271
@@ -264,18 +264,18 @@ module RubyReactor
264
264
  RubyReactor.configuration.storage_adapter
265
265
  end
266
266
 
267
- # Distinguishes `RubyReactor::Skipped` from a plain `Success`. Works on
268
- # any object with a `skipped?` predicate.
267
+ # Distinguishes `RubyReactor::Halt` (a clean halt) from a plain
268
+ # `Success`. Works on any object with a `halted?` predicate.
269
269
  #
270
270
  # Examples:
271
- # expect(result).to be_skipped
272
- # expect(result).to be_skipped.because(:period)
273
- # expect(result).to be_skipped.at_step(:second)
274
- ::RSpec::Matchers.define :be_skipped do
271
+ # expect(result).to be_halted
272
+ # expect(result).to be_halted.because(:period)
273
+ # expect(result).to be_halted.at_step(:second)
274
+ ::RSpec::Matchers.define :be_halted do
275
275
  match do |subject|
276
276
  subject.ensure_executed! if subject.respond_to?(:ensure_executed!)
277
277
  actual = subject.respond_to?(:result) ? subject.result : subject
278
- next false unless actual.respond_to?(:skipped?) && actual.skipped?
278
+ next false unless actual.respond_to?(:halted?) && actual.halted?
279
279
  next false if @expected_reason && actual.reason != @expected_reason
280
280
  next false if @expected_step && actual.step_name != @expected_step
281
281
 
@@ -292,12 +292,62 @@ module RubyReactor
292
292
 
293
293
  failure_message do |subject|
294
294
  actual = subject.respond_to?(:result) ? subject.result : subject
295
- if !actual.respond_to?(:skipped?) || !actual.skipped?
296
- "expected result to be Skipped, got #{actual.class}"
295
+ if !actual.respond_to?(:halted?) || !actual.halted?
296
+ "expected result to be Halt, got #{actual.class}"
297
297
  elsif @expected_reason && actual.reason != @expected_reason
298
- "expected Skipped reason #{@expected_reason.inspect}, got #{actual.reason.inspect}"
298
+ "expected Halt reason #{@expected_reason.inspect}, got #{actual.reason.inspect}"
299
299
  else
300
- "expected Skipped at_step #{@expected_step.inspect}, got #{actual.step_name.inspect}"
300
+ "expected Halt at_step #{@expected_step.inspect}, got #{actual.step_name.inspect}"
301
+ end
302
+ end
303
+
304
+ failure_message_when_negated do
305
+ "expected result not to be Halt"
306
+ end
307
+ end
308
+
309
+ # Asserts that a STEP was skipped — either the subject itself is a
310
+ # `RubyReactor::Skipped` result, or the execution trace records a
311
+ # `:skipped` entry (optionally for a specific step via `.at_step`). A
312
+ # halted run never satisfies this — halting and skipping are distinct.
313
+ #
314
+ # Examples:
315
+ # expect(result).to be_skipped
316
+ # expect(subject).to be_skipped.at_step(:maybe_sync)
317
+ ::RSpec::Matchers.define :be_skipped do
318
+ match do |subject|
319
+ subject.ensure_executed! if subject.respond_to?(:ensure_executed!)
320
+ actual = subject.respond_to?(:result) ? subject.result : subject
321
+ next false if actual.respond_to?(:halted?) && actual.halted?
322
+
323
+ entries = skipped_trace_entries(subject)
324
+ next entries.any? { |e| e[:step].to_s == @expected_step.to_s } if @expected_step
325
+ next true if entries.any?
326
+
327
+ actual.respond_to?(:skipped?) && actual.skipped?
328
+ end
329
+
330
+ chain :at_step do |step|
331
+ @expected_step = step
332
+ end
333
+
334
+ def skipped_trace_entries(subject)
335
+ trace = if subject.respond_to?(:reactor_instance)
336
+ subject.reactor_instance.context.execution_trace
337
+ elsif subject.respond_to?(:execution_trace)
338
+ subject.execution_trace
339
+ else
340
+ []
341
+ end
342
+ trace.select { |e| e[:type].to_s == "skipped" }
343
+ end
344
+
345
+ failure_message do |subject|
346
+ actual = subject.respond_to?(:result) ? subject.result : subject
347
+ if @expected_step
348
+ "expected a skipped step named #{@expected_step.inspect}, found none in the execution trace"
349
+ else
350
+ "expected result to be Skipped, got #{actual.class}"
301
351
  end
302
352
  end
303
353
 
@@ -300,8 +300,8 @@ module RubyReactor
300
300
  end
301
301
  end
302
302
  RubyReactor::Success.new(val)
303
- when "skipped"
304
- skipped_result(ctx)
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.Skipped(...)`. The sync run already produced the exact
330
- # Skipped (reason/step intact) — surface it. For async runs the worker
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 skipped_result(ctx)
333
- return @run_result if @run_result.is_a?(RubyReactor::Skipped)
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 == "skipped" }
336
- RubyReactor::Skipped.new(reason: entry&.dig(:reason), step_name: entry&.dig(:step))
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?
@@ -64,7 +64,7 @@ module RubyReactor
64
64
 
65
65
  def run_inline(arguments, context)
66
66
  results = execute_inline_map(arguments, context)
67
- return results if results.is_a?(RubyReactor::Failure)
67
+ return results if results.is_a?(RubyReactor::Failure) || results.is_a?(RubyReactor::Halt)
68
68
 
69
69
  process_results(results, arguments[:collect_block], arguments[:fail_fast])
70
70
  end
@@ -76,6 +76,10 @@ module RubyReactor
76
76
  arguments[:source].each do |element|
77
77
  result = execute_single_element(element, arguments, context)
78
78
 
79
+ # An element-level Halt propagates as a run halt: stop immediately
80
+ # rather than being collected as a (nil) value.
81
+ return result if result.is_a?(RubyReactor::Halt)
82
+
79
83
  if fail_fast && result.failure?
80
84
  return result # Stop immediately on first failure
81
85
  end
@@ -7,6 +7,8 @@ module RubyReactor
7
7
  end
8
8
 
9
9
  module ClassMethods
10
+ include RubyReactor::StepSignals
11
+
10
12
  # rubocop:disable Naming/MethodName
11
13
  def Success(value = nil)
12
14
  RubyReactor::Success(value)
@@ -16,8 +18,12 @@ module RubyReactor
16
18
  RubyReactor::Failure(error)
17
19
  end
18
20
 
19
- def Skipped(reason: nil, **kwargs)
20
- RubyReactor.Skipped(reason: reason, **kwargs)
21
+ def Halt(reason: nil, **kwargs)
22
+ RubyReactor.Halt(reason: reason, **kwargs)
23
+ end
24
+
25
+ def Skipped(...)
26
+ RubyReactor.Skipped(...)
21
27
  end
22
28
  # rubocop:enable Naming/MethodName
23
29
 
@@ -26,11 +32,11 @@ module RubyReactor
26
32
  end
27
33
 
28
34
  def compensate(_reason, _arguments, _context)
29
- RubyReactor.Success() # Default: accept failure and continue rollback
35
+ RubyReactor.Skipped() # Default: nothing defined, rollback continues
30
36
  end
31
37
 
32
38
  def undo(_result, _arguments, _context)
33
- RubyReactor.Success() # Default: no-op undo
39
+ RubyReactor.Skipped() # Default: nothing defined, rollback continues
34
40
  end
35
41
  end
36
42
  end
@@ -0,0 +1,33 @@
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. The catching `catch(StepSignals::TAG)` lives
13
+ # at each step-body invocation site (step_executor.rb, compensation_manager.rb).
14
+ module StepSignals
15
+ TAG = :ruby_reactor_step_signal
16
+
17
+ def success!(value = nil)
18
+ throw TAG, RubyReactor.Success(value)
19
+ end
20
+
21
+ def skip!(...)
22
+ throw TAG, RubyReactor.Skipped(...)
23
+ end
24
+
25
+ def fail!(error, **opts)
26
+ throw TAG, RubyReactor.Failure(error, **opts)
27
+ end
28
+
29
+ def halt!(reason: nil, **kwargs)
30
+ throw TAG, RubyReactor.Halt(reason: reason, **kwargs)
31
+ end
32
+ end
33
+ end
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyReactor
4
- VERSION = "0.7.0"
4
+ VERSION = "0.7.1"
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