active_saga 0.1.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.
@@ -0,0 +1,706 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+
5
+ module ActiveWorkflow
6
+ module Stores
7
+ # ActiveRecord-backed persistence implementation.
8
+ class ActiveRecord < Base
9
+ TERMINAL_STATES = %w[completed failed cancelled timed_out].freeze
10
+
11
+ module Models
12
+ class Execution < ::ActiveRecord::Base
13
+ self.table_name = "aw_executions"
14
+
15
+ has_many :steps,
16
+ class_name: "ActiveWorkflow::Stores::ActiveRecord::Models::Step",
17
+ foreign_key: :execution_id,
18
+ inverse_of: :execution,
19
+ dependent: :destroy
20
+
21
+ has_many :events,
22
+ class_name: "ActiveWorkflow::Stores::ActiveRecord::Models::Event",
23
+ foreign_key: :execution_id,
24
+ inverse_of: :execution,
25
+ dependent: :destroy
26
+ end
27
+
28
+ class Step < ::ActiveRecord::Base
29
+ self.table_name = "aw_steps"
30
+
31
+ belongs_to :execution,
32
+ class_name: "ActiveWorkflow::Stores::ActiveRecord::Models::Execution",
33
+ foreign_key: :execution_id,
34
+ inverse_of: :steps
35
+
36
+ scope :ordered, -> { order(:position) }
37
+
38
+ enum :state, {
39
+ pending: "pending",
40
+ running: "running",
41
+ waiting: "waiting",
42
+ completed: "completed",
43
+ failed: "failed",
44
+ timed_out: "timed_out",
45
+ cancelled: "cancelled",
46
+ compensating: "compensating",
47
+ compensated: "compensated"
48
+ }
49
+ end
50
+
51
+ class Event < ::ActiveRecord::Base
52
+ self.table_name = "aw_events"
53
+
54
+ belongs_to :execution,
55
+ class_name: "ActiveWorkflow::Stores::ActiveRecord::Models::Execution",
56
+ foreign_key: :execution_id,
57
+ inverse_of: :events
58
+ end
59
+ end
60
+
61
+ def start_execution(workflow_class:, context:, steps:, idempotency_key:, timeout:, metadata: {})
62
+ now = clock.call
63
+ record = nil
64
+
65
+ ::ActiveRecord::Base.transaction do
66
+ if idempotency_key
67
+ existing = Models::Execution.lock.find_by(idempotency_key:)
68
+ return build_execution(existing) if existing
69
+ end
70
+
71
+ record = Models::Execution.create!(
72
+ workflow_class:,
73
+ state: "running",
74
+ ctx: serializer.dump(context),
75
+ cursor_step: steps.first && steps.first[:name],
76
+ idempotency_key:,
77
+ metadata: (metadata || {}).stringify_keys,
78
+ timeout_at: timeout ? now + timeout : nil,
79
+ last_enqueued_at: now
80
+ )
81
+
82
+ steps.each_with_index do |step, index|
83
+ Models::Step.create!(
84
+ execution: record,
85
+ name: step[:name].to_s,
86
+ style: step[:style].to_s,
87
+ options: serializable_step_options(step[:options]),
88
+ position: step[:position] || index,
89
+ state: index.zero? ? "pending" : "pending",
90
+ attempts: 0
91
+ )
92
+ end
93
+
94
+ if steps.empty?
95
+ record.update!(state: "completed", cursor_step: nil, completed_at: now)
96
+ ActiveSupport::Notifications.instrument("active_workflow.execution.completed",
97
+ execution_id: record.id,
98
+ workflow: workflow_class)
99
+ end
100
+ end
101
+
102
+ enqueue_runner(record.id) unless steps.empty?
103
+ build_execution(record)
104
+ end
105
+
106
+ def serializable_step_options(options)
107
+ return {} unless options.is_a?(Hash)
108
+
109
+ options.each_with_object({}) do |(key, value), hash|
110
+ hash[key.to_s] = case value
111
+ when Proc, Method
112
+ nil
113
+ when Hash
114
+ serializable_step_options(value)
115
+ when Array
116
+ value.map { |item| item.is_a?(Hash) ? serializable_step_options(item) : item }
117
+ when Symbol, String, Numeric, TrueClass, FalseClass, NilClass
118
+ value
119
+ else
120
+ value.respond_to?(:name) ? value.name : value.to_s
121
+ end
122
+ end.compact
123
+ end
124
+
125
+ def load_execution(id)
126
+ record = Models::Execution.find_by(id:)
127
+ build_execution(record) if record
128
+ end
129
+
130
+ def enqueue_runner(execution_id, run_at: nil)
131
+ if run_at
132
+ ActiveWorkflow::Jobs::RunnerJob.set(wait_until: run_at).perform_later(execution_id)
133
+ else
134
+ ActiveWorkflow::Jobs::RunnerJob.perform_later(execution_id)
135
+ end
136
+ end
137
+
138
+ def with_execution_lock(execution_id)
139
+ ::ActiveRecord::Base.transaction do
140
+ record = Models::Execution.lock.find_by(id: execution_id)
141
+ return unless record
142
+
143
+ yield(record)
144
+ end
145
+ end
146
+
147
+ def process_execution(execution_id)
148
+ with_execution_lock(execution_id) do |execution|
149
+ Processor.new(self, execution).process!
150
+ end
151
+ end
152
+
153
+ def complete_step!(execution_id, step_name, payload:, idempotency_key: nil)
154
+ with_execution_lock(execution_id) do |execution|
155
+ step = execution.steps.lock.find_by(name: step_name.to_s)
156
+ if step&.completed? && idempotency_key && step.completion_idempotency_key == idempotency_key
157
+ return build_execution(execution)
158
+ end
159
+
160
+ raise ActiveWorkflow::Errors::StepNotWaiting, "Step #{step_name} is not waiting" unless step&.waiting?
161
+
162
+ if idempotency_key && step.completion_idempotency_key.present? && step.completion_idempotency_key != idempotency_key
163
+ raise ActiveWorkflow::Errors::AsyncCompletionConflict, "Completion idempotency mismatch"
164
+ end
165
+
166
+ return build_execution(execution) if step.completion_idempotency_key.present? && step.completion_idempotency_key == idempotency_key
167
+
168
+ definition = step_definition(execution, step)
169
+ ctx = context_from(execution)
170
+
171
+ unless definition.options[:fire_and_forget]
172
+ store_keys = [definition.name.to_sym]
173
+ store_result = definition.options[:store_result_as]&.to_sym
174
+ store_keys << store_result if store_result && store_result != definition.name.to_sym
175
+
176
+ store_keys.each do |key|
177
+ ctx[key] = payload unless payload.nil?
178
+ end
179
+ end
180
+
181
+ step.update!(
182
+ state: "completed",
183
+ completion_payload: payload,
184
+ completion_idempotency_key: idempotency_key,
185
+ waiting_since: nil,
186
+ timeout_at: nil,
187
+ completed_at: clock.call
188
+ )
189
+
190
+ ActiveSupport::Notifications.instrument("active_workflow.step.completed_async",
191
+ execution_id: execution.id,
192
+ step: step.name,
193
+ workflow: execution.workflow_class)
194
+
195
+ persist_context(execution, ctx)
196
+ advance_cursor(execution, step)
197
+ enqueue_runner(execution.id)
198
+ end
199
+ end
200
+
201
+ def fail_step!(execution_id, step_name, error_class:, message:, details:, idempotency_key: nil)
202
+ with_execution_lock(execution_id) do |execution|
203
+ step = execution.steps.lock.find_by(name: step_name.to_s)
204
+ raise ActiveWorkflow::Errors::StepNotWaiting, "Step #{step_name} is not waiting" unless step&.waiting?
205
+
206
+ definition = step_definition(execution, step)
207
+ current_attempts = step.attempts.to_i
208
+ record_failure!(execution, step,
209
+ error_class:,
210
+ message:,
211
+ details: details || {},
212
+ attempts: current_attempts,
213
+ async: true)
214
+
215
+ ActiveSupport::Notifications.instrument("active_workflow.step.failed_async",
216
+ execution_id: execution.id,
217
+ step: step.name,
218
+ workflow: execution.workflow_class,
219
+ error_class: error_class,
220
+ message: message)
221
+
222
+ schedule_retry_or_fail!(execution, step, definition)
223
+ end
224
+ end
225
+
226
+ def extend_timeout!(execution_id, step_name, by:)
227
+ with_execution_lock(execution_id) do |execution|
228
+ step = execution.steps.lock.find_by(name: step_name.to_s)
229
+ return unless step&.waiting?
230
+
231
+ step.update!(timeout_at: step.timeout_at && step.timeout_at + by)
232
+ end
233
+ end
234
+
235
+ def heartbeat!(execution_id, step_name, at: clock.call)
236
+ with_execution_lock(execution_id) do |execution|
237
+ step = execution.steps.lock.find_by(name: step_name.to_s)
238
+ return unless step&.waiting?
239
+
240
+ step.update!(last_heartbeat_at: at)
241
+ end
242
+ end
243
+
244
+ def signal!(execution_id, name, payload: nil)
245
+ with_execution_lock(execution_id) do |execution|
246
+ Models::Event.create!(execution:, name: name.to_s, payload: payload)
247
+
248
+ ActiveSupport::Notifications.instrument("active_workflow.signal.received",
249
+ execution_id: execution.id,
250
+ signal: name)
251
+
252
+ apply_signal_handler(execution, name, payload)
253
+
254
+ if execution.steps.where(name: name.to_s, state: "waiting").exists?
255
+ enqueue_runner(execution.id)
256
+ end
257
+ end
258
+ end
259
+
260
+ def cancel_execution!(execution_id, reason: nil)
261
+ with_execution_lock(execution_id) do |execution|
262
+ return build_execution(execution) if TERMINAL_STATES.include?(execution.state)
263
+
264
+ completed_upto = execution.steps.where(state: %w[completed compensated failed timed_out]).maximum(:position)
265
+ run_compensations!(execution, upto: completed_upto)
266
+ cancel_remaining_steps(execution, upto: completed_upto)
267
+
268
+ execution.update!(
269
+ state: "cancelled",
270
+ cursor_step: nil,
271
+ last_error_class: nil,
272
+ last_error_message: nil,
273
+ cancelled_at: clock.call
274
+ )
275
+
276
+ ActiveSupport::Notifications.instrument("active_workflow.execution.cancelled",
277
+ execution_id: execution.id,
278
+ workflow: execution.workflow_class,
279
+ reason: reason)
280
+
281
+ build_execution(execution)
282
+ end
283
+ end
284
+
285
+ def context_from(execution)
286
+ ActiveWorkflow::Context.new(serializer.load(execution.ctx))
287
+ end
288
+
289
+ def persist_context(execution, ctx)
290
+ execution.update!(ctx: serializer.dump(ctx.to_h))
291
+ end
292
+
293
+ def build_execution(record)
294
+ ActiveWorkflow::Execution.new(
295
+ id: record.id,
296
+ workflow_class: record.workflow_class,
297
+ state: record.state,
298
+ ctx: serializer.load(record.ctx),
299
+ cursor_step: record.cursor_step&.to_sym,
300
+ created_at: record.created_at,
301
+ updated_at: record.updated_at,
302
+ cancelled_at: record.cancelled_at,
303
+ store: self
304
+ )
305
+ end
306
+
307
+ def step_definition(execution, step)
308
+ execution.workflow_class.constantize.step_definition(step.name)
309
+ end
310
+
311
+ def apply_signal_handler(execution, name, payload)
312
+ workflow_class = execution.workflow_class.constantize
313
+ handler = workflow_class.signal_handler_for(name)
314
+ return unless handler
315
+
316
+ ctx = context_from(execution)
317
+ workflow = workflow_class.new(context: ctx, execution_id: execution.id)
318
+
319
+ workflow.send(handler, payload)
320
+ persist_context(execution, workflow.ctx)
321
+ end
322
+
323
+ def advance_cursor(execution, step)
324
+ next_step = execution.steps.ordered.where("position > ?", step.position).first
325
+ if next_step
326
+ execution.update!(
327
+ cursor_step: next_step.name,
328
+ state: next_step.waiting? ? "waiting" : "running"
329
+ )
330
+ else
331
+ execution.update!(
332
+ cursor_step: nil,
333
+ state: "completed",
334
+ completed_at: clock.call
335
+ )
336
+ ActiveSupport::Notifications.instrument("active_workflow.execution.completed",
337
+ execution_id: execution.id,
338
+ workflow: execution.workflow_class)
339
+ end
340
+ end
341
+
342
+ def record_failure!(execution, step, error_class:, message:, details:, attempts:, async: false)
343
+ step.update!(
344
+ state: async ? "failed" : "failed",
345
+ last_error_class: error_class,
346
+ last_error_message: message,
347
+ last_error_details: details,
348
+ attempts: attempts,
349
+ last_error_at: clock.call
350
+ )
351
+ execution.update!(state: "failed", last_error_class: error_class, last_error_message: message)
352
+ end
353
+
354
+ def schedule_retry_or_fail!(execution, step, definition)
355
+ retry_config = (definition.options[:retry] || {}).deep_symbolize_keys
356
+
357
+ return transition_to_failed!(execution, step) if retry_config.empty?
358
+
359
+ current_attempts = step.attempts.to_i
360
+ max_attempts = retry_config[:max] || Float::INFINITY
361
+
362
+ if current_attempts < max_attempts
363
+ retry_index = [current_attempts, 1].max
364
+ delay = ActiveWorkflow::Backoff.calculate(retry_config, attempts: retry_index)
365
+ scheduled_at = clock.call + delay
366
+
367
+ step.update!(state: "pending", attempts: current_attempts, scheduled_at: scheduled_at)
368
+ execution.update!(state: "running", cursor_step: definition.name)
369
+
370
+ ActiveSupport::Notifications.instrument("active_workflow.retry.scheduled",
371
+ execution_id: execution.id,
372
+ workflow: execution.workflow_class,
373
+ step: step.name,
374
+ attempts: current_attempts + 1,
375
+ wait: delay)
376
+
377
+ enqueue_runner(execution.id, run_at: scheduled_at)
378
+ else
379
+ transition_to_failed!(execution, step)
380
+ end
381
+ end
382
+
383
+ def transition_to_failed!(execution, step)
384
+ step.update!(state: "failed")
385
+ run_compensations!(execution, upto: step.position)
386
+ execution.update!(state: "failed", cursor_step: nil)
387
+ ActiveSupport::Notifications.instrument("active_workflow.execution.failed",
388
+ execution_id: execution.id,
389
+ workflow: execution.workflow_class,
390
+ step: step.name)
391
+ end
392
+
393
+ private
394
+
395
+ def run_compensations!(execution, upto:)
396
+ return unless upto
397
+
398
+ workflow_class = execution.workflow_class.constantize
399
+ ctx = context_from(execution)
400
+ workflow = workflow_class.new(context: ctx, execution_id: execution.id)
401
+
402
+ steps = execution.steps.where("position <= ?", upto).order(position: :desc)
403
+ steps.each do |step|
404
+ definition = workflow_class.step_definition(step.name)
405
+ compensate = definition.options[:compensate]
406
+ next if step.state == "compensated"
407
+ next unless compensate
408
+
409
+ ActiveSupport::Notifications.instrument("active_workflow.step.compensating",
410
+ execution_id: execution.id,
411
+ step: definition.name) do
412
+ case compensate
413
+ when Symbol
414
+ workflow.send(compensate)
415
+ when Proc
416
+ workflow.instance_exec(workflow.ctx, &compensate)
417
+ else
418
+ workflow.send(compensate)
419
+ end
420
+ end
421
+ step.update!(state: "compensated")
422
+ end
423
+
424
+ persist_context(execution, workflow.ctx)
425
+ end
426
+
427
+ def cancel_remaining_steps(execution, upto:)
428
+ scope = execution.steps
429
+ scope = scope.where("position > ?", upto) if upto
430
+ scope.where(state: %w[pending running waiting timed_out compensating]).update_all(
431
+ state: "cancelled",
432
+ scheduled_at: nil,
433
+ waiting_since: nil,
434
+ timeout_at: nil,
435
+ last_heartbeat_at: nil
436
+ )
437
+ end
438
+
439
+ # Coordinates execution progression within a DB transaction.
440
+ class Processor
441
+ attr_reader :store, :execution
442
+
443
+ def initialize(store, execution)
444
+ @store = store
445
+ @execution = execution
446
+ end
447
+
448
+ def process!
449
+ return if TERMINAL_STATES.include?(execution.state)
450
+
451
+ step = nil
452
+ definition = nil
453
+ workflow = nil
454
+
455
+ check_timeouts!
456
+ step = next_step
457
+ return if step.nil?
458
+
459
+ workflow_class = execution.workflow_class.constantize
460
+ definition = workflow_class.step_definition(step.name)
461
+ ctx = store.context_from(execution)
462
+ workflow = workflow_class.new(context: ctx, execution_id: execution.id)
463
+
464
+ if skip_step?(workflow, definition)
465
+ mark_skipped(step, definition)
466
+ store.persist_context(execution, workflow.ctx)
467
+ ActiveSupport::Notifications.instrument("active_workflow.step.skipped",
468
+ execution_id: execution.id, step: definition.name, workflow: workflow_class.name)
469
+ process! # continue to next
470
+ return
471
+ end
472
+
473
+ attempt = step.attempts.to_i + 1
474
+ now = store.clock.call
475
+ step.update!(state: "running", attempts: attempt, started_at: now, scheduled_at: nil)
476
+ execution.update!(state: "running", cursor_step: definition.name)
477
+
478
+ ActiveSupport::Notifications.instrument("active_workflow.step.started",
479
+ execution_id: execution.id,
480
+ workflow: workflow_class.name,
481
+ step: definition.name,
482
+ attempt:) do
483
+ result = execute_step(workflow, definition, step)
484
+
485
+ case result
486
+ when :waiting
487
+ # Wait steps or async initialization indicated waiting state.
488
+ store.persist_context(execution, workflow.ctx)
489
+ when :complete
490
+ store.persist_context(execution, workflow.ctx)
491
+ step.update!(state: "completed", completed_at: store.clock.call)
492
+ ActiveSupport::Notifications.instrument("active_workflow.step.completed",
493
+ execution_id: execution.id, workflow: workflow_class.name, step: definition.name)
494
+ new_state = next_state_after(step)
495
+ execution.update!(state: new_state, cursor_step: next_step_name(step))
496
+ ActiveSupport::Notifications.instrument("active_workflow.execution.completed",
497
+ execution_id: execution.id,
498
+ workflow: execution.workflow_class) if new_state == "completed"
499
+ process!
500
+ else
501
+ # Synchronous result returned.
502
+ store.persist_context(execution, workflow.ctx)
503
+ step.update!(state: "completed", completed_at: store.clock.call)
504
+ ActiveSupport::Notifications.instrument("active_workflow.step.completed",
505
+ execution_id: execution.id, workflow: workflow_class.name, step: definition.name, result: result)
506
+ new_state = next_state_after(step)
507
+ execution.update!(state: new_state, cursor_step: next_step_name(step))
508
+ ActiveSupport::Notifications.instrument("active_workflow.execution.completed",
509
+ execution_id: execution.id,
510
+ workflow: execution.workflow_class) if new_state == "completed"
511
+ process!
512
+ end
513
+ end
514
+ rescue => error
515
+ raise if step.nil? || definition.nil? || workflow.nil?
516
+
517
+ handle_error(step, definition, workflow, error)
518
+ end
519
+
520
+ private
521
+
522
+ def check_timeouts!
523
+ now = store.clock.call
524
+
525
+ execution.steps.waiting.each do |step|
526
+ next unless step.timeout_at && step.timeout_at <= now
527
+
528
+ definition = step_definition(step)
529
+ ActiveSupport::Notifications.instrument("active_workflow.step.timeout",
530
+ execution_id: execution.id,
531
+ workflow: execution.workflow_class,
532
+ step: step.name)
533
+
534
+ current_attempts = step.attempts.to_i
535
+ step.update!(state: "timed_out", attempts: current_attempts)
536
+ store.schedule_retry_or_fail!(execution, step, definition)
537
+ end
538
+ end
539
+
540
+ def next_step
541
+ now = store.clock.call
542
+ execution.steps.ordered.detect do |step|
543
+ case step.state
544
+ when "pending"
545
+ !step.scheduled_at || step.scheduled_at <= now
546
+ when "running"
547
+ true
548
+ when "waiting"
549
+ wait_step_ready?(step)
550
+ else
551
+ false
552
+ end
553
+ end
554
+ end
555
+
556
+ def wait_step_ready?(step)
557
+ return false unless step.style == "wait"
558
+
559
+ execution.events.where(name: step.name, consumed_at: nil).exists?
560
+ end
561
+
562
+ def execute_step(workflow, definition, step)
563
+ case definition.style
564
+ when :wait
565
+ event = execution.events.where(name: step.name, consumed_at: nil).order(:created_at).first
566
+ if event
567
+ consume_event(workflow, definition, event)
568
+ :complete
569
+ else
570
+ mark_waiting(step, definition, nil)
571
+ :waiting
572
+ end
573
+ else
574
+ result = workflow.call_step_callable(definition)
575
+ if async_step?(definition)
576
+ assign_async_init_result(workflow, definition, result)
577
+ mark_waiting(step, definition, result)
578
+ :waiting
579
+ else
580
+ assign_result(workflow, definition, result)
581
+ :complete
582
+ end
583
+ end
584
+ end
585
+
586
+ def consume_event(workflow, definition, event)
587
+ payload = event.payload
588
+ key = definition.options[:as]&.to_sym || definition.name
589
+ workflow.ctx[key] = payload
590
+ event.update!(consumed_at: store.clock.call)
591
+ end
592
+
593
+ def async_step?(definition)
594
+ definition.options[:async]
595
+ end
596
+
597
+ def assign_result(workflow, definition, result)
598
+ return if definition.options[:fire_and_forget]
599
+
600
+ key = definition.options[:store_result_as]&.to_sym || definition.name
601
+ workflow.ctx[key] = result unless result.nil?
602
+ end
603
+
604
+ def assign_async_init_result(workflow, definition, result)
605
+ return if definition.options[:fire_and_forget]
606
+
607
+ keys = [definition.name.to_sym]
608
+ store_result = definition.options[:store_result_as]&.to_sym
609
+ keys << store_result if store_result && store_result != definition.name.to_sym
610
+ keys.each do |key|
611
+ workflow.ctx[key] = result unless result.nil?
612
+ end
613
+ end
614
+
615
+ def mark_waiting(step, definition, init_result)
616
+ now = store.clock.call
617
+ timeout = definition.options[:timeout]
618
+ timeout_at = timeout ? now + timeout : nil
619
+ step.update!(
620
+ state: "waiting",
621
+ waiting_since: now,
622
+ timeout_at: timeout_at,
623
+ init_result: init_result
624
+ )
625
+ execution.update!(state: "waiting", cursor_step: definition.name)
626
+
627
+ ActiveSupport::Notifications.instrument("active_workflow.step.waiting",
628
+ execution_id: execution.id, workflow: execution.workflow_class, step: definition.name)
629
+
630
+ store.enqueue_runner(execution.id, run_at: timeout_at) if timeout_at
631
+ end
632
+
633
+ def mark_skipped(step, definition)
634
+ step.update!(state: "completed", completed_at: store.clock.call)
635
+ execution.update!(cursor_step: next_step_name(step), state: next_state_after(step))
636
+ end
637
+
638
+ def handle_error(step, definition, workflow, error)
639
+ attempts = step.attempts.to_i
640
+ step.update!(
641
+ state: "failed",
642
+ last_error_class: error.class.name,
643
+ last_error_message: error.message,
644
+ last_error_backtrace: error.backtrace&.take(20)&.join("\n"),
645
+ attempts: attempts
646
+ )
647
+
648
+ ActiveSupport::Notifications.instrument("active_workflow.step.failed",
649
+ execution_id: execution.id,
650
+ workflow: execution.workflow_class,
651
+ step: definition.name,
652
+ error_class: error.class.name,
653
+ message: error.message)
654
+
655
+ store.persist_context(execution, workflow.ctx)
656
+ store.schedule_retry_or_fail!(execution, step, definition)
657
+ end
658
+
659
+ def skip_step?(workflow, definition)
660
+ if_condition = definition.options[:if]
661
+ unless_condition = definition.options[:unless]
662
+
663
+ if if_condition
664
+ result = evaluate_condition(workflow, if_condition)
665
+ return true unless result
666
+ end
667
+
668
+ if unless_condition
669
+ result = evaluate_condition(workflow, unless_condition)
670
+ return true if result
671
+ end
672
+
673
+ false
674
+ end
675
+
676
+ def evaluate_condition(workflow, condition)
677
+ case condition
678
+ when Symbol
679
+ workflow.send(condition)
680
+ when Proc
681
+ workflow.instance_exec(workflow.ctx, &condition)
682
+ else
683
+ !!condition
684
+ end
685
+ end
686
+
687
+ def next_step_name(step)
688
+ next_step = execution.steps.ordered.where("position > ?", step.position).first
689
+ next_step&.name
690
+ end
691
+
692
+ def next_state_after(step)
693
+ next_step = execution.steps.ordered.where("position > ?", step.position).first
694
+ return "completed" unless next_step
695
+
696
+ next_step.waiting? ? "waiting" : "running"
697
+ end
698
+
699
+ def step_definition(step)
700
+ execution.workflow_class.constantize.step_definition(step.name)
701
+ end
702
+ end
703
+
704
+ end
705
+ end
706
+ end