ruby_raider 4.1.0 → 4.2.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 (128) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/e2e_tests.yml +3 -0
  3. data/README.md +52 -53
  4. data/RELEASE.md +14 -1
  5. data/lib/commands/schedule_commands.rb +84 -0
  6. data/lib/generators/templates/common/git_ignore.tt +1 -0
  7. data/lib/ruby_raider/runtime/retry_reset.rb +32 -0
  8. data/lib/ruby_raider/runtime/rspec.rb +1 -0
  9. data/lib/ruby_raider/runtime/show_browser.rb +53 -0
  10. data/lib/ruby_raider/runtime.rb +2 -0
  11. data/lib/ruby_raider.rb +22 -7
  12. data/lib/utilities/schedules/adapter.rb +22 -0
  13. data/lib/utilities/schedules/base.rb +27 -0
  14. data/lib/utilities/schedules/kind.rb +74 -0
  15. data/lib/utilities/schedules/legacy.rb +196 -0
  16. data/lib/utilities/schedules/legacy_stub.rb +62 -0
  17. data/lib/utilities/{flows → schedules}/next_run.rb +2 -2
  18. data/lib/utilities/{flows → schedules}/notifier.rb +6 -6
  19. data/lib/utilities/{flows → schedules}/os/command_runner.rb +1 -1
  20. data/lib/utilities/{flows → schedules}/os/cron.rb +27 -17
  21. data/lib/utilities/{flows → schedules}/os/launchd.rb +32 -14
  22. data/lib/utilities/{flows → schedules}/os/linux.rb +23 -5
  23. data/lib/utilities/{flows → schedules}/os/systemd.rb +33 -17
  24. data/lib/utilities/{flows → schedules}/os/task_scheduler.rb +36 -20
  25. data/lib/utilities/schedules/os_enumerator.rb +30 -0
  26. data/lib/utilities/schedules/project.rb +70 -0
  27. data/lib/utilities/schedules/rule.rb +162 -0
  28. data/lib/utilities/schedules/run_lock.rb +160 -0
  29. data/lib/utilities/schedules/run_log.rb +122 -0
  30. data/lib/utilities/schedules/run_report.rb +139 -0
  31. data/lib/utilities/schedules/run_wrapper.rb +216 -0
  32. data/lib/utilities/schedules/schedule.rb +89 -0
  33. data/lib/utilities/schedules/schedule_store.rb +53 -0
  34. data/lib/utilities/schedules/scheduler.rb +178 -0
  35. data/lib/utilities/schedules/scheduler_steps.rb +69 -0
  36. data/lib/utilities/schedules/selection.rb +133 -0
  37. data/lib/utilities/schedules/spawn.rb +68 -0
  38. data/lib/utilities/{flows/schedule_store.rb → schedules/store.rb} +17 -13
  39. data/lib/version +1 -1
  40. data/ruby_raider.gemspec +1 -0
  41. data/sig/commands/schedule_commands.rbs +12 -0
  42. data/sig/ruby_raider/runtime/retry_reset.rbs +7 -0
  43. data/sig/ruby_raider/runtime/show_browser.rbs +27 -0
  44. data/sig/ruby_raider.rbs +4 -0
  45. data/sig/utilities/schedules/adapter.rbs +5 -0
  46. data/sig/utilities/schedules/base.rbs +15 -0
  47. data/sig/utilities/schedules/kind.rbs +32 -0
  48. data/sig/utilities/schedules/legacy.rbs +45 -0
  49. data/sig/utilities/schedules/legacy_stub.rbs +15 -0
  50. data/sig/utilities/{flows → schedules}/next_run.rbs +3 -3
  51. data/sig/utilities/{flows → schedules}/notifier.rbs +1 -1
  52. data/sig/utilities/{flows → schedules}/os/command_runner.rbs +1 -1
  53. data/sig/utilities/{flows → schedules}/os/cron.rbs +7 -7
  54. data/sig/utilities/{flows → schedules}/os/launchd.rbs +7 -6
  55. data/sig/utilities/{flows → schedules}/os/linux.rbs +10 -4
  56. data/sig/utilities/{flows → schedules}/os/systemd.rbs +8 -7
  57. data/sig/utilities/{flows → schedules}/os/task_scheduler.rbs +13 -10
  58. data/sig/utilities/schedules/os_enumerator.rbs +12 -0
  59. data/sig/utilities/schedules/project.rbs +22 -0
  60. data/sig/utilities/{flows/schedule.rbs → schedules/rule.rbs} +23 -12
  61. data/sig/utilities/schedules/run_lock.rbs +29 -0
  62. data/sig/utilities/schedules/run_log.rbs +33 -0
  63. data/sig/utilities/schedules/run_report.rbs +34 -0
  64. data/sig/utilities/schedules/run_wrapper.rbs +45 -0
  65. data/sig/utilities/schedules/schedule.rbs +27 -0
  66. data/sig/utilities/schedules/schedule_store.rbs +21 -0
  67. data/sig/utilities/{flows → schedules}/scheduler.rbs +22 -15
  68. data/sig/utilities/schedules/scheduler_steps.rbs +22 -0
  69. data/sig/utilities/schedules/selection.rbs +36 -0
  70. data/sig/utilities/schedules/spawn.rbs +10 -0
  71. data/sig/utilities/{flows/schedule_store.rbs → schedules/store.rbs} +14 -9
  72. data/spec/commands/schedule_commands_spec.rb +284 -0
  73. data/spec/integration/content/common_content_spec.rb +8 -2
  74. data/spec/integration/content/gemfile_content_spec.rb +2 -2
  75. data/spec/integration/end_to_end_spec.rb +0 -63
  76. data/spec/runtime/retry_reset_spec.rb +112 -0
  77. data/spec/runtime/show_browser_project_spec.rb +70 -0
  78. data/spec/runtime/show_browser_spec.rb +101 -0
  79. data/spec/utilities/schedules/kind_spec.rb +81 -0
  80. data/spec/utilities/schedules/legacy_spec.rb +433 -0
  81. data/spec/utilities/schedules/legacy_stub_spec.rb +144 -0
  82. data/spec/utilities/{flows → schedules}/next_run_spec.rb +5 -5
  83. data/spec/utilities/{flows → schedules}/notifier_spec.rb +3 -4
  84. data/spec/utilities/{flows → schedules}/os/command_runner_spec.rb +2 -3
  85. data/spec/utilities/{flows → schedules}/os/cron_spec.rb +34 -29
  86. data/spec/utilities/{flows → schedules}/os/launchd_spec.rb +42 -29
  87. data/spec/utilities/{flows → schedules}/os/linux_spec.rb +27 -13
  88. data/spec/utilities/schedules/os/list_ids_spec.rb +202 -0
  89. data/spec/utilities/{flows → schedules}/os/systemd_spec.rb +35 -31
  90. data/spec/utilities/{flows → schedules}/os/task_scheduler_spec.rb +40 -34
  91. data/spec/utilities/schedules/os_entry_spec.rb +54 -0
  92. data/spec/utilities/schedules/os_enumerator_spec.rb +57 -0
  93. data/spec/utilities/schedules/project_spec.rb +125 -0
  94. data/spec/utilities/schedules/run_lock_spec.rb +143 -0
  95. data/spec/utilities/schedules/run_log_spec.rb +83 -0
  96. data/spec/utilities/schedules/run_report_spec.rb +150 -0
  97. data/spec/utilities/schedules/run_wrapper_integration_spec.rb +166 -0
  98. data/spec/utilities/schedules/run_wrapper_spec.rb +430 -0
  99. data/spec/utilities/schedules/schedule_spec.rb +120 -0
  100. data/spec/utilities/schedules/schedule_store_spec.rb +151 -0
  101. data/spec/utilities/schedules/scheduler_launchd_spec.rb +77 -0
  102. data/spec/utilities/schedules/scheduler_spec.rb +445 -0
  103. data/spec/utilities/schedules/selection_spec.rb +176 -0
  104. data/spec/utilities/schedules/spawn_spec.rb +104 -0
  105. metadata +106 -53
  106. data/lib/commands/flow_commands.rb +0 -104
  107. data/lib/generators/templates/flows/flow_helper.tt +0 -42
  108. data/lib/generators/templates/flows/run.tt +0 -332
  109. data/lib/utilities/flows/project.rb +0 -170
  110. data/lib/utilities/flows/run_log.rb +0 -61
  111. data/lib/utilities/flows/runner.rb +0 -206
  112. data/lib/utilities/flows/schedule.rb +0 -221
  113. data/lib/utilities/flows/scheduler.rb +0 -187
  114. data/sig/commands/flow_commands.rbs +0 -13
  115. data/sig/utilities/flows/project.rbs +0 -46
  116. data/sig/utilities/flows/run_log.rbs +0 -22
  117. data/sig/utilities/flows/runner.rbs +0 -35
  118. data/spec/commands/flow_commands_spec.rb +0 -128
  119. data/spec/generators/flow_helper_spec.rb +0 -40
  120. data/spec/integration/flows/flow_project.rb +0 -62
  121. data/spec/integration/flows/run_command_spec.rb +0 -91
  122. data/spec/integration/flows/run_lifecycle_spec.rb +0 -383
  123. data/spec/utilities/flows/project_spec.rb +0 -178
  124. data/spec/utilities/flows/run_log_spec.rb +0 -47
  125. data/spec/utilities/flows/runner_spec.rb +0 -313
  126. data/spec/utilities/flows/schedule_spec.rb +0 -233
  127. data/spec/utilities/flows/schedule_store_spec.rb +0 -137
  128. data/spec/utilities/flows/scheduler_spec.rb +0 -329
