geneva_drive 0.5.0 → 0.7.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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -1
  3. data/MANUAL.md +449 -11
  4. data/README.md +5 -1
  5. data/lib/generators/geneva_drive/install/install_generator.rb +20 -0
  6. data/lib/generators/geneva_drive/install/templates/add_metadata_to_workflows.rb +17 -0
  7. data/lib/generators/geneva_drive/install/templates/add_resumable_step_support.rb +29 -0
  8. data/lib/generators/geneva_drive/install/templates/add_signals_support.rb +94 -0
  9. data/lib/generators/geneva_drive/install/templates/add_started_at_index_to_step_executions.rb +48 -0
  10. data/lib/generators/geneva_drive/install/templates/create_workflows_migration.rb +11 -0
  11. data/lib/generators/geneva_drive/install/templates/initializer.rb.tt +8 -0
  12. data/lib/geneva_drive/combined_exception_policy.rb +1 -1
  13. data/lib/geneva_drive/exception_policy.rb +36 -10
  14. data/lib/geneva_drive/executor.rb +352 -26
  15. data/lib/geneva_drive/flow_control.rb +87 -7
  16. data/lib/geneva_drive/iterable_step.rb +199 -0
  17. data/lib/geneva_drive/job_options.rb +70 -0
  18. data/lib/geneva_drive/jobs/housekeeping_job.rb +120 -7
  19. data/lib/geneva_drive/resumable_step_definition.rb +97 -0
  20. data/lib/geneva_drive/signal.rb +243 -0
  21. data/lib/geneva_drive/signal_matcher.rb +60 -0
  22. data/lib/geneva_drive/step_definition.rb +96 -2
  23. data/lib/geneva_drive/step_execution.rb +141 -2
  24. data/lib/geneva_drive/test_helpers.rb +212 -6
  25. data/lib/geneva_drive/version.rb +1 -1
  26. data/lib/geneva_drive/workflow/metadata_accessor.rb +85 -0
  27. data/lib/geneva_drive/workflow.rb +559 -43
  28. data/lib/geneva_drive.rb +37 -0
  29. data/test/dsl/signal_step_definition_test.rb +132 -0
  30. data/test/dsl/step_definition_test.rb +70 -0
  31. data/test/jobs/housekeeping_job_test.rb +119 -0
  32. data/test/jobs/housekeeping_signals_test.rb +198 -0
  33. data/test/jobs/perform_step_job_test.rb +25 -2
  34. data/test/migration_helpers_test.rb +1 -1
  35. data/test/test_helper.rb +4 -0
  36. data/test/test_helper_test.rb +281 -0
  37. data/test/workflow/cursor_size_limit_test.rb +75 -0
  38. data/test/workflow/instrumentation_test.rb +28 -0
  39. data/test/workflow/resumable_step_integration_test.rb +341 -0
  40. data/test/workflow/resumable_step_test.rb +615 -0
  41. data/test/workflow/resumable_without_migration_test.rb +103 -0
  42. data/test/workflow/signal_consumption_test.rb +397 -0
  43. data/test/workflow/signal_delivery_test.rb +255 -0
  44. data/test/workflow/signal_durability_test.rb +164 -0
  45. data/test/workflow/signal_rendezvous_test.rb +581 -0
  46. data/test/workflow/signals_without_migration_test.rb +97 -0
  47. data/test/workflow/workflow_test.rb +72 -1
  48. metadata +25 -15
  49. data/test/dummy/config/initializers/geneva_drive.rb +0 -44
  50. data/test/dummy/db/migrate/20260219212321_create_geneva_drive_workflows.rb +0 -74
  51. data/test/dummy/db/migrate/20260219212322_create_geneva_drive_step_executions.rb +0 -108
  52. data/test/dummy/db/migrate/20260219212323_add_finished_at_to_geneva_drive_step_executions.rb +0 -25
  53. data/test/dummy/db/migrate/20260219212324_add_error_class_name_to_geneva_drive_step_executions.rb +0 -7
  54. data/test/dummy/db/migrate/20260316100007_add_metadata_to_geneva_drive_step_executions.rb +0 -17
  55. data/test/dummy/db/migrate/20260327000000_allow_null_hero_on_geneva_drive_workflows.rb +0 -66
  56. data/test/dummy/db/schema.rb +0 -73
  57. data/test/dummy/log/development.log +0 -202
  58. data/test/dummy/log/test.log +0 -79471
  59. data/test/dummy/log/test.log.0 +0 -4
  60. data/test/dummy/tmp/local_secret.txt +0 -1
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Metadata about a resumable step definition in a workflow.
4
+ # Extends StepDefinition with cursor-based iteration support.
5
+ #
6
+ # Resumable steps can iterate over large collections with checkpointing,
7
+ # allowing them to be suspended and resumed across job executions.
8
+ #
9
+ # @api private
10
+ class GenevaDrive::ResumableStepDefinition < GenevaDrive::StepDefinition
11
+ # @return [Proc] the block to execute (receives IterableStep)
12
+ attr_reader :block
13
+
14
+ # @return [Integer, nil] maximum number of iterations before auto-suspend
15
+ attr_reader :max_iterations
16
+
17
+ # @return [ActiveSupport::Duration, nil] maximum runtime before auto-suspend
18
+ attr_reader :max_runtime
19
+
20
+ # Creates a new resumable step definition.
21
+ #
22
+ # @param name [String, Symbol] the step name
23
+ # @param max_iterations [Integer, nil] maximum iterations before auto-suspend
24
+ # @param max_runtime [ActiveSupport::Duration, nil] maximum runtime before auto-suspend
25
+ # @param options [Hash] additional options (same as StepDefinition)
26
+ # @param block [Proc] the block to execute (required, receives IterableStep)
27
+ # @raise [StepConfigurationError] if configuration is invalid
28
+ def initialize(name:, max_iterations: nil, max_runtime: nil, **options, &block)
29
+ @block = block
30
+ @max_iterations = max_iterations
31
+ @max_runtime = max_runtime
32
+
33
+ # Pass a dummy callable to parent since we use block instead
34
+ super(name: name, callable: block, **options)
35
+
36
+ validate_resumable!
37
+ end
38
+
39
+ # Returns true to indicate this step supports resumable iteration.
40
+ #
41
+ # @return [Boolean]
42
+ def resumable?
43
+ true
44
+ end
45
+
46
+ # Executes the step block with an IterableStep in the workflow context.
47
+ #
48
+ # @param workflow [GenevaDrive::Workflow] the workflow instance
49
+ # @param iterable_step [GenevaDrive::IterableStep] the iteration context
50
+ # @return [Object] the result of the step execution
51
+ def execute_in_context(workflow, iterable_step)
52
+ workflow.instance_exec(iterable_step, &@block)
53
+ end
54
+
55
+ private
56
+
57
+ # Validates resumable-specific configuration.
58
+ #
59
+ # @raise [StepConfigurationError] if configuration is invalid
60
+ def validate_resumable!
61
+ validate_block!
62
+ validate_max_iterations!
63
+ validate_max_runtime!
64
+ end
65
+
66
+ # Validates that the block is present.
67
+ #
68
+ # @raise [StepConfigurationError] if block is missing
69
+ def validate_block!
70
+ return if @block.is_a?(Proc)
71
+
72
+ raise GenevaDrive::StepConfigurationError,
73
+ "Resumable step '#{@name}' requires a block"
74
+ end
75
+
76
+ # Validates the max_iterations value.
77
+ #
78
+ # @raise [StepConfigurationError] if max_iterations is invalid
79
+ def validate_max_iterations!
80
+ return if @max_iterations.nil?
81
+ return if @max_iterations.is_a?(Integer) && @max_iterations.positive?
82
+
83
+ raise GenevaDrive::StepConfigurationError,
84
+ "Resumable step '#{@name}' has invalid max_iterations: must be a positive integer"
85
+ end
86
+
87
+ # Validates the max_runtime value.
88
+ #
89
+ # @raise [StepConfigurationError] if max_runtime is invalid
90
+ def validate_max_runtime!
91
+ return if @max_runtime.nil?
92
+ return if @max_runtime.respond_to?(:to_i) && @max_runtime.to_i.positive?
93
+
94
+ raise GenevaDrive::StepConfigurationError,
95
+ "Resumable step '#{@name}' has invalid max_runtime: must be a positive duration"
96
+ end
97
+ end
@@ -0,0 +1,243 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A per-workflow event record. Signals are how the outside world tells a
4
+ # workflow that something happened: a webhook landed, a human clicked
5
+ # "approve", another system finished its part of the job.
6
+ #
7
+ # A signal is always addressed to one workflow instance and is persisted
8
+ # before any processing happens, so it can never be lost between arriving
9
+ # and being noticed - a signal that arrives before the waiting step even
10
+ # exists simply sits in the table until the step's gate picks it up.
11
+ #
12
+ # The lifecycle, in the +state+ column, is +pending+ -> +claimed+ -> +consumed+:
13
+ #
14
+ # - +pending+: persisted, nobody is working on it
15
+ # - +claimed+: attached to at least one step execution, at least one of which
16
+ # has not resolved yet
17
+ # - +consumed+: every attached chain resolved cleanly; no new execution may attach
18
+ #
19
+ # Alongside the state there are two counters, +claimed+ and +consumed+: how
20
+ # many executions have attached to this event, and how many attached chains
21
+ # have resolved cleanly.
22
+ #
23
+ # @example Delivering a signal
24
+ # workflow = OrderFulfillmentWorkflow.ongoing.for_hero(order).first
25
+ # workflow.signal!(:payment_confirmed, payload: {amount_cents: 12_500})
26
+ #
27
+ class GenevaDrive::Signal < ActiveRecord::Base
28
+ self.table_name = "geneva_drive_signals"
29
+
30
+ # Signal lifecycle states as enum with string values.
31
+ #
32
+ # Neither predicates nor scopes are generated: the +claimed+ and +consumed+
33
+ # counter columns own those names, and a `Signal.claimed` scope reading the
34
+ # state while `signal.claimed` reads the counter would be a trap. The state
35
+ # is queried explicitly (`where(state: ...)`) and read off the column; the
36
+ # public predicates are {#claimed?} and {#consumed?} over the counters.
37
+ enum :state, {
38
+ pending: "pending",
39
+ claimed: "claimed",
40
+ consumed: "consumed"
41
+ }, instance_methods: false, scopes: false
42
+
43
+ # Step execution states that mean "this chain has not come to rest yet".
44
+ ACTIVE_EXECUTION_STATES = %w[waiting scheduled in_progress].freeze
45
+
46
+ # Outcomes that resolve an attached chain cleanly, so the signal can be
47
+ # considered handled by it.
48
+ CLEAN_OUTCOMES = %w[success skipped].freeze
49
+
50
+ belongs_to :workflow,
51
+ class_name: "GenevaDrive::Workflow",
52
+ foreign_key: :workflow_id,
53
+ inverse_of: :signals
54
+
55
+ validates :name, presence: true
56
+
57
+ # Signals that may still be attached to a step execution, oldest first.
58
+ # The eligibility rule for both the gate and dispatch: anything that
59
+ # matches and has not been consumed, FIFO.
60
+ scope :attachable, -> { where.not(state: "consumed").order(created_at: :asc, id: :asc) }
61
+
62
+ class << self
63
+ # Lazily checks whether the signals table has been migrated. Never hits
64
+ # the database at class definition time - only on the first runtime call.
65
+ #
66
+ # Deployments usually ship the gem update before running migrations, so
67
+ # everything except actually sending or waiting for a signal must keep
68
+ # working when the table is absent.
69
+ #
70
+ # @return [Boolean]
71
+ def table_available?
72
+ if defined?(@_table_available)
73
+ return @_table_available
74
+ end
75
+
76
+ @_table_available = table_exists?
77
+ end
78
+
79
+ # Clears the cached table detection result. Call this in tests or after
80
+ # running migrations in-process so the next access re-checks.
81
+ #
82
+ # @return [void]
83
+ def reset_table_available_cache!
84
+ remove_instance_variable(:@_table_available) if defined?(@_table_available)
85
+ end
86
+
87
+ # Serializes a payload using ActiveJob serializers (handles Date, Time,
88
+ # and other types) and enforces GenevaDrive.max_signal_payload_size on
89
+ # the serialized JSON. The single serialization path for payload writes.
90
+ #
91
+ # @param value [Object, nil] the payload
92
+ # @return [Object, nil] the serialized payload
93
+ # @raise [SignalPayloadTooLargeError] if the serialized JSON exceeds the limit
94
+ def serialize_payload(value)
95
+ return nil if value.nil?
96
+
97
+ serialized = ActiveJob::Arguments.serialize([value]).first
98
+
99
+ limit = GenevaDrive.max_signal_payload_size
100
+ if limit
101
+ bytesize = JSON.generate(serialized).bytesize
102
+ if bytesize > limit
103
+ raise GenevaDrive::SignalPayloadTooLargeError,
104
+ "Serialized signal payload is #{bytesize} bytes, exceeding " \
105
+ "GenevaDrive.max_signal_payload_size (#{limit} bytes). A payload describes the " \
106
+ "event, it is not a place to ship the data the step should be working on - " \
107
+ "write what matters onto the hero instead. Set " \
108
+ "GenevaDrive.max_signal_payload_size to nil to disable this check."
109
+ end
110
+ end
111
+
112
+ serialized
113
+ end
114
+ end
115
+
116
+ # Returns the deserialized payload. Hashes come back with indifferent
117
+ # access, because webhook senders produce string keys while Ruby senders
118
+ # produce symbols and matchers should not have to know which.
119
+ #
120
+ # @return [Object, nil] the payload
121
+ def payload
122
+ raw = self[:payload]
123
+ return nil if raw.nil?
124
+
125
+ value = ActiveJob::Arguments.deserialize([raw]).first
126
+ value.is_a?(Hash) ? ActiveSupport::HashWithIndifferentAccess.new(value) : value
127
+ end
128
+
129
+ # Sets the payload, serializing it through ActiveJob serializers.
130
+ #
131
+ # @param value [Object, nil] the payload
132
+ # @raise [SignalPayloadTooLargeError] if the serialized JSON exceeds the limit
133
+ # @return [void]
134
+ def payload=(value)
135
+ self[:payload] = self.class.serialize_payload(value)
136
+ end
137
+
138
+ # Whether this instance was returned from a deduplicated delivery - that
139
+ # is, {GenevaDrive::Workflow#signal!} found an existing row with the same
140
+ # idempotency key instead of inserting a new one. Not a column: it is a
141
+ # fact about this call, not about the record.
142
+ #
143
+ # @return [Boolean]
144
+ def duplicate_delivery?
145
+ !!@duplicate_delivery
146
+ end
147
+
148
+ # Flags this instance as the result of a deduplicated delivery.
149
+ #
150
+ # @return [void]
151
+ # @api private
152
+ def duplicate_delivery!
153
+ @duplicate_delivery = true
154
+ end
155
+
156
+ # Whether at least one execution has ever attached to this signal. Reads
157
+ # the counter, not the lifecycle state, so it stays true after the signal
158
+ # has been consumed - "this event was picked up" rather than "is being
159
+ # handled right now".
160
+ #
161
+ # @return [Boolean]
162
+ def claimed? = claimed > 0
163
+
164
+ # Whether at least one attached chain has resolved cleanly. Reads the
165
+ # counter, so for a one-to-many dispatch it goes true with the first
166
+ # resolved branch, while the state only flips once the last one resolves.
167
+ #
168
+ # @return [Boolean]
169
+ def consumed? = consumed > 0
170
+
171
+ # Records one execution attaching to this signal, bumping the claimed
172
+ # count. The pending -> claimed state flip and claimed_at happen on the
173
+ # first claim only, so the timestamp keeps meaning "when this event started
174
+ # being handled". A retry attaching to a still-claimed signal counts as a
175
+ # new claim; a successor continuing the same chain does not (it carries the
176
+ # pin over rather than acquiring it).
177
+ #
178
+ # @return [void]
179
+ # @api private
180
+ def claim!
181
+ attrs = {claimed: claimed + 1}
182
+ if state == "pending"
183
+ attrs[:state] = "claimed"
184
+ attrs[:claimed_at] = Time.current
185
+ end
186
+ update!(attrs)
187
+ end
188
+
189
+ # Records one attached execution chain resolving cleanly, bumping the
190
+ # consumed count. The state flips to consumed - closing the signal to new
191
+ # attachments - only once every attached chain has resolved, which for a
192
+ # single claimant is the same moment.
193
+ #
194
+ # @param resolved_step_names [Array<String, Symbol>] steps whose chains are
195
+ # resolved by decree rather than by outcome - a skip settles the chain it
196
+ # skips past even when that chain's last execution failed
197
+ # @return [void]
198
+ # @api private
199
+ def record_consumption!(resolved_step_names: [])
200
+ attrs = {consumed: consumed + 1}
201
+ if state == "claimed" && fully_resolved?(resolved_step_names: resolved_step_names)
202
+ attrs[:state] = "consumed"
203
+ attrs[:consumed_at] = Time.current
204
+ end
205
+ update!(attrs)
206
+ end
207
+
208
+ # Whether every execution chain attached to this signal has come to a
209
+ # clean stop. A chain that is still running (or parked), and one whose
210
+ # latest attached execution failed or was canceled, both keep the state at
211
+ # claimed - the failed chain's retry re-attaches to it and reads the same
212
+ # payload.
213
+ #
214
+ # Only the most recent attached execution per step counts: the earlier ones
215
+ # in a chain end with `continued` or `reattempted` precisely because they
216
+ # handed the work to the next one.
217
+ #
218
+ # @param resolved_step_names [Array<String, Symbol>] steps to treat as
219
+ # resolved whatever their executions say. Skipping a step is a decision
220
+ # that its chain is over, including when that chain ended in a failure -
221
+ # no future execution of it is coming to resolve it cleanly.
222
+ # @return [Boolean]
223
+ # @api private
224
+ def fully_resolved?(resolved_step_names: [])
225
+ settled = Array(resolved_step_names).map(&:to_s)
226
+ attached = step_executions.order(created_at: :asc, id: :asc).to_a
227
+ return true if attached.empty?
228
+
229
+ unsettled = attached.reject { |execution| settled.include?(execution.step_name) }
230
+ return false if unsettled.any? { |execution| ACTIVE_EXECUTION_STATES.include?(execution.state) }
231
+
232
+ unsettled.group_by(&:step_name).all? do |_step_name, chain|
233
+ CLEAN_OUTCOMES.include?(chain.last.outcome)
234
+ end
235
+ end
236
+
237
+ # Step executions pinned to this signal.
238
+ #
239
+ # @return [ActiveRecord::Relation<GenevaDrive::StepExecution>]
240
+ def step_executions
241
+ GenevaDrive::StepExecution.where(signal_id: id)
242
+ end
243
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Decides whether a given {GenevaDrive::Signal} is the one a step is waiting
4
+ # for. A matcher is a plain object, so it can live in a constant and be shared
5
+ # between steps and workflows.
6
+ #
7
+ # Name equality is the whole predicate unless a block is given; the block
8
+ # receives the (indifferent-access) payload and is +instance_exec+'d on the
9
+ # signal's workflow, so +hero+ and the workflow's own methods are in scope.
10
+ #
11
+ # @example Name only (what `wait_for: :payment_confirmed` builds for you)
12
+ # GenevaDrive::SignalMatcher.new(:payment_confirmed)
13
+ #
14
+ # @example Narrowed by payload
15
+ # GenevaDrive::SignalMatcher.new(:document_signed) do |payload|
16
+ # payload[:document_id] == hero.contract_id
17
+ # end
18
+ #
19
+ # Anything else responding to +#matches?(signal)+ works just as well in
20
+ # +wait_for:+ - a custom matcher owns its whole predicate and can, for
21
+ # instance, accept either of two signal names.
22
+ #
23
+ # Matchers run both at the gate (the waiting step's own job) and at dispatch
24
+ # (inside +signal!+, in the sender's process). They must be cheap and free of
25
+ # side effects, the same expectation +skip_if:+ carries.
26
+ class GenevaDrive::SignalMatcher
27
+ # @return [String] the signal name this matcher accepts
28
+ attr_reader :name
29
+
30
+ # @return [Proc, nil] the payload predicate, if any
31
+ attr_reader :condition
32
+
33
+ # @param name [String, Symbol] signal name for equality matching
34
+ # @yield [payload] optional payload predicate, instance_exec'd on the workflow
35
+ # @raise [ArgumentError] if the name is blank
36
+ def initialize(name, &condition)
37
+ raise ArgumentError, "GenevaDrive::SignalMatcher needs a signal name" if name.blank?
38
+
39
+ @name = name.to_s
40
+ @condition = condition
41
+ end
42
+
43
+ # Whether the signal is the one being waited for.
44
+ #
45
+ # @param signal [GenevaDrive::Signal] the candidate signal
46
+ # @return [Boolean]
47
+ def matches?(signal)
48
+ return false unless signal.name == @name
49
+ return true unless @condition
50
+
51
+ !!signal.workflow.instance_exec(signal.payload, &@condition)
52
+ end
53
+
54
+ # Human-readable description, used in log lines and test helper messages.
55
+ #
56
+ # @return [String]
57
+ def to_s
58
+ @condition ? "#{@name} (narrowed by payload)" : @name
59
+ end
60
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  # Metadata about a step definition in a workflow.
4
4
  # Holds the step name, callable (block or method name), wait time,
