ruby_raider 4.0.1 → 4.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.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +59 -1
  3. data/RELEASE.md +12 -0
  4. data/lib/commands/flow_commands.rb +104 -0
  5. data/lib/generators/templates/flows/flow_helper.tt +42 -0
  6. data/lib/generators/templates/flows/run.tt +332 -0
  7. data/lib/ruby_raider.rb +16 -0
  8. data/lib/utilities/flows/next_run.rb +61 -0
  9. data/lib/utilities/flows/notifier.rb +76 -0
  10. data/lib/utilities/flows/os/command_runner.rb +73 -0
  11. data/lib/utilities/flows/os/cron.rb +137 -0
  12. data/lib/utilities/flows/os/launchd.rb +164 -0
  13. data/lib/utilities/flows/os/linux.rb +63 -0
  14. data/lib/utilities/flows/os/systemd.rb +178 -0
  15. data/lib/utilities/flows/os/task_scheduler.rb +179 -0
  16. data/lib/utilities/flows/project.rb +170 -0
  17. data/lib/utilities/flows/run_log.rb +61 -0
  18. data/lib/utilities/flows/runner.rb +206 -0
  19. data/lib/utilities/flows/schedule.rb +221 -0
  20. data/lib/utilities/flows/schedule_store.rb +135 -0
  21. data/lib/utilities/flows/scheduler.rb +187 -0
  22. data/lib/version +1 -1
  23. data/sig/commands/flow_commands.rbs +13 -0
  24. data/sig/ruby_raider.rbs +2 -0
  25. data/sig/utilities/flows/next_run.rbs +15 -0
  26. data/sig/utilities/flows/notifier.rbs +29 -0
  27. data/sig/utilities/flows/os/command_runner.rbs +27 -0
  28. data/sig/utilities/flows/os/cron.rbs +34 -0
  29. data/sig/utilities/flows/os/launchd.rbs +45 -0
  30. data/sig/utilities/flows/os/linux.rbs +27 -0
  31. data/sig/utilities/flows/os/systemd.rbs +43 -0
  32. data/sig/utilities/flows/os/task_scheduler.rbs +53 -0
  33. data/sig/utilities/flows/project.rbs +46 -0
  34. data/sig/utilities/flows/run_log.rbs +22 -0
  35. data/sig/utilities/flows/runner.rbs +35 -0
  36. data/sig/utilities/flows/schedule.rbs +61 -0
  37. data/sig/utilities/flows/schedule_store.rbs +34 -0
  38. data/sig/utilities/flows/scheduler.rbs +42 -0
  39. data/spec/commands/flow_commands_spec.rb +128 -0
  40. data/spec/generators/flow_helper_spec.rb +40 -0
  41. data/spec/integration/content/gemfile_content_spec.rb +2 -2
  42. data/spec/integration/end_to_end_spec.rb +63 -0
  43. data/spec/integration/flows/flow_project.rb +62 -0
  44. data/spec/integration/flows/run_command_spec.rb +91 -0
  45. data/spec/integration/flows/run_lifecycle_spec.rb +383 -0
  46. data/spec/utilities/flows/next_run_spec.rb +119 -0
  47. data/spec/utilities/flows/notifier_spec.rb +121 -0
  48. data/spec/utilities/flows/os/command_runner_spec.rb +56 -0
  49. data/spec/utilities/flows/os/cron_spec.rb +287 -0
  50. data/spec/utilities/flows/os/launchd_spec.rb +293 -0
  51. data/spec/utilities/flows/os/linux_spec.rb +149 -0
  52. data/spec/utilities/flows/os/systemd_spec.rb +329 -0
  53. data/spec/utilities/flows/os/task_scheduler_spec.rb +505 -0
  54. data/spec/utilities/flows/project_spec.rb +178 -0
  55. data/spec/utilities/flows/run_log_spec.rb +47 -0
  56. data/spec/utilities/flows/runner_spec.rb +313 -0
  57. data/spec/utilities/flows/schedule_spec.rb +233 -0
  58. data/spec/utilities/flows/schedule_store_spec.rb +137 -0
  59. data/spec/utilities/flows/scheduler_spec.rb +329 -0
  60. metadata +53 -2
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tmpdir'
4
+ require_relative '../../../lib/utilities/flows/schedule_store'
5
+
6
+ # Flow specs live under spec/utilities/flows, next to the code in lib/utilities/flows
7
+ RSpec.describe RubyRaider::Flows::ScheduleStore do # rubocop:disable RSpec/FilePath
8
+ let(:dir) { File.realpath(Dir.mktmpdir('flows-store')) }
9
+ let(:project) { RubyRaider::Flows::Project.new(dir) }
10
+ let(:store) { described_class.new(project) }
11
+ let(:file) { File.join(dir, '.raider/flows/schedules.yml') }
12
+
13
+ def schedule(name, at = '08:00')
14
+ RubyRaider::Flows::Schedule.build(project: project, name: name, every: 'day', at: at)
15
+ end
16
+
17
+ after { FileUtils.rm_rf(dir) }
18
+
19
+ it 'is empty without a file' do
20
+ expect([store.all, store.find('report')]).to eq([[], nil])
21
+ end
22
+
23
+ it 'saves entries sorted by name and reads them back' do
24
+ %w[zeta alpha mid].each { |name| store.save(schedule(name)) }
25
+
26
+ expect([store.all.map(&:name), YAML.safe_load_file(file)['schedules'].map { |entry| entry['name'] }]).to eq([%w[alpha mid zeta]] * 2)
27
+ end
28
+
29
+ it 'replaces an entry by name' do
30
+ store.save(schedule('report'))
31
+ store.save(schedule('report', '09:30'))
32
+
33
+ expect([store.all.size, store.find('report').at]).to eq([1, '09:30'])
34
+ end
35
+
36
+ it 'deletes every entry with the name, and returns the name and os_id of one it could not read' do
37
+ FileUtils.mkdir_p(File.dirname(file))
38
+ File.write(file, { 'schedules' => [{ 'name' => 'report', 'every' => 'yearly' }, { 'name' => 'report' }, schedule('other').to_h] }.to_yaml)
39
+ removed = store.delete('report')
40
+
41
+ expect([removed.class, removed.name, removed.os_id]).to eq([described_class::Removed, 'report', RubyRaider::Flows::Schedule.os_id_for(dir, 'report')])
42
+ expect(YAML.safe_load_file(file)['schedules'].map { |entry| entry['name'] }).to eq(%w[other])
43
+ end
44
+
45
+ it 'returns the readable schedule when a broken entry shares its name' do
46
+ store.save(schedule('report'))
47
+ entries = YAML.safe_load_file(file)['schedules']
48
+ File.write(file, { 'schedules' => [{ 'name' => 'report' }, *entries] }.to_yaml)
49
+
50
+ expect([store.delete('report').class, store.all]).to eq([RubyRaider::Flows::Schedule, []])
51
+ end
52
+
53
+ it 'deletes an entry and returns it, or nil when it is not there' do
54
+ store.save(schedule('report'))
55
+
56
+ expect([store.delete('report').name, store.delete('report'), store.all]).to eq(['report', nil, []])
57
+ end
58
+
59
+ it 'skips invalid entries and keeps them when it writes' do
60
+ FileUtils.mkdir_p(File.dirname(file))
61
+ File.write(file, { 'schedules' => [{ 'name' => 'broken', 'every' => 'yearly' }, 'junk'] }.to_yaml)
62
+ store.save(schedule('report'))
63
+
64
+ expect([store.all.map(&:name), YAML.safe_load_file(file)['schedules'].size]).to eq([%w[report], 3])
65
+ end
66
+
67
+ it 'reads a file that is not YAML as empty but will not overwrite it' do
68
+ FileUtils.mkdir_p(File.dirname(file))
69
+ File.write(file, "schedules: [\n")
70
+
71
+ expect(store.all).to eq([])
72
+ expect { store.save(schedule('report')) }.to raise_error(RubyRaider::Flows::Error, /schedules.yml/)
73
+ expect { store.delete('report') }.to raise_error(RubyRaider::Flows::Error, /schedules.yml/)
74
+ expect(File.read(file)).to eq("schedules: [\n")
75
+ end
76
+
77
+ it 'says whether it could write, before anything else is changed' do
78
+ expect(store.writable!).to be_nil
79
+ FileUtils.mkdir_p(File.dirname(file))
80
+ File.write(file, "schedules: [\n")
81
+
82
+ expect { store.writable! }
83
+ .to raise_error(RubyRaider::Flows::Error, '.raider/flows/schedules.yml is not a schedules file: fix or remove it, then try again')
84
+ end
85
+
86
+ it 'will not overwrite a file it cannot read' do
87
+ store.save(schedule('report'))
88
+ before = File.read(file)
89
+ File.chmod(0o000, file)
90
+
91
+ expect(store.all).to eq([])
92
+ expect { store.save(schedule('other')) }.to raise_error(RubyRaider::Flows::Error, /schedules.yml/)
93
+ File.chmod(0o600, file)
94
+ expect(File.read(file)).to eq(before)
95
+ end
96
+
97
+ it 'does not load Ruby objects' do
98
+ FileUtils.mkdir_p(File.dirname(file))
99
+ File.write(file, "--- !ruby/object:OpenStruct\ntable: {}\n")
100
+
101
+ expect(store.all).to eq([])
102
+ end
103
+
104
+ it 'writes through a temp file and a rename in the same folder' do
105
+ allow(File).to receive(:rename).and_call_original
106
+ store.save(schedule('report'))
107
+
108
+ expect(File).to have_received(:rename).with(%r{\A#{Regexp.escape(File.dirname(file))}/}, file)
109
+ expect(Dir.children(File.dirname(file))).to eq(['schedules.yml'])
110
+ end
111
+
112
+ it 'makes the flows folder private' do
113
+ store.save(schedule('report'))
114
+
115
+ expect(File.stat(File.dirname(file)).mode & 0o777).to eq(0o700)
116
+ end
117
+
118
+ it 'refuses a linked flows folder' do
119
+ FileUtils.mkdir_p(File.join(dir, 'elsewhere'))
120
+ FileUtils.mkdir_p(File.join(dir, '.raider'))
121
+ File.symlink(File.join(dir, 'elsewhere'), File.join(dir, '.raider/flows'))
122
+
123
+ expect { store.save(schedule('report')) }.to raise_error(RubyRaider::Flows::Error, /not a link/)
124
+ end
125
+
126
+ it 'does not read or replace a linked schedules file' do
127
+ FileUtils.mkdir_p(File.dirname(file))
128
+ target = File.join(dir, 'other.yml')
129
+ File.write(target, { 'schedules' => [schedule('report').to_h] }.to_yaml)
130
+ before = File.read(target)
131
+ File.symlink(target, file)
132
+
133
+ expect(store.all).to eq([])
134
+ expect { store.save(schedule('other')) }.to raise_error(RubyRaider::Flows::Error, /schedules.yml/)
135
+ expect([File.symlink?(file), File.read(target)]).to eq([true, before])
136
+ end
137
+ end
@@ -0,0 +1,329 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'stringio'
5
+ require 'tmpdir'
6
+ require_relative '../../../lib/utilities/flows/scheduler'
7
+
8
+ # Flow specs live under spec/utilities/flows, next to the code in lib/utilities/flows
9
+ # The fake adapter, store and streams sit next to the project and scheduler
10
+ # rubocop:disable RSpec/MultipleMemoizedHelpers
11
+ RSpec.describe RubyRaider::Flows::Scheduler do # rubocop:disable RSpec/FilePath
12
+ # Stands in for an OS scheduler: records every call, keeps the installed os_ids
13
+ let(:fake_adapter) do
14
+ Class.new do
15
+ attr_reader :calls, :installed
16
+ attr_accessor :fail_present
17
+
18
+ def initialize
19
+ @calls = []
20
+ @installed = []
21
+ end
22
+
23
+ def label = 'fakeos'
24
+
25
+ def install(schedule)
26
+ @calls << [:install, schedule.os_id]
27
+ @installed |= [schedule.os_id]
28
+ true
29
+ end
30
+
31
+ def remove(os_id)
32
+ @calls << [:remove, os_id]
33
+ !@installed.delete(os_id).nil?
34
+ end
35
+
36
+ def present?(os_id)
37
+ raise RubyRaider::Flows::Error, 'task list unreadable' if fail_present
38
+
39
+ @installed.include?(os_id)
40
+ end
41
+ end
42
+ end
43
+
44
+ let(:dir) { File.realpath(Dir.mktmpdir('flows-scheduler')) }
45
+ let(:project) { RubyRaider::Flows::Project.new(dir) }
46
+ let(:adapter) { fake_adapter.new }
47
+ let(:store) { RubyRaider::Flows::ScheduleStore.new(project) }
48
+ let(:out) { StringIO.new }
49
+ let(:err) { StringIO.new }
50
+ # Sun 8 Nov 2026 12:00 local
51
+ let(:now) { Time.local(2026, 11, 8, 12, 0) }
52
+ let(:scheduler) { described_class.new(project, adapter: adapter, store: store, out: out, err: err, now: -> { now }) }
53
+ let(:os_id) { RubyRaider::Flows::Schedule.os_id_for(dir, 'report') }
54
+
55
+ before do
56
+ %w[flows/flow_helper.rb flows/support/run.rb flows/report_flow.rb].each do |file|
57
+ FileUtils.mkdir_p(File.dirname(File.join(dir, file)))
58
+ File.write(File.join(dir, file), '')
59
+ end
60
+ end
61
+
62
+ after { FileUtils.rm_rf(dir) }
63
+
64
+ def log_run(name, line)
65
+ file = File.join(dir, ".raider/flows/#{name}/runs.jsonl")
66
+ FileUtils.mkdir_p(File.dirname(file))
67
+ File.write(file, "#{JSON.generate(line)}\n", mode: 'a')
68
+ end
69
+
70
+ describe '.adapter' do
71
+ let(:runner) { instance_double(RubyRaider::Flows::Os::CommandRunner) }
72
+
73
+ def result(success)
74
+ RubyRaider::Flows::Os::CommandRunner::Result.new(stdout: '', stderr: '', exitstatus: success ? 0 : 1)
75
+ end
76
+
77
+ it 'picks launchd on macOS and Task Scheduler on Windows without calling them' do
78
+ picked = %w[darwin25 mingw32 mswin64 cygwin].map { |host| described_class.adapter(runner: runner, host_os: host).class }
79
+
80
+ expect(picked).to eq([RubyRaider::Flows::Os::Launchd] + ([RubyRaider::Flows::Os::TaskScheduler] * 3))
81
+ end
82
+
83
+ it 'picks the Linux adapter, which labels itself by the scheduler that answers' do
84
+ allow(runner).to receive(:call).and_return(result(true), result(false))
85
+
86
+ picked = 2.times.map { described_class.adapter(runner: runner, host_os: 'linux-gnu') }
87
+
88
+ expect(picked.map { |adapter| [adapter.class, adapter.label] })
89
+ .to eq([[RubyRaider::Flows::Os::Linux, 'systemd'], [RubyRaider::Flows::Os::Linux, 'cron']])
90
+ end
91
+
92
+ it 'refuses another OS' do
93
+ expect { described_class.adapter(runner: runner, host_os: 'solaris2') }
94
+ .to raise_error(RubyRaider::Flows::Error, 'Scheduling flows is not supported on solaris2')
95
+ end
96
+ end
97
+
98
+ describe '#schedule' do
99
+ it 'installs the OS entry, then writes the yml, and says when it next runs' do
100
+ allow(store).to receive(:save).and_wrap_original do |original, schedule|
101
+ expect(adapter.installed).to include(schedule.os_id)
102
+ original.call(schedule)
103
+ end
104
+
105
+ scheduler.schedule('report', every: 'weekday', at: '08:00')
106
+
107
+ expect(out.string).to eq("Scheduled report: every weekday at 08:00. Next run Mon 9 Nov 08:00.\n")
108
+ expect([adapter.calls, store.find('report').rule_text]).to eq([[[:install, os_id]], 'every weekday at 08:00'])
109
+ end
110
+
111
+ it 'stores notify and adds --notify to the OS entry' do
112
+ allow(adapter).to receive(:install).and_wrap_original do |original, schedule|
113
+ expect(schedule.argv).to include('--notify')
114
+ original.call(schedule)
115
+ end
116
+ scheduler.schedule('report', every: 'day', at: '08:00', notify: true)
117
+
118
+ expect([store.find('report').notify, out.string]).to eq([true, "Scheduled report: every day at 08:00 (notify). Next run Mon 9 Nov 08:00.\n"])
119
+ end
120
+
121
+ it 'says Replaced when the flow already had a schedule' do
122
+ scheduler.schedule('report', every: 'day', at: '08:00')
123
+ scheduler.schedule('report', every: 'week', at: '09:30', on: 'wed')
124
+
125
+ expect(out.string.lines.last).to eq("Replaced the schedule for report: every week on wed at 09:30. Next run Wed 11 Nov 09:30.\n")
126
+ expect(store.all.map(&:rule_text)).to eq(['every week on wed at 09:30'])
127
+ end
128
+
129
+ it 'removes the OS entry again when the yml cannot be written' do
130
+ allow(store).to receive(:save).and_raise(RubyRaider::Flows::Error, 'disk full')
131
+
132
+ expect { scheduler.schedule('report', every: 'day', at: '08:00') }
133
+ .to raise_error(RubyRaider::Flows::Error, 'disk full: the fakeos entry was removed too, so report is not scheduled')
134
+ expect([adapter.calls, adapter.installed]).to eq([[[:install, os_id], [:remove, os_id]], []])
135
+ end
136
+
137
+ it 'needs the flow file and flows set up, and checks the rule, before touching the OS' do
138
+ expect { scheduler.schedule('nope', every: 'day', at: '08:00') }.to raise_error(RubyRaider::Flows::Error, /No flow nope/)
139
+ expect { scheduler.schedule('report', every: 'day') }.to raise_error(RubyRaider::Flows::Error, /needs at/)
140
+ File.delete(File.join(dir, 'flows/support/run.rb'))
141
+ expect { scheduler.schedule('report', every: 'day', at: '08:00') }
142
+ .to raise_error(RubyRaider::Flows::Error, 'Flows are not set up in this project: run raider flow init')
143
+ expect(adapter.calls).to be_empty
144
+ end
145
+
146
+ it 'checks schedules.yml can be written before installing the OS entry' do
147
+ FileUtils.mkdir_p(File.join(dir, '.raider/flows'))
148
+ File.write(File.join(dir, '.raider/flows/schedules.yml'), "schedules: [\n")
149
+
150
+ expect { scheduler.schedule('report', every: 'day', at: '08:00') }
151
+ .to raise_error(RubyRaider::Flows::Error, /schedules.yml is not a schedules file/)
152
+ expect(adapter.calls).to be_empty
153
+ end
154
+
155
+ it 'refuses with a Flows::Error when the flow folder cannot be made' do
156
+ File.write(File.join(dir, '.raider'), '')
157
+
158
+ expect { scheduler.schedule('report', every: 'day', at: '08:00') }
159
+ .to raise_error(RubyRaider::Flows::Error, %r{\ACould not make \.raider/flows: })
160
+ expect(adapter.calls).to be_empty
161
+ end
162
+
163
+ it 'makes .raider/flows/<name> a 0700 folder and refuses a link there' do
164
+ scheduler.schedule('report', every: 'day', at: '08:00')
165
+ expect(File.stat(File.join(dir, '.raider/flows/report')).mode & 0o777).to eq(0o700)
166
+
167
+ FileUtils.rm_rf(File.join(dir, '.raider/flows/report'))
168
+ File.symlink(FileUtils.mkdir_p(File.join(dir, 'elsewhere')).first, File.join(dir, '.raider/flows/report'))
169
+ expect { scheduler.schedule('report', every: 'day', at: '08:00') }
170
+ .to raise_error(RubyRaider::Flows::Error, '.raider/flows/report must be a folder, not a link')
171
+ expect(adapter.calls).to eq([[:install, os_id]])
172
+ end
173
+ end
174
+
175
+ describe '#unschedule' do
176
+ it 'keeps the yml entry when the OS scheduler refuses the removal' do
177
+ scheduler.schedule('report', every: 'day', at: '08:00')
178
+ allow(adapter).to receive(:remove).and_raise(RubyRaider::Flows::Error, 'systemd units found')
179
+
180
+ expect { scheduler.unschedule('report') }.to raise_error(RubyRaider::Flows::Error, 'systemd units found')
181
+ expect(store.find('report')).not_to be_nil
182
+ end
183
+
184
+ it 'removes the yml entry and the OS entry' do
185
+ scheduler.schedule('report', every: 'day', at: '08:00')
186
+ scheduler.unschedule('report')
187
+
188
+ expect([out.string.lines.last, store.all, adapter.installed]).to eq(["Unscheduled report\n", [], []])
189
+ end
190
+
191
+ it 'says when the OS entry was already gone' do
192
+ scheduler.schedule('report', every: 'day', at: '08:00')
193
+ adapter.installed.clear
194
+ scheduler.unschedule('report')
195
+
196
+ expect([out.string.lines.last, store.all]).to eq(["Unscheduled report (it was already gone from fakeos)\n", []])
197
+ end
198
+
199
+ it 'removes an OS entry with no yml entry, even for a deleted flow' do
200
+ adapter.installed << RubyRaider::Flows::Schedule.os_id_for(dir, 'gone')
201
+ scheduler.unschedule('gone')
202
+
203
+ expect([out.string, adapter.installed]).to eq(["Unscheduled gone\n", []])
204
+ end
205
+
206
+ it 'refuses a flow with no schedule anywhere, and an invalid name' do
207
+ expect { scheduler.unschedule('report') }.to raise_error(RubyRaider::Flows::Error, 'No schedule for report')
208
+ expect { scheduler.unschedule('Bad-Name') }.to raise_error(RubyRaider::Flows::Error, /Invalid flow name/)
209
+ end
210
+
211
+ it 'says the OS entry was already removed when schedules.yml can not be written' do
212
+ scheduler.schedule('report', every: 'day', at: '08:00')
213
+ allow(store).to receive(:delete).and_raise(Errno::EACCES, 'schedules.yml')
214
+
215
+ expect { scheduler.unschedule('report') }.to raise_error(RubyRaider::Flows::Error) do |error|
216
+ expect([error.message, error.exit_code]).to eq(
217
+ ['Permission denied - schedules.yml: the fakeos entry was already removed, but schedules.yml still lists report', 2]
218
+ )
219
+ end
220
+ expect(adapter.installed).to eq([])
221
+ end
222
+
223
+ it 'warns that an OS entry from another project path may remain when the yml entry was unreadable' do
224
+ FileUtils.mkdir_p(File.join(dir, '.raider/flows'))
225
+ File.write(File.join(dir, '.raider/flows/schedules.yml'), { 'schedules' => [{ 'name' => 'report' }] }.to_yaml)
226
+ scheduler.unschedule('report')
227
+
228
+ expect(out.string).to eq("Unscheduled report (it was already gone from fakeos)\n")
229
+ expect(err.string).to include('could not be read', 'fakeos entry made from a different project path may remain')
230
+ end
231
+ end
232
+
233
+ describe '#schedules' do
234
+ it 'lists each schedule with its rule, next run, last result and OS state' do
235
+ scheduler.schedule('report', every: 'hour', at: ':15', retries: 2, inputs: ['who=me'], notify: true)
236
+ log_run('report', 'status' => 'failed', 'finished_at' => "2026-11-09T08:00:12Z\e[2J", 'trigger' => 'schedule',
237
+ 'failed_step' => 'Open', 'results_dir' => 'flow-results/report/x', 'run_id' => 'abc')
238
+ out.truncate(0)
239
+ out.rewind
240
+ scheduler.schedules
241
+
242
+ expect(out.string).to eq("#{'report'.ljust(30)} every hour at :15 (notify) next Sun 8 Nov 12:15 failed 2026-11-09T08:00:12Z?[2J ok\n")
243
+ end
244
+
245
+ it 'prints the JSON the desktop app reads' do
246
+ scheduler.schedule('report', every: 'week', at: '08:00', on: 'mon', inputs: ['who=me'], notify: true)
247
+ log_run('report', 'status' => 'completed', 'finished_at' => 'f', 'trigger' => 't', 'failed_step' => nil, 'results_dir' => 'r', 'run_id' => 'x')
248
+ out.truncate(0)
249
+ out.rewind
250
+ scheduler.schedules(json: true)
251
+
252
+ expect(JSON.parse(out.string)).to eq(
253
+ [{ 'name' => 'report', 'every' => 'week', 'at' => '08:00', 'on' => 'mon', 'retries' => 0, 'inputs' => ['who=me'], 'notify' => true,
254
+ 'next_run_at' => Time.local(2026, 11, 9, 8, 0).iso8601, 'os_state' => 'ok',
255
+ 'last' => { 'status' => 'completed', 'finished_at' => 'f', 'trigger' => 't', 'failed_step' => nil, 'results_dir' => 'r' } }]
256
+ )
257
+ end
258
+
259
+ it 'says Ruby not found when the stored ruby is gone, and missing when the OS entry is gone' do
260
+ scheduler.schedule('report', every: 'day', at: '08:00')
261
+ FileUtils.touch(File.join(dir, 'flows/other_flow.rb'))
262
+ fields = store.find('report').to_h.transform_keys(&:to_sym)
263
+ other = { name: 'other', os_id: RubyRaider::Flows::Schedule.os_id_for(dir, 'other'), ruby: '/nonexistent/ruby' }
264
+ store.save(RubyRaider::Flows::Schedule.new(**fields, **other, project_root: dir))
265
+ adapter.installed.clear
266
+ out.truncate(0)
267
+ out.rewind
268
+ scheduler.schedules(json: true)
269
+
270
+ expect(JSON.parse(out.string).map { |row| [row['name'], row['os_state'], row['last']] })
271
+ .to eq([['other', 'Ruby not found: re-schedule', nil], ['report', 'missing from fakeos', nil]])
272
+ end
273
+
274
+ it 'says flow not found when the flow file is gone, after Ruby not found and before the OS state' do
275
+ scheduler.schedule('report', every: 'day', at: '08:00')
276
+ FileUtils.touch(File.join(dir, 'flows/other_flow.rb'))
277
+ fields = store.find('report').to_h.transform_keys(&:to_sym)
278
+ other = { name: 'other', os_id: RubyRaider::Flows::Schedule.os_id_for(dir, 'other'), ruby: '/nonexistent/ruby' }
279
+ store.save(RubyRaider::Flows::Schedule.new(**fields, **other, project_root: dir))
280
+ %w[report other].each { |name| File.delete(File.join(dir, "flows/#{name}_flow.rb")) }
281
+ adapter.fail_present = true
282
+ scheduler.schedules(json: true)
283
+
284
+ expect(JSON.parse(out.string.lines.last).map { |row| [row['name'], row['os_state']] })
285
+ .to eq([['other', 'Ruby not found: re-schedule'], ['report', 'flow not found: unschedule']])
286
+ end
287
+
288
+ it 'adds a row for a flow the OS still schedules with no yml entry' do
289
+ adapter.installed << os_id
290
+ scheduler.schedules(json: true)
291
+
292
+ expect(JSON.parse(out.string)).to eq(
293
+ [{ 'name' => 'report', 'every' => nil, 'at' => nil, 'on' => nil, 'retries' => nil, 'inputs' => nil, 'notify' => nil,
294
+ 'next_run_at' => nil, 'os_state' => 'missing from schedules.yml', 'last' => nil }]
295
+ )
296
+ end
297
+
298
+ it 'shows unknown when the OS state cannot be read' do
299
+ scheduler.schedule('report', every: 'day', at: '08:00')
300
+ adapter.fail_present = true
301
+ out.truncate(0)
302
+ out.rewind
303
+ scheduler.schedules
304
+
305
+ expect(out.string).to end_with(" never run unknown (task list unreadable)\n")
306
+ end
307
+
308
+ it 'prints the missed runs note of an adapter that has one, once' do
309
+ adapter.define_singleton_method(:missed_runs_note) { '(fakeos: missed runs are skipped)' }
310
+ %w[report other].each do |name|
311
+ FileUtils.touch(File.join(dir, "flows/#{name}_flow.rb"))
312
+ scheduler.schedule(name, every: 'day', at: '08:00')
313
+ end
314
+ out.truncate(0)
315
+ out.rewind
316
+ scheduler.schedules
317
+
318
+ expect(out.string.lines.size).to eq(3)
319
+ expect(out.string.lines.last).to eq("(fakeos: missed runs are skipped)\n")
320
+ end
321
+
322
+ it 'says how to add one when there are none' do
323
+ scheduler.schedules
324
+
325
+ expect(out.string).to eq("No schedules yet: raider flow schedule <name> --every day --at 08:00\n")
326
+ end
327
+ end
328
+ end
329
+ # rubocop:enable RSpec/MultipleMemoizedHelpers
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_raider
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.0.1
4
+ version: 4.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Agustin Pequeno
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-11-08 00:00:00.000000000 Z
11
+ date: 2026-11-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler-audit
@@ -260,6 +260,7 @@ files:
260
260
  - assets/ruby_raider_logo.svg
261
261
  - bin/raider
262
262
  - bin/release
263
+ - lib/commands/flow_commands.rb
263
264
  - lib/commands/scaffolding_commands.rb
264
265
  - lib/commands/utility_commands.rb
265
266
  - lib/generators/automation/automation_generator.rb
@@ -305,6 +306,8 @@ files:
305
306
  - lib/generators/templates/common/reek.tt
306
307
  - lib/generators/templates/common/rspec.tt
307
308
  - lib/generators/templates/common/rubocop.tt
309
+ - lib/generators/templates/flows/flow_helper.tt
310
+ - lib/generators/templates/flows/run.tt
308
311
  - lib/generators/templates/helpers/appium_helper.tt
309
312
  - lib/generators/templates/helpers/driver_helper.tt
310
313
  - lib/generators/templates/helpers/gesture_helper.tt
@@ -352,12 +355,27 @@ files:
352
355
  - lib/scaffolding/templates/page_object.tt
353
356
  - lib/scaffolding/templates/spec.tt
354
357
  - lib/utilities/desktop_downloader.rb
358
+ - lib/utilities/flows/next_run.rb
359
+ - lib/utilities/flows/notifier.rb
360
+ - lib/utilities/flows/os/command_runner.rb
361
+ - lib/utilities/flows/os/cron.rb
362
+ - lib/utilities/flows/os/launchd.rb
363
+ - lib/utilities/flows/os/linux.rb
364
+ - lib/utilities/flows/os/systemd.rb
365
+ - lib/utilities/flows/os/task_scheduler.rb
366
+ - lib/utilities/flows/project.rb
367
+ - lib/utilities/flows/run_log.rb
368
+ - lib/utilities/flows/runner.rb
369
+ - lib/utilities/flows/schedule.rb
370
+ - lib/utilities/flows/schedule_store.rb
371
+ - lib/utilities/flows/scheduler.rb
355
372
  - lib/utilities/logger.rb
356
373
  - lib/utilities/logo.rb
357
374
  - lib/utilities/utilities.rb
358
375
  - lib/version
359
376
  - plugins.yml
360
377
  - ruby_raider.gemspec
378
+ - sig/commands/flow_commands.rbs
361
379
  - sig/commands/scaffolding_commands.rbs
362
380
  - sig/commands/utility_commands.rbs
363
381
  - sig/generators/automation/automation_generator.rbs
@@ -398,18 +416,34 @@ files:
398
416
  - sig/scaffolding/project_detector.rbs
399
417
  - sig/scaffolding/scaffolding.rbs
400
418
  - sig/utilities/desktop_downloader.rbs
419
+ - sig/utilities/flows/next_run.rbs
420
+ - sig/utilities/flows/notifier.rbs
421
+ - sig/utilities/flows/os/command_runner.rbs
422
+ - sig/utilities/flows/os/cron.rbs
423
+ - sig/utilities/flows/os/launchd.rbs
424
+ - sig/utilities/flows/os/linux.rbs
425
+ - sig/utilities/flows/os/systemd.rbs
426
+ - sig/utilities/flows/os/task_scheduler.rbs
427
+ - sig/utilities/flows/project.rbs
428
+ - sig/utilities/flows/run_log.rbs
429
+ - sig/utilities/flows/runner.rbs
430
+ - sig/utilities/flows/schedule.rbs
431
+ - sig/utilities/flows/schedule_store.rbs
432
+ - sig/utilities/flows/scheduler.rbs
401
433
  - sig/utilities/logger.rbs
402
434
  - sig/utilities/logo.rbs
403
435
  - sig/utilities/utilities.rbs
404
436
  - sig/vendor/selenium_webdriver.rbs
405
437
  - sig/vendor/thor.rbs
406
438
  - sig/vendor/tty_prompt.rbs
439
+ - spec/commands/flow_commands_spec.rb
407
440
  - spec/commands/raider_commands_spec.rb
408
441
  - spec/commands/scaffolding_commands_spec.rb
409
442
  - spec/commands/utility_commands_spec.rb
410
443
  - spec/generators/fixtures/templates/test.tt
411
444
  - spec/generators/fixtures/templates/test_partial.tt
412
445
  - spec/generators/fixtures/video_helper_stubs/selenium-webdriver.rb
446
+ - spec/generators/flow_helper_spec.rb
413
447
  - spec/generators/gesture_helper_spec.rb
414
448
  - spec/generators/network_helper_spec.rb
415
449
  - spec/generators/screenshot_helper_spec.rb
@@ -434,6 +468,9 @@ files:
434
468
  - spec/integration/content/test_content_spec.rb
435
469
  - spec/integration/end_to_end_features_spec.rb
436
470
  - spec/integration/end_to_end_spec.rb
471
+ - spec/integration/flows/flow_project.rb
472
+ - spec/integration/flows/run_command_spec.rb
473
+ - spec/integration/flows/run_lifecycle_spec.rb
437
474
  - spec/integration/frozen_string_literal_matcher.rb
438
475
  - spec/integration/generators/automation_generator_spec.rb
439
476
  - spec/integration/generators/axe_addon_spec.rb
@@ -489,6 +526,20 @@ files:
489
526
  - spec/system/support/system_test_helper.rb
490
527
  - spec/utilities/desktop_downloader_spec.rb
491
528
  - spec/utilities/ffmpeg_spec.rb
529
+ - spec/utilities/flows/next_run_spec.rb
530
+ - spec/utilities/flows/notifier_spec.rb
531
+ - spec/utilities/flows/os/command_runner_spec.rb
532
+ - spec/utilities/flows/os/cron_spec.rb
533
+ - spec/utilities/flows/os/launchd_spec.rb
534
+ - spec/utilities/flows/os/linux_spec.rb
535
+ - spec/utilities/flows/os/systemd_spec.rb
536
+ - spec/utilities/flows/os/task_scheduler_spec.rb
537
+ - spec/utilities/flows/project_spec.rb
538
+ - spec/utilities/flows/run_log_spec.rb
539
+ - spec/utilities/flows/runner_spec.rb
540
+ - spec/utilities/flows/schedule_spec.rb
541
+ - spec/utilities/flows/schedule_store_spec.rb
542
+ - spec/utilities/flows/scheduler_spec.rb
492
543
  - spec/utilities/headless_config_spec.rb
493
544
  - spec/utilities/trace_command_spec.rb
494
545
  - spec/utilities/utilities_spec.rb