@@ -2,12 +2,12 @@
2
2
 
3
3
  require 'shellwords'
4
4
  require 'tmpdir'
5
- require_relative '../../../../lib/utilities/flows/os/cron'
5
+ require_relative '../../../../lib/utilities/schedules/os/cron'
6
+ require_relative '../../../../lib/utilities/schedules/schedule'
6
7
 
7
- # Flow specs live under spec/utilities/flows, next to the code in lib/utilities/flows
8
8
  # The fake runner and project folders sit next to the adapter under test
9
9
  # rubocop:disable RSpec/MultipleMemoizedHelpers
10
- RSpec.describe RubyRaider::Flows::Os::Cron do # rubocop:disable RSpec/FilePath
10
+ RSpec.describe RubyRaider::Schedules::Os::Cron do # rubocop:disable RSpec/FilePath
11
11
  # Plays crontab: -l prints the current table (or fails), - stores stdin; never runs anything
12
12
  let(:fake_runner_class) do
13
13
  Class.new do
@@ -27,22 +27,24 @@ RSpec.describe RubyRaider::Flows::Os::Cron do # rubocop:disable RSpec/FilePath
27
27
  stderr, status = answers.fetch(verb) { missing ? ['no crontab for raider', 1] : ['', 0] }
28
28
  stdout = verb == '-l' ? table.to_s : ''