5
- # skip conditions, and exception handling configuration.
5
+ # job options, skip conditions, and exception handling configuration.
6
6
  #
7
7
  # @api private
8
8
  class GenevaDrive::StepDefinition
@@ -15,6 +15,10 @@ class GenevaDrive::StepDefinition
15
15
  # Valid types for skip conditions
16
16
  VALID_SKIP_CONDITION_TYPES = [Symbol, Proc, TrueClass, FalseClass, NilClass].freeze
17
17
 
18
+ # Types that mean "a delay" and therefore belong to wait:, never wait_for:.
19
+ # ActiveSupport::Duration is a Numeric-alike but not a Numeric, hence both.
20
+ DURATION_SHAPED_TYPES = [ActiveSupport::Duration, Numeric, Time, Date].freeze
21
+
18
22
  # @return [String] the step name
19
23
  attr_reader :name
20
24
 
@@ -24,6 +28,9 @@ class GenevaDrive::StepDefinition
24
28
  # @return [ActiveSupport::Duration, nil] wait time before executing this step
25
29
  attr_reader :wait
26
30
 
31
+ # @return [Hash] frozen, symbol-keyed Active Job options for this step
32
+ attr_reader :job_options
33
+
27
34
  # @return [Proc, Symbol, Boolean, nil] condition for skipping this step
