Skip to content

Commit ec03b0b

Browse files
committed
Handle continuable workflow resumes in CLI
1 parent 969961e commit ec03b0b

7 files changed

Lines changed: 150 additions & 3 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@ This is a Rails API app for the `r3x` Ruby-native workflow engine. Keep changes
170170
- Prefer expressive stdlib methods (`values_at`, `slice`, `filter_map`, `each_with_object`, `sum`, `tally`) over verbose manual loops when they make intent clearer.
171171
- For conditional hash keys, build a base hash, assign optional keys, and return it. Avoid `Hash#tap`/`merge` for simple conditional key assignment.
172172
- Method chaining is fine when it makes data flow clearer; introduce locals when the intermediate value is reused or has important meaning.
173+
- Prefer one-line `tap { it... }` for a single obvious post-construction tweak on the returned object. Use a named block parameter or multiline block when the object role is not obvious, there is more than one mutation, or the block contains branching/side effects that deserve a name.
173174
- Do not remove existing code comments without explicit user approval.
174175

175176
## Ruby Version Updates

bin/workflow

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
#!/usr/bin/env ruby
2+
# frozen_string_literal: true
3+
24
ENV["R3X_RUNTIME_PROFILE"] = "workflow_cli"
35

46
require_relative "../config/environment"
@@ -18,10 +20,11 @@ class WorkflowCli < Thor
1820
desc "run PATH", "Run a workflow from file path"
1921
option :dry_run, type: :boolean, desc: "Run with global dry run mode (clients skip external side effects)"
2022
option :skip_cache, type: :boolean, desc: "Run without workflow cache (with_cache blocks execute directly)"
23+
option :skip_wait, type: :boolean, desc: "Skip waits between resumable workflow executions"
2124
option :verbose, type: :boolean, aliases: "-v", desc: "Enable verbose logging (debug level)"
2225
def execute(path)
2326
Rails.logger.level = Logger::DEBUG if options[:verbose]
24-
cli.run(path, dry_run: options[:dry_run], skip_cache: options[:skip_cache])
27+
cli.run(path, dry_run: options[:dry_run], skip_cache: options[:skip_cache], wait: !options[:skip_wait])
2528
end
2629

2730
desc "list", "List available workflows"

docs/workflows.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ bin/workflow info <workflow_key>
189189
bin/workflow run workflows/<workflow_name>/workflow.rb
190190
bin/workflow run --dry-run workflows/<workflow_name>/workflow.rb
191191
bin/workflow run --skip-cache workflows/<workflow_name>/workflow.rb
192+
bin/workflow run --skip-wait workflows/<workflow_name>/workflow.rb
192193
```
193194

194195
For an included workflow in this checkout:
@@ -202,10 +203,14 @@ bin/workflow run --dry-run workflows/porto_santo_news/workflow.rb
202203
- `run` always takes a direct path to a `workflow.rb` file.
203204
- In `development` and `test`, `bin/workflow run` defaults to dry-run, so dry-run-aware clients avoid
204205
real side effects.
206+
- `run` resumes `ActiveJob::Continuable` interruptions in-process until the workflow completes.
207+
For `isolated: true` steps it waits according to the workflow's `resume_options[:wait]`, matching
208+
queued execution.
205209
- `--dry-run` explicitly enables dry-run for that run (`R3X_DRY_RUN=true`).
206210
- `--no-dry-run` explicitly disables dry-run for that run (`R3X_DRY_RUN=false`), even in
207211
`development`.
208212
- `--skip-cache` sets `R3X_SKIP_CACHE=true` for that run and bypasses `with_cache`.
213+
- `--skip-wait` fast-forwards through local Continuable waits when debugging isolated steps.
209214
- Use `--dry-run --skip-cache` together when you want a fresh, low-risk local run:
210215

211216
```bash

lib/r3x/workflow/cli.rb

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@ def initialize(stdout: $stdout, pack_loader: PackLoader, registry: Registry)
99
@registry = registry
1010
end
1111

12-
def run(path, dry_run: nil, skip_cache: false)
12+
def run(path, dry_run: nil, skip_cache: false, wait: true)
1313
with_run_env(dry_run:, skip_cache:) do
1414
stdout.puts run_message(path, dry_run_explicit: dry_run == true)
15-
load_workflow(path).new.perform
15+
run_workflow(load_workflow(path), wait:)
1616
end
1717
end
1818

@@ -47,6 +47,43 @@ def info(key)
4747

4848
attr_reader :pack_loader, :registry, :stdout
4949