29
29
  self.table = stdin_data if verb == '-' && status.zero?
30
- RubyRaider::Flows::Os::CommandRunner::Result.new(stdout: stdout, stderr: stderr, exitstatus: status)
30
+ RubyRaider::Schedules::Os::CommandRunner::Result.new(stdout: stdout, stderr: stderr, exitstatus: status)
31
31
  end
32
32
  end
33
33
  end
34
34
 
35
35
  let(:hostile) { %q('; rm -rf ~; ' </string> %h $(id) " \\ &) }
36
- let(:tmp) { File.realpath(Dir.mktmpdir('flows-cron')) }
36
+ let(:tmp) { File.realpath(Dir.mktmpdir('schedules-cron')) }
37
37
  let(:project_dir) { File.join(tmp, 'plain') }
38
38
  let(:runner) { fake_runner_class.new }
39
- let(:cron) { described_class.new(runner: runner) }
39
+ let(:cron) { described_class.new(kind: RubyRaider::Schedules::Kind::TESTS, runner: runner) }
40
40
  let(:now) { Time.utc(2026, 11, 9, 10, 0, 0) }
41
41
 
42
- def schedule(dir: project_dir, name: 'report', **options)
42
+ def schedule(dir: project_dir, name: 'report', gem_env: {}, bundle: '/opt/ruby/bin/bundle', **options)
43
43
  FileUtils.mkdir_p(dir)
44
- project = RubyRaider::Flows::Project.new(File.realpath(dir))
45
- RubyRaider::Flows::Schedule.build(project: project, name: name, every: 'day', at: '08:00', now: now, **options)
44
+ # Hermetic: the GEM_HOME/GEM_PATH and bundle this spec runs with never reach the entry; a spec passes its own
45
+ allow(RubyRaider::Schedules::Schedule).to receive_messages(captured_gem_env: gem_env, bundle_path: bundle)
46
+ project = RubyRaider::Schedules::Project.new(File.realpath(dir))
47
+ RubyRaider::Schedules::Schedule.build(project: project, name: name, every: 'day', at: '08:00', now: now, **options)
46
48
  end
47
49
 
48
50
  # What cron (cronie/vixie do_command.c) hands to /bin/sh: a backslash escapes the next character, an
@@ -76,7 +78,7 @@ RSpec.describe RubyRaider::Flows::Os::Cron do # rubocop:disable RSpec/FilePath
76
78
  '>>', item.log_path, '2>&1']
77
79
  end
78
80
 
79
- def block(item) = "# raider-flow #{item.os_id}\n#{cron.line(item)}\n"
81
+ def block(item) = "# raider-schedule #{item.os_id}\n#{cron.line(item)}\n"
80
82
 
81
83
  after { FileUtils.rm_rf(tmp) }
82
84
 
@@ -103,27 +105,30 @@ RSpec.describe RubyRaider::Flows::Os::Cron do # rubocop:disable RSpec/FilePath
103
105
  end
104
106
 
105
107
  it 'keeps GEM_HOME and GEM_PATH in the environment when they were captured' do
106
- allow(RubyRaider::Flows::Schedule).to receive(:captured_gem_env).and_return('GEM_HOME' => '/g/%home', 'GEM_PATH' => '/g/path')
108
+ item = schedule(gem_env: { 'GEM_HOME' => '/g/%home', 'GEM_PATH' => '/g/path' })
107
109
 
108
- expect(fields_and_words(cron.line(schedule)).last).to include('GEM_HOME=/g/%home', 'GEM_PATH=/g/path')
110
+ expect(fields_and_words(cron.line(item)).last).to include('GEM_HOME=/g/%home', 'GEM_PATH=/g/path')
109
111
  end
110
112
 
111
113
  it 'refuses a ruby path env would read as a variable' do
112
114
  plain = schedule
113
- item = RubyRaider::Flows::Schedule.from_h(plain.to_h.merge('ruby' => '/opt/a=b/ruby'), plain.project_root)
115
+ item = RubyRaider::Schedules::Schedule.from_h(plain.to_h.merge('ruby' => '/opt/a=b/ruby'), plain.project_root)
114
116
 
115
- expect { cron.line(item) }.to raise_error(RubyRaider::Flows::Error, /cron can not run ruby from .*a=b/)
117
+ expect { cron.line(item) }.to raise_error(RubyRaider::Schedules::Error, /cron can not run ruby from .*a=b/)
116
118
  end
117
119
 
118
- describe 'with hostile values in an input and in the project path' do
119
- let(:item) { schedule(dir: File.join(tmp, "p#{hostile}<", "string> #{hostile} 100%"), inputs: ["note=#{hostile} 50%"]) }
120
+ describe 'with hostile values in the bundle path, GEM_HOME and the project path' do
121
+ let(:item) do
122
+ schedule(dir: File.join(tmp, "p#{hostile}<", "string> #{hostile} 100%"), bundle: "/opt/#{hostile} 50%/bundle",
123
+ gem_env: { 'GEM_HOME' => "/g/#{hostile} 25%" })
124
+ end
120
125
  let(:text) { cron.line(item) }
121
126
 
122
127
  it 'gives back the exact command words after cron and the shell read it' do
123
128
  fields, words = fields_and_words(text)
124
129
 
125
130
  expect([fields, words]).to eq([%w[0 8 * * *], expected_words(item)])
126
- expect(words).to include("note=#{hostile} 50%")
131
+ expect(words).to include("/opt/#{hostile} 50%/bundle", "GEM_HOME=/g/#{hostile} 25%")
127
132
  expect(item.working_directory).to include('</string>', "'; rm -rf ~; '", '$(id)', '%h', '"', '\\', '&', ' ')
128
133
  end
129
134
 
@@ -145,7 +150,7 @@ RSpec.describe RubyRaider::Flows::Os::Cron do # rubocop:disable RSpec/FilePath
145
150
 
146
151
  it 'puts the block first and keeps every other byte, CRLF and a missing last newline included' do
147
152
  item = schedule
148
- other = "SHELL=/bin/fish\r\nMAILTO=\"\"\r\n# raider-flow dev.rubyraider.flow.0123456789.other\n1 2 * * * echo % \\%\n5 5 * * * last"
153
+ other = "SHELL=/bin/fish\r\nMAILTO=\"\"\r\n# raider-schedule dev.rubyraider.schedule.0123456789.other\n1 2 * * * echo % \\%\n5 5 * * * last"
149
154
  runner.table = other
150
155
 
151
156
  cron.install(item)
@@ -164,7 +169,7 @@ RSpec.describe RubyRaider::Flows::Os::Cron do # rubocop:disable RSpec/FilePath
164
169
  expect(runner.table).not_to include(daily)
165
170
  end
166
171
 
167
- it 'keeps another flow block' do
172
+ it "keeps another schedule's block" do
168
173
  other = schedule(name: 'nightly')
169
174
  runner.table = block(other)
170
175
 
@@ -174,16 +179,16 @@ RSpec.describe RubyRaider::Flows::Os::Cron do # rubocop:disable RSpec/FilePath
174
179
  end
175
180
 
176
181
  it 'refuses a line vixie cron would cut short and writes nothing' do
177
- item = schedule(inputs: ["note=#{'x' * 900}"])
182
+ item = schedule(gem_env: { 'GEM_HOME' => "/g/#{'x' * 900}" })
178
183
 
179
- expect { cron.install(item) }.to raise_error(RubyRaider::Flows::Error, /crontab line is \d{4} bytes, over 990/)
184
+ expect { cron.install(item) }.to raise_error(RubyRaider::Schedules::Error, /crontab line is \d{4} bytes, over 990/)
180
185
  expect(runner.calls).to be_empty
181
186
  end
182
187
 
183
188
  it 'raises with the scheduler error when crontab -l fails for another reason' do
184
189
  runner.answers['-l'] = ["crontab: permission denied\e[2J", 1]
185
190
 
186
- expect { cron.install(schedule) }.to raise_error(RubyRaider::Flows::Error, /cron could not read .*permission denied\?\[2J/)
191
+ expect { cron.install(schedule) }.to raise_error(RubyRaider::Schedules::Error, /cron could not read .*permission denied\?\[2J/)
187
192
  expect(runner.calls).to eq([%w[crontab -l]])
188
193
  end
189
194
 
@@ -191,7 +196,7 @@ RSpec.describe RubyRaider::Flows::Os::Cron do # rubocop:disable RSpec/FilePath
191
196
  runner.table = "1 * * * * a\n"
192
197
  runner.answers['-'] = ['crontab: errors in crontab file', 1]
193
198
 
194
- expect { cron.install(schedule) }.to raise_error(RubyRaider::Flows::Error, /cron could not save .*errors in crontab file/)
199
+ expect { cron.install(schedule) }.to raise_error(RubyRaider::Schedules::Error, /cron could not save .*errors in crontab file/)
195
200
  expect(runner.table).to eq("1 * * * * a\n")
196
201
  end
197
202
  end
@@ -217,7 +222,7 @@ RSpec.describe RubyRaider::Flows::Os::Cron do # rubocop:disable RSpec/FilePath
217
222
  it 'keeps an unrelated line that follows an orphaned tag' do
218
223
  item = schedule
219
224
  other = schedule(name: 'nightly')
220
- runner.table = "# raider-flow #{item.os_id}\n#{cron.line(other)}\n1 * * * * a\n"
225
+ runner.table = "# raider-schedule #{item.os_id}\n#{cron.line(other)}\n1 * * * * a\n"
221
226
 
222
227
  expect(cron.remove(item.os_id)).to be(true)
223
228
  expect(runner.table).to eq("#{cron.line(other)}\n1 * * * * a\n")
@@ -226,7 +231,7 @@ RSpec.describe RubyRaider::Flows::Os::Cron do # rubocop:disable RSpec/FilePath
226
231
  it 'keeps a line under its tag that is not a schedule run' do
227
232
  item = schedule
228
233
  manual = cron.line(item).sub(' --trigger schedule', ' --trigger manual')
229
- runner.table = "# raider-flow #{item.os_id}\n#{manual}\n"
234
+ runner.table = "# raider-schedule #{item.os_id}\n#{manual}\n"
230
235
 
231
236
  expect(cron.remove(item.os_id)).to be(true)
232
237
  expect(runner.table).to eq("#{manual}\n")
@@ -234,14 +239,14 @@ RSpec.describe RubyRaider::Flows::Os::Cron do # rubocop:disable RSpec/FilePath
234
239
 
235
240
  it 'removes a tag line left without its line' do
236
241
  item = schedule
237
- runner.table = "1 * * * * a\n# raider-flow #{item.os_id}"
242
+ runner.table = "1 * * * * a\n# raider-schedule #{item.os_id}"
238
243
 
239
244
  expect(cron.remove(item.os_id)).to be(true)
240
245
  expect(runner.table).to eq("1 * * * * a\n")
241
246
  end
242
247
 
243
248
  it 'is false and writes nothing when its block is not there' do
244
- runner.table = "1 * * * * a\n# raider-flow #{schedule.os_id}x\n"
249
+ runner.table = "1 * * * * a\n# raider-schedule #{schedule.os_id}x\n"
245
250
 
246
251
  expect(cron.remove(schedule.os_id)).to be(false)
247
252
  expect(runner.calls).to eq([%w[crontab -l]])
@@ -251,8 +256,8 @@ RSpec.describe RubyRaider::Flows::Os::Cron do # rubocop:disable RSpec/FilePath
251
256
  expect(cron.remove(schedule.os_id)).to be(false)
252
257
  end
253
258
 
254
- it 'refuses an os_id that is not a flow schedule id' do
255
- expect { cron.remove("x\n* * * * * evil") }.to raise_error(RubyRaider::Flows::Error, /Invalid os_id/)
259
+ it 'refuses an os_id that is not a schedule id' do
260
+ expect { cron.remove("x\n* * * * * evil") }.to raise_error(RubyRaider::Schedules::Error, /Invalid os_id/)
256
261
  expect(runner.calls).to be_empty
257
262
  end
258
263
  end
@@ -2,12 +2,12 @@
2
2
 
3
3
  require 'tmpdir'
4
4
  require 'rexml/document'
5
- require_relative '../../../../lib/utilities/flows/os/launchd'
5
+ require_relative '../../../../lib/utilities/schedules/os/launchd'
6
+ require_relative '../../../../lib/utilities/schedules/schedule'
6
7
 
7
- # Flow specs live under spec/utilities/flows, next to the code in lib/utilities/flows
8
8
  # The fake runner, temp home and project folders sit next to the adapter under test
9
9
  # rubocop:disable RSpec/MultipleMemoizedHelpers
10
- RSpec.describe RubyRaider::Flows::Os::Launchd do # rubocop:disable RSpec/FilePath
10
+ RSpec.describe RubyRaider::Schedules::Os::Launchd do # rubocop:disable RSpec/FilePath
11
11
  # Records every command and answers from a table (success unless told otherwise); never runs anything
12
12
  let(:fake_runner_class) do
13
13
  Class.new do
@@ -24,7 +24,7 @@ RSpec.describe RubyRaider::Flows::Os::Launchd do # rubocop:disable RSpec/FilePat
24
24
  @stdin << stdin_data
25
25
  @seen&.call(argv)
26
26
  stderr, status = answers.fetch(argv[1], ['', 0])
27
- RubyRaider::Flows::Os::CommandRunner::Result.new(stdout: '', stderr: stderr, exitstatus: status)
27
+ RubyRaider::Schedules::Os::CommandRunner::Result.new(stdout: '', stderr: stderr, exitstatus: status)
28
28
  end
29
29
 
30
30
  def on_call(&block) = @seen = block
@@ -32,18 +32,20 @@ RSpec.describe RubyRaider::Flows::Os::Launchd do # rubocop:disable RSpec/FilePat
32
32
  end
33
33
 
34
34
  let(:hostile) { %q('; rm -rf ~; ' </string> %h $(id) " \\ &) }
35
- let(:tmp) { File.realpath(Dir.mktmpdir('flows-launchd')) }
35
+ let(:tmp) { File.realpath(Dir.mktmpdir('schedules-launchd')) }
36
36
  let(:home) { File.join(tmp, 'home') }
37
37
  let(:project_dir) { File.join(tmp, 'plain') }
38
38
  let(:runner) { fake_runner_class.new }
39
- let(:launchd) { described_class.new(runner: runner, home: home, uid: 501) }
39
+ let(:launchd) { described_class.new(kind: RubyRaider::Schedules::Kind::TESTS, runner: runner, home: home, uid: 501) }
40
40
  let(:now) { Time.utc(2026, 11, 9, 10, 0, 0) }
41
41
  let(:agents) { File.join(home, 'Library', 'LaunchAgents') }
42
42
 
43
- def schedule(dir: project_dir, **options)
43
+ def schedule(dir: project_dir, gem_env: {}, bundle: '/opt/ruby/bin/bundle', **options)
44
44
  FileUtils.mkdir_p(dir)
45
- project = RubyRaider::Flows::Project.new(File.realpath(dir))
46
- RubyRaider::Flows::Schedule.build(project: project, name: 'report', every: 'day', at: '08:00', now: now, **options)
45
+ # Hermetic: the GEM_HOME/GEM_PATH and bundle this spec runs with never reach the entry; a spec passes its own
46
+ allow(RubyRaider::Schedules::Schedule).to receive_messages(captured_gem_env: gem_env, bundle_path: bundle)
47
+ project = RubyRaider::Schedules::Project.new(File.realpath(dir))
48
+ RubyRaider::Schedules::Schedule.build(project: project, name: 'report', every: 'day', at: '08:00', now: now, **options)
47
49
  end
48
50
 
49
51
  # plist XML back to Ruby: dict -> Hash, array -> Array, string -> String, integer -> Integer
@@ -116,7 +118,7 @@ RSpec.describe RubyRaider::Flows::Os::Launchd do # rubocop:disable RSpec/FilePat
116
118
  end
117
119
 
118
120
  it 'round-trips every field and never sets RunAtLoad' do
119
- item = schedule(retries: 2, inputs: %w[env=staging])
121
+ item = schedule(retries: 2, selection: RubyRaider::Schedules::Selection.new(kind: 'tag', tag: 'smoke'))
120
122
 
121
123
  expect(parsed(launchd.plist(item))).to eq(
122
124
  'Label' => item.os_id, 'ProgramArguments' => item.argv, 'WorkingDirectory' => item.working_directory,
@@ -126,9 +128,7 @@ RSpec.describe RubyRaider::Flows::Os::Launchd do # rubocop:disable RSpec/FilePat
126
128
  end
127
129
 
128
130
  it 'keeps GEM_HOME and GEM_PATH in the environment when they were captured' do
129
- allow(RubyRaider::Flows::Schedule).to receive(:captured_gem_env).and_return('GEM_HOME' => '/g/home', 'GEM_PATH' => '/g/path')
130
-
131
- env = parsed(launchd.plist(schedule))['EnvironmentVariables']
131
+ env = parsed(launchd.plist(schedule(gem_env: { 'GEM_HOME' => '/g/home', 'GEM_PATH' => '/g/path' })))['EnvironmentVariables']
132
132
 
133
133
  expect(env.slice('LANG', 'GEM_HOME', 'GEM_PATH')).to eq('LANG' => 'en_US.UTF-8', 'GEM_HOME' => '/g/home', 'GEM_PATH' => '/g/path')
134
134
  end
@@ -149,15 +149,17 @@ RSpec.describe RubyRaider::Flows::Os::Launchd do # rubocop:disable RSpec/FilePat
149
149
  expect(days).to eq([0, 1, 6].map { |day| { 'Weekday' => day, 'Hour' => 8, 'Minute' => 0 } })
150
150
  end
151
151
 
152
- describe 'with hostile values in an input and in the project path' do
153
- let(:item) { schedule(dir: File.join(tmp, "p#{hostile}<", "string> #{hostile}"), inputs: ["note=#{hostile}"]) }
152
+ describe 'with hostile values in the bundle path, GEM_HOME and the project path' do
153
+ let(:item) do
154
+ schedule(dir: File.join(tmp, "p#{hostile}<", "string> #{hostile}"), bundle: "/opt/#{hostile}/bundle", gem_env: { 'GEM_HOME' => "/g/#{hostile}" })
155
+ end
154
156
  let(:text) { launchd.plist(item) }
155
157
 
156
158
  it 'round-trips them exactly' do
157
159
  data = parsed(text)
158
160
 
159
161
  expect([data['ProgramArguments'], data['WorkingDirectory'], data['StandardOutPath']]).to eq([item.argv, item.working_directory, item.log_path])
160
- expect(data['ProgramArguments'].last).to eq("note=#{hostile}")
162
+ expect([data['ProgramArguments'][1], data['EnvironmentVariables']['GEM_HOME']]).to eq(["/opt/#{hostile}/bundle", "/g/#{hostile}"])
161
163
  expect(item.working_directory).to include('</string>', "'; rm -rf ~; '", '$(id)', '%h', '"', '\\', '&')
162
164
  end
163
165
 
@@ -203,7 +205,7 @@ RSpec.describe RubyRaider::Flows::Os::Launchd do # rubocop:disable RSpec/FilePat
203
205
  item = schedule
204
206
  runner.answers['bootstrap'] = ["Bootstrap failed: 5: Input/output error\e[2J\n", 5]
205
207
 
206
- expect { launchd.install(item) }.to raise_error(RubyRaider::Flows::Error, %r{launchd.*Bootstrap failed: 5: Input/output error\?\[2J})
208
+ expect { launchd.install(item) }.to raise_error(RubyRaider::Schedules::Error, %r{launchd.*Bootstrap failed: 5: Input/output error\?\[2J})
207
209
  expect(Dir.children(agents)).to be_empty
208
210
  end
209
211
 
@@ -213,14 +215,14 @@ RSpec.describe RubyRaider::Flows::Os::Launchd do # rubocop:disable RSpec/FilePat
213
215
  allow(File).to receive(:unlink).and_call_original
214
216
  allow(File).to receive(:unlink).with(plist_path(item)).and_raise(Errno::EACCES)
215
217
 
216
- expect { launchd.install(item) }.to raise_error(RubyRaider::Flows::Error, /Bootstrap failed: 5; also: Could not remove .*Permission denied/)
218
+ expect { launchd.install(item) }.to raise_error(RubyRaider::Schedules::Error, /Bootstrap failed: 5; also: Could not remove .*Permission denied/)
217
219
  end
218
220
 
219
- it 'raises a flow error and runs nothing when it can not write the plist' do
221
+ it 'raises a schedule error and runs nothing when it can not write the plist' do
220
222
  FileUtils.mkdir_p(File.dirname(agents))
221
223
  File.write(agents, 'not a folder')
222
224
 
223
- expect { launchd.install(schedule) }.to raise_error(RubyRaider::Flows::Error, /Could not write .*LaunchAgents/)
225
+ expect { launchd.install(schedule) }.to raise_error(RubyRaider::Schedules::Error, /Could not write .*LaunchAgents/)
224
226
  expect([runner.calls, File.read(agents)]).to eq([[], 'not a folder'])
225
227
  end
226
228
 
@@ -230,20 +232,31 @@ RSpec.describe RubyRaider::Flows::Os::Launchd do # rubocop:disable RSpec/FilePat
230
232
  File.write(plist_path(item), 'old')
231
233
  runner.answers['-lint'] = ['bad plist', 1]
232
234
 
233
- expect { launchd.install(item) }.to raise_error(RubyRaider::Flows::Error, /bad plist/)
235
+ expect { launchd.install(item) }.to raise_error(RubyRaider::Schedules::Error, /bad plist/)
234
236
  expect([runner.calls.map { |argv| argv[1] }, Dir.children(agents), File.read(plist_path(item))])
235
237
  .to eq([['-lint'], [File.basename(plist_path(item))], 'old'])
236
238
  end
237
239
  end
238
240
 
239
241
  describe '#remove' do
240
- it 'boots out and deletes the plist' do
242
+ it 'deletes the plist, then boots out' do
241
243
  item = schedule
242
244
  FileUtils.mkdir_p(agents)
243
245
  File.write(plist_path(item), 'x')
246
+ seen = []
247
+ runner.on_call { |argv| seen << [argv[1], File.exist?(plist_path(item))] }
244
248
 
245
249
  expect(launchd.remove(item.os_id)).to be(true)
246
- expect([runner.calls, File.exist?(plist_path(item))]).to eq([[['/bin/launchctl', 'bootout', "gui/501/#{item.os_id}"]], false])
250
+ expect([runner.calls, seen]).to eq([[['/bin/launchctl', 'bootout', "gui/501/#{item.os_id}"]], [['bootout', false]]])
251
+ end
252
+
253
+ it 'is true when only the plist was there' do
254
+ item = schedule
255
+ FileUtils.mkdir_p(agents)
256
+ File.write(plist_path(item), 'x')
257
+ runner.answers['bootout'] = ['Boot-out failed: 3: No such process', 3]
258
+
259
+ expect([launchd.remove(item.os_id), File.exist?(plist_path(item))]).to eq([true, false])
247
260
  end
248
261
 
249
262
  it 'is true when only the service was loaded' do
@@ -260,12 +273,12 @@ RSpec.describe RubyRaider::Flows::Os::Launchd do # rubocop:disable RSpec/FilePat
260
273
  item = schedule
261
274
  allow(File).to receive(:unlink).and_raise(Errno::EACCES)
262
275
 
263
- expect { launchd.remove(item.os_id) }.to raise_error(RubyRaider::Flows::Error, /Could not remove .*Permission denied/)
264
- expect(runner.calls).to eq([['/bin/launchctl', 'bootout', "gui/501/#{item.os_id}"]])
276
+ expect { launchd.remove(item.os_id) }.to raise_error(RubyRaider::Schedules::Error, /Could not remove .*Permission denied/)
277
+ expect(runner.calls).to be_empty
265
278
  end
266
279
 
267
- it 'refuses an os_id that is not a flow schedule id' do
268
- expect { launchd.remove('../../evil') }.to raise_error(RubyRaider::Flows::Error, /Invalid os_id/)
280
+ it 'refuses an os_id that is not a schedule id' do
281
+ expect { launchd.remove('../../evil') }.to raise_error(RubyRaider::Schedules::Error, /Invalid os_id/)
269
282
  expect(runner.calls).to be_empty
270
283
  end
271
284
  end
@@ -278,8 +291,8 @@ RSpec.describe RubyRaider::Flows::Os::Launchd do # rubocop:disable RSpec/FilePat
278
291
  expect(runner.calls).to eq([['/bin/launchctl', 'print', "gui/501/#{item.os_id}"]])
279
292
  end
280
293
 
281
- it 'refuses an os_id that is not a flow schedule id and runs nothing' do
282
- expect { launchd.present?('../../evil') }.to raise_error(RubyRaider::Flows::Error, /Invalid os_id/)
294
+ it 'refuses an os_id that is not a schedule id and runs nothing' do
295
+ expect { launchd.present?('../../evil') }.to raise_error(RubyRaider::Schedules::Error, /Invalid os_id/)
283
296
  expect(runner.calls).to be_empty
284
297
  end
285
298
 
@@ -1,11 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'tmpdir'
4
- require_relative '../../../../lib/utilities/flows/os/linux'
4
+ require_relative '../../../../lib/utilities/schedules/os/linux'
5
+ require_relative '../../../../lib/utilities/schedules/schedule'
5
6
 
6
- # Flow specs live under spec/utilities/flows, next to the code in lib/utilities/flows
7
7
  # rubocop:disable RSpec/MultipleMemoizedHelpers
8
- RSpec.describe RubyRaider::Flows::Os::Linux do # rubocop:disable RSpec/FilePath
8
+ RSpec.describe RubyRaider::Schedules::Os::Linux do # rubocop:disable RSpec/FilePath
9
9
  # Plays systemctl --user and crontab: answers keyed by "systemctl verb" or "crontab -l"; never runs anything
10
10
  let(:fake_runner_class) do
11
11
  Class.new do
@@ -24,34 +24,36 @@ RSpec.describe RubyRaider::Flows::Os::Linux do # rubocop:disable RSpec/FilePath
24
24
  missing = key == 'crontab -l' && table.nil?
25
25
  stderr, status = answers.fetch(key) { missing ? ['no crontab for raider', 1] : ['', 0] }
26
26
  self.table = stdin_data if key == 'crontab -' && status.zero?
27
- RubyRaider::Flows::Os::CommandRunner::Result.new(stdout: key == 'crontab -l' ? table.to_s : '', stderr: stderr, exitstatus: status)
27
+ RubyRaider::Schedules::Os::CommandRunner::Result.new(stdout: key == 'crontab -l' ? table.to_s : '', stderr: stderr, exitstatus: status)
28
28
  end
29
29
  end
30
30
  end
31
31
 
32
- let(:tmp) { File.realpath(Dir.mktmpdir('flows-linux')) }
32
+ let(:tmp) { File.realpath(Dir.mktmpdir('schedules-linux')) }
33
33
  let(:runner) { fake_runner_class.new }
34
34
  let(:item) do
35
- project = RubyRaider::Flows::Project.new(tmp)
36
- RubyRaider::Flows::Schedule.build(project: project, name: 'report', every: 'day', at: '08:00', now: Time.utc(2026, 11, 9))
35
+ # Hermetic: the GEM_HOME/GEM_PATH and bundle this spec runs with never reach the entry
36
+ allow(RubyRaider::Schedules::Schedule).to receive_messages(captured_gem_env: {}, bundle_path: '/opt/ruby/bin/bundle')
37
+ project = RubyRaider::Schedules::Project.new(tmp)
38
+ RubyRaider::Schedules::Schedule.build(project: project, name: 'report', every: 'day', at: '08:00', now: Time.utc(2026, 11, 9))
37
39
  end
38
- let(:cron_block) { "# raider-flow #{item.os_id}\n#{RubyRaider::Flows::Os::Cron.new.line(item)}\n" }
40
+ let(:cron_block) { "# raider-schedule #{item.os_id}\n#{RubyRaider::Schedules::Os::Cron.new(kind: RubyRaider::Schedules::Kind::TESTS).line(item)}\n" }
39
41
  let(:no_bus) { ['Failed to connect to bus', 1] }
40
42
 
41
43
  # The systemd units go to a temp home, never the real one
42
- def pick = described_class.new(runner: runner, systemd: RubyRaider::Flows::Os::Systemd.new(runner: runner, home: tmp, config_home: nil))
44
+ def pick = described_class.new(kind: RubyRaider::Schedules::Kind::TESTS, runner: runner, systemd: RubyRaider::Schedules::Os::Systemd.new(kind: RubyRaider::Schedules::Kind::TESTS, runner: runner, home: tmp, config_home: nil))
43
45
 
44
46
  after { FileUtils.rm_rf(tmp) }
45
47
 
46
48
  it 'uses systemd when the user manager answers' do
47
49
  linux = pick
48
50
 
49
- expect([linux.label, linux.missed_runs_note, described_class.pick(runner).label]).to eq(['systemd', nil, 'systemd'])
51
+ expect([linux.label, linux.missed_runs_note, described_class.pick(runner, kind: RubyRaider::Schedules::Kind::TESTS).label]).to eq(['systemd', nil, 'systemd'])
50
52
  expect(runner.calls.uniq).to eq([%w[systemctl --user show-environment]])
51
53
  end
52
54
 
53
55
  it 'falls back to cron when it does not, or systemctl is missing' do
54
- [no_bus, ['Could not run systemctl', RubyRaider::Flows::Os::CommandRunner::MISSING]].each do |answer|
56
+ [no_bus, ['Could not run systemctl', RubyRaider::Schedules::Os::CommandRunner::MISSING]].each do |answer|
55
57
  runner.answers['systemctl show-environment'] = answer
56
58
 
57
59
  expect([pick.label, pick.missed_runs_note]).to eq(['cron', '(cron: missed runs are skipped)'])
@@ -111,10 +113,22 @@ RSpec.describe RubyRaider::Flows::Os::Linux do # rubocop:disable RSpec/FilePath
111
113
  runner.table = "#{cron_block}1 * * * * a\n"
112
114
 
113
115
  expect { pick.remove(item.os_id) }
114
- .to raise_error(RubyRaider::Flows::Error, 'This schedule is a systemd timer; run raider flow unschedule report ' \
115
- 'from a session where systemctl --user works')
116
+ .to raise_error(RubyRaider::Schedules::Error, 'This schedule is a systemd timer; run raider schedule remove report ' \
117
+ 'from a session where systemctl --user works')
116
118
  expect([runner.table, File.exist?(File.join(units, "#{item.os_id}.timer"))]).to eq(["1 * * * * a\n", true])
117
119
  end
120
+
121
+ it 'names raider schedule list, with no name, for a 4.1 timer' do
122
+ legacy = RubyRaider::Schedules::Kind::FLOW_LEGACY
123
+ os_id = "#{legacy.os_id_prefix}.0123456789.report"
124
+ File.write(File.join(units, "#{os_id}.timer"), 'x')
125
+ linux = described_class.new(kind: legacy, runner: runner,
126
+ systemd: RubyRaider::Schedules::Os::Systemd.new(kind: legacy, runner: runner, home: tmp, config_home: nil))
127
+
128
+ expect { linux.remove(os_id) }
129
+ .to raise_error(RubyRaider::Schedules::Error, 'This schedule is a systemd timer; run raider schedule list ' \
130
+ 'from a session where systemctl --user works')
131
+ end
118
132
  end
119
133
 
120
134
  it 'does not look for systemd unit files when systemd is the primary' do