28
35
  attr_reader :skip_condition
29
36
 
@@ -42,12 +49,19 @@ class GenevaDrive::StepDefinition
42
49
  # @return [Array<String, Integer>, nil] source location of the step block [path, lineno]
43
50
  attr_reader :block_location
44
51
 
52
+ # @return [Symbol, String, Object, nil] the raw wait_for: option
53
+ attr_reader :wait_for
54
+
55
+ # @return [GenevaDrive::SignalMatcher, nil] the normalized signal matcher, if this step waits
56
+ attr_reader :signal_matcher
57
+
45
58
  # Creates a new step definition.
46
59
  #
47
60
  # @param name [String, Symbol] the step name
48
61
  # @param callable [Proc, Symbol, nil] the code to execute (block or method name)
49
62
  # @param options [Hash] additional options
50
63
  # @option options [ActiveSupport::Duration, nil] :wait delay before execution
64
+ # @option options [Hash] :job_options options passed to Active Job's set method
51
65
  # @option options [Proc, Symbol, Boolean, nil] :skip_if condition for skipping
52
66
  # @option options [Proc, Symbol, Boolean, nil] :if condition for running (inverse of skip_if)
53
67
  # @option options [Symbol, GenevaDrive::ExceptionPolicy, Proc, Array<GenevaDrive::ExceptionPolicy>] :on_exception