50+
def run_workflow(workflow_class, wait:)
51+
workflow = workflow_class.new
52+
53+
loop do
54+
return workflow.perform
55+
rescue ActiveJob::Continuation::Interrupt
56+
# Isolated Continuable steps interrupt so a queue adapter can persist progress
57+
# and resume the job later. The local CLI runs in-process, so it carries that
58+
# serialized continuation forward itself.
59+
max_resumptions = workflow.class.max_resumptions
60+
61+
if max_resumptions && workflow.resumptions >= max_resumptions
62+
raise ActiveJob::Continuation::ResumeLimitError, "Job was resumed a maximum of #{max_resumptions} times"
63+
end
64+
65+
sleep resume_delay_for(workflow) if wait
66+
workflow = workflow.class.deserialize(workflow.serialize).tap { it.resumptions += 1 }
67+
end
68+
end
69+
70+
def resume_delay_for(workflow)
71+
resume_options = workflow.class.resume_options
72+
73+
if resume_options[:wait_until]
74+
return [resume_options[:wait_until].to_f - Time.current.to_f, 0].max
75+
end
76+
77+
return 0 unless resume_options[:wait]
78+
79+
workflow.send(
80+
:determine_delay,
81+
seconds_or_duration_or_algorithm: resume_options[:wait],
82+
executions: workflow.resumptions + 1,
83+
jitter: 0,
84+
)
85+
end
86+
5087
def load_workflow(path)
5188
full_path = workflow_file_path(path)
5289
require full_path
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# frozen_string_literal: true
2+
3+
module Workflows
4+
class ContinuableWorkflow < R3x::Workflow::Base
5+
EVENTS = Concurrent::Array.new
6+
7+
self.resume_options = { wait: 2.minutes }
8+
9+
trigger :manual
10+
on_complete { self.class.events << "complete" }
11+
12+
class << self
13+
def events
14+
EVENTS
15+
end
16+
17+
def reset_events!
18+
EVENTS.clear
19+
end
20+
end
21+
22+
def run
23+
step :first do
24+
self.class.events << "first"
25+
end
26+
27+
step :second, isolated: true do
28+
self.class.events << "second"
29+
end
30+
31+
step :third, isolated: true do
32+
self.class.events << "third"
33+
end
34+
35+
{ "events" => self.class.events.dup }
36+
end
37+
end
38+
end

test/integration/workflow_cli_test.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ class WorkflowCliTest < ActiveSupport::TestCase
2121
assert_includes output, "Running with dry run: #{@fixture_path}"
2222
end
2323

24+
test "run command accepts skip wait option" do
25+
output = run_cli("run --skip-wait #{@fixture_path}")
26+
27+
assert_includes output, "Running with dry run: #{@fixture_path}"
28+
end
29+
2430
test "nonexistent file shows error" do
2531
output = run_cli("run /nonexistent/path.rb", allow_failure: true)
2632

test/lib/r3x/workflow/cli_test.rb

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@ class CliTest < ActiveSupport::TestCase
99
setup do
1010
@fixture_dir = Rails.root.join("test/fixtures/workflows")
1111
@fixture_path = @fixture_dir.join("test_workflow/workflow.rb")
12+
@continuable_fixture_path = @fixture_dir.join("continuable_workflow/workflow.rb")
1213
@original_workflow_paths = ENV["R3X_WORKFLOW_PATHS"]
1314
ENV["R3X_WORKFLOW_PATHS"] = @fixture_dir.to_s
1415
R3x::Workflow::PackLoader.load!(rebuild_registry: true)
16+
Workflows::ContinuableWorkflow.reset_events!
1517
end
1618

1719
teardown do
@@ -65,6 +67,61 @@ def self.all = []
6567
assert_equal({ "test" => true, "message" => "Test workflow executed successfully" }, result)
6668
end
6769

70+
test "run resumes continuable workflows until completion" do
71+
output = StringIO.new
72+
cli = Cli.new(stdout: output)
73+
cli.stubs(:sleep)
74+
75+
result = cli.run(@continuable_fixture_path.to_s)
76+
77+
assert_equal %w[first second third complete], Workflows::ContinuableWorkflow.events
78+
assert_equal({ "events" => %w[first second third] }, result)
79+
end
80+
81+
test "run waits between continuable workflow resumptions by default" do
82+
output = StringIO.new
83+
cli = Cli.new(stdout: output)
84+
cli.expects(:sleep).twice.with(2.minutes.to_i)
85+
86+
cli.run(@continuable_fixture_path.to_s)
87+
end
88+
89+
test "run supports polynomial wait between continuable workflow resumptions" do
90+
original_resume_options = Workflows::ContinuableWorkflow.resume_options
91+
Workflows::ContinuableWorkflow.resume_options = { wait: :polynomially_longer }
92+
output = StringIO.new
93+
cli = Cli.new(stdout: output)
94+
sleeps = sequence("sleeps")
95+
cli.expects(:sleep).with(3).in_sequence(sleeps)
96+
cli.expects(:sleep).with(18).in_sequence(sleeps)
97+
98+
cli.run(@continuable_fixture_path.to_s)
99+
ensure
100+
Workflows::ContinuableWorkflow.resume_options = original_resume_options
101+
end
102+
103+
test "run supports wait until between continuable workflow resumptions" do
104+
original_resume_options = Workflows::ContinuableWorkflow.resume_options
105+
travel_to Time.zone.local(2026, 1, 1, 12) do
106+
Workflows::ContinuableWorkflow.resume_options = { wait_until: 30.seconds.from_now }
107+
output = StringIO.new
108+
cli = Cli.new(stdout: output)
109+
cli.expects(:sleep).twice.with(30.0)
110+
111+
cli.run(@continuable_fixture_path.to_s)
112+
end
113+
ensure
114+
Workflows::ContinuableWorkflow.resume_options = original_resume_options
115+
end
116+
117+
test "run can skip waiting between continuable workflow resumptions" do
118+
output = StringIO.new
119+
cli = Cli.new(stdout: output)
120+
cli.expects(:sleep).never
121+
122+
cli.run(@continuable_fixture_path.to_s, wait: false)
123+
end
124+
68125
test "run supports dry run and skip cache messaging without leaking env overrides" do
69126
output = StringIO.new
70127

0 commit comments

Comments
 (0)