@@ -65,6 +79,8 @@ class GenevaDrive::StepDefinition
65
79
  @call_location = call_location
66
80
  @block_location = block_location
67
81
  @wait = options[:wait]
82
+ @wait_for = options[:wait_for]
83
+ @job_options = options.fetch(:job_options, {})
68
84
  @skip_if_option = options[:skip_if]
69
85
  @if_option = options[:if]
70
86
  @skip_condition = @skip_if_option || @if_option
@@ -76,7 +92,9 @@ class GenevaDrive::StepDefinition
76
92
  @after_step = options[:after_step]&.to_s
77
93
 
78
94
  validate!
95
+ @job_options = GenevaDrive::JobOptions.validate!(@job_options, context: "Step '#{@name}'").freeze
79
96
  @exception_policy = build_exception_policy
97
+ @signal_matcher = build_signal_matcher
80
98
  end
81
99
 
82
100
  # Returns the action symbol from the exception policy.
@@ -106,6 +124,20 @@ class GenevaDrive::StepDefinition
106
124
  evaluate_condition(@skip_condition, workflow)
107
125
  end
108
126
 
127
+ # Returns whether this is a resumable step.
128
+ #
129
+ # @return [Boolean] false for standard steps
130
+ def resumable?
131
+ false
132
+ end
133
+
134
+ # Whether this step parks until a matching signal arrives.
135
+ #
136
+ # @return [Boolean]
137
+ def waits_for_signal?
138
+ !@signal_matcher.nil?
139
+ end
140
+
109
141
  # Executes the step callable in the context of the workflow.
110
142
  #
111
143
  # @param workflow [GenevaDrive::Workflow] the workflow instance
@@ -131,6 +163,58 @@ class GenevaDrive::StepDefinition
131
163
  validate_terminal_action_raw!
132
164
  validate_positioning!
133
165
  validate_skip_condition!
166
+ validate_wait_for!
167
+ end
168
+
169
+ # Normalizes wait_for: into a matcher. A bare name becomes a
170
+ # {GenevaDrive::SignalMatcher}; anything else already is one (or quacks
171
+ # like one).
172
+ #
173
+ # @return [Object, nil] an object responding to #matches?(signal)
174
+ def build_signal_matcher
175
+ return nil if @wait_for.nil?
176
+ return GenevaDrive::SignalMatcher.new(@wait_for) if signal_shaped_name?(@wait_for)
177
+
178
+ @wait_for
179
+ end
180
+
181
+ # Whether the value is a plain signal name.
182
+ #
183
+ # @param value [Object]
184
+ # @return [Boolean]
185
+ def signal_shaped_name?(value)
186
+ value.is_a?(Symbol) || value.is_a?(String)
187
+ end
188
+
189
+ # Whether the value means "a delay" and therefore belongs to wait:.
190
+ #
191
+ # @param value [Object]
192
+ # @return [Boolean]
193
+ def duration_shaped?(value)
194
+ DURATION_SHAPED_TYPES.any? { |type| value.is_a?(type) }
195
+ end
196
+
197
+ # Validates wait_for:, including the swap guard that catches a duration
198
+ # passed to it. wait: and wait_for: read alike and take disjoint types, so
199
+ # each direction of the mistake gets an error naming the kwarg the author
200
+ # meant (the other direction lives in validate_wait!).
201
+ #
202
+ # @raise [StepConfigurationError] if the option is invalid or swapped
203
+ def validate_wait_for!
204
+ return if @wait_for.nil?
205
+
206
+ if duration_shaped?(@wait_for)
207
+ raise GenevaDrive::StepConfigurationError,
208
+ "Step '#{@name}' has wait_for: #{@wait_for.inspect} — wait_for: takes a signal name or " \
209
+ "matcher; to delay the step, use wait:"
210
+ end
211
+
212
+ return if signal_shaped_name?(@wait_for)
213
+ return if @wait_for.respond_to?(:matches?)
214
+
215
+ raise GenevaDrive::StepConfigurationError,
216
+ "Step '#{@name}' has invalid wait_for: must be a Symbol, String, GenevaDrive::SignalMatcher, " \
217
+ "or an object responding to #matches?(signal), but was #{@wait_for.class}"
134
218
  end
135
219
 
136
220
  # Builds the ExceptionPolicy from validated raw inputs.
@@ -183,9 +267,19 @@ class GenevaDrive::StepDefinition
183
267
 
184
268
  # Validates the wait duration.
185
269
  #
186
- # @raise [StepConfigurationError] if wait is negative
270
+ # @raise [StepConfigurationError] if wait is negative or signal-shaped
187
271
  def validate_wait!
188
272
  return if @wait.nil?
273
+
274
+ # The other half of the wait:/wait_for: swap guard. Durations are numbers
275
+ # or Durations, never Strings - and a String sails through the to_i check
276
+ # below as a zero-second wait, so this arm tightens wait: for everyone.
277
+ if signal_shaped_name?(@wait) || @wait.respond_to?(:matches?)
278
+ raise GenevaDrive::StepConfigurationError,
279
+ "Step '#{@name}' has wait: #{@wait.inspect} — wait: takes a duration; to wait for a " \
280
+ "signal, use wait_for:"
281
+ end
282
+
189
283
  return if @wait.respond_to?(:to_i) && @wait.to_i >= 0
190
284
 
191
285
  raise GenevaDrive::StepConfigurationError,