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,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tmpdir'
4
+ require_relative '../../../lib/utilities/flows/run_log'
5
+ require_relative '../../../lib/utilities/flows/project'
6
+
7
+ # Flow specs live under spec/utilities/flows, next to the code in lib/utilities/flows
8
+ RSpec.describe RubyRaider::Flows::RunLog do # rubocop:disable RSpec/FilePath
9
+ let(:dir) { File.realpath(Dir.mktmpdir('flows-log')) }
10
+ let(:log) { described_class.new(RubyRaider::Flows::Project.new(dir), 'report') }
11
+ let(:file) { File.join(dir, '.raider/flows/report/runs.jsonl') }
12
+
13
+ before { FileUtils.mkdir_p(File.dirname(file)) }
14
+
15
+ after { FileUtils.rm_rf(dir) }
16
+
17
+ it 'skips broken and non-object lines' do
18
+ File.write(file, %({"status":"failed"}\nnot json\n[1]\n{"status":"completed","run_id":"a"}\n))
19
+
20
+ expect([log.lines.size, log.last['status'], log.find('a')['status']]).to eq([2, 'completed', 'completed'])
21
+ end
22
+
23
+ it 'reads only the last megabyte of a big log, from a whole line' do
24
+ File.write(file, %({"status":"old","pad":"#{'x' * 2_000_000}"}\n{"status":"completed"}\n))
25
+
26
+ expect(log.lines).to eq([{ 'status' => 'completed' }])
27
+ end
28
+
29
+ it 'does not follow a linked log' do
30
+ File.write(File.join(dir, 'other.jsonl'), %({"status":"completed"}\n))
31
+ File.symlink(File.join(dir, 'other.jsonl'), file)
32
+
33
+ expect(log.lines).to eq([])
34
+ end
35
+
36
+ it 'lists the last run that ran, not a busy line after it' do
37
+ FileUtils.mkdir_p(File.join(dir, 'flows'))
38
+ File.write(File.join(dir, 'flows/report_flow.rb'), '')
39
+ File.write(file, %({"status":"failed"}\n{"status":"busy"}\n))
40
+
41
+ expect(described_class.summaries(RubyRaider::Flows::Project.new(dir)).first['last']['status']).to eq('failed')
42
+ end
43
+
44
+ it 'is empty without a log' do
45
+ expect(log.last).to be_nil
46
+ end
47
+ end
@@ -0,0 +1,313 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'stringio'
5
+ require 'tmpdir'
6
+ require_relative '../../../lib/utilities/flows/runner'
7
+
8
+ # Flow specs live under spec/utilities/flows, next to the code in lib/utilities/flows
9
+ # The fake spawner needs its call, sleep and outcome lists next to the project and runner
10
+ # rubocop:disable RSpec/MultipleMemoizedHelpers
11
+ RSpec.describe RubyRaider::Flows::Runner do # rubocop:disable RSpec/FilePath
12
+ let(:dir) { File.realpath(Dir.mktmpdir('flows-runner')) }
13
+ let(:project) { RubyRaider::Flows::Project.new(dir) }
14
+ let(:out) { StringIO.new }
15
+ let(:calls) { [] }
16
+ let(:sleeps) { [] }
17
+ let(:outcomes) { [] }
18
+
19
+ # Stands in for `bundle exec rspec`: records the call and appends the line flows/support/run.rb would
20
+ let(:spawner) do
21
+ lambda do |env, argv, chdir|
22
+ calls << { env: env, argv: argv, chdir: chdir }
23
+ line = outcomes.shift || {}
24
+ unless line == :nothing
25
+ log = File.join(dir, '.raider/flows/report/runs.jsonl')
26
+ FileUtils.mkdir_p(File.dirname(log))
27
+ File.write(log, "#{JSON.generate(line_for(env, line))}\n", mode: 'a')
28
+ end
29
+ line == :nothing ? 2 : line.fetch(:code, 0)
30
+ end
31
+ end
32
+
33
+ let(:runner) { described_class.new(project, out: out, spawner: spawner, sleeper: ->(seconds) { sleeps << seconds }) }
34
+
35
+ def line_for(env, line)
36
+ { 'started_at' => '2026-11-09T08:00:00.000Z', 'finished_at' => '2026-11-09T08:00:12.300Z', 'status' => 'completed',
37
+ 'attempt' => env['RAIDER_FLOW_ATTEMPT'].to_i, 'trigger' => env['RAIDER_FLOW_TRIGGER'],
38
+ 'run_id' => env['RAIDER_FLOW_RUN_ID'], 'results_dir' => 'flow-results/report/20261109T080000Z' }
39
+ .merge(line.except(:code).transform_keys(&:to_s))
40
+ end
41
+
42
+ def log_statuses = File.readlines(File.join(dir, '.raider/flows/report/runs.jsonl')).map { |l| JSON.parse(l)['status'] }
43
+
44
+ before do
45
+ %w[flows/flow_helper.rb flows/support/run.rb flows/report_flow.rb].each do |file|
46
+ FileUtils.mkdir_p(File.dirname(File.join(dir, file)))
47
+ File.write(File.join(dir, file), '')
48
+ end
49
+ FileUtils.mkdir_p(File.join(dir, 'flow-results/report/20261109T080000Z'))
50
+ end
51
+
52
+ after { FileUtils.rm_rf(dir) }
53
+
54
+ it 'runs the flow file through bundle exec rspec as an argv array, from the project root' do
55
+ runner.run('report')
56
+
57
+ expect(calls.first[:argv]).to eq(['bundle', 'exec', 'rspec', 'flows/report_flow.rb', '--tag', 'flow'])
58
+ expect(calls.first[:chdir]).to eq(dir)
59
+ end
60
+
61
+ it 'exits 0 and prints the time and results folder when the flow completed' do
62
+ expect(runner.run('report')).to eq(0)
63
+ expect(out.string).to eq("Flow report completed in 12.3 s\nResults: flow-results/report/20261109T080000Z\n")
64
+ end
65
+
66
+ it 'exits 1 and names the step when a step failed' do
67
+ outcomes << { status: 'failed', code: 1, failed_step: { 'number' => 4, 'title' => 'click "Export"', 'line' => 9 } }
68
+
69
+ expect(runner.run('report')).to eq(1)
70
+ expect(out.string).to start_with(%(Flow report failed at step 4 (click "Export")\n))
71
+ end
72
+
73
+ it 'names the line when the flow has no recorded steps' do
74
+ outcomes << { status: 'failed', code: 1, failed_step: { 'title' => 'exports', 'line' => 9 } }
75
+
76
+ runner.run('report')
77
+
78
+ expect(out.string).to start_with("Flow report failed at line 9 (exports)\n")
79
+ end
80
+
81
+ it 'exits 2 on a setup error' do
82
+ outcomes << { status: 'setup_error', code: 1 }
83
+
84
+ expect(runner.run('report')).to eq(2)
85
+ expect(out.string).to start_with("Flow report could not start: see the output above\n")
86
+ end
87
+
88
+ it 'exits 2 when the run left no log line' do
89
+ outcomes << :nothing
90
+
91
+ expect(runner.run('report')).to eq(2)
92
+ expect(out.string).to eq("Flow report did not record a run (exit 2): see the output above\n")
93
+ end
94
+
95
+ it 'exits 3 without retrying when the flow is already running' do
96
+ outcomes << { status: 'busy', code: 3, results_dir: nil }
97
+
98
+ expect(runner.run('report', retries: 2)).to eq(3)
99
+ expect([calls.size, sleeps]).to eq([1, []])
100
+ expect(out.string).to eq("Flow report is already running\n")
101
+ end
102
+
103
+ it 'retries the whole flow 30 s later and exits 0 once it completes' do
104
+ outcomes.push({ status: 'failed', code: 1 }, { status: 'completed' })
105
+
106
+ expect(runner.run('report', retries: 2)).to eq(0)
107
+ expect(sleeps).to eq([30])
108
+ expect(calls.map { |call| call[:env]['RAIDER_FLOW_ATTEMPT'] }).to eq(%w[1 2])
109
+ expect(log_statuses).to eq(%w[failed completed])
110
+ expect(out.string).to include('Retrying in 30 s (attempt 2 of 3)')
111
+ end
112
+
113
+ it 'gives up after the last retry with the last status' do
114
+ outcomes.push({ status: 'failed', code: 1 }, { status: 'setup_error', code: 1 })
115
+
116
+ expect(runner.run('report', retries: 1)).to eq(2)
117
+ expect(calls.size).to eq(2)
118
+ end
119
+
120
+ it 'reads each attempt by its own run id, not the last line in the log' do
121
+ outcomes << { status: 'completed', run_id: 'someone-else' }
122
+
123
+ expect(runner.run('report')).to eq(2)
124
+ end
125
+
126
+ it 'passes inputs, trigger and headless through the environment' do
127
+ runner.run('report', inputs: ['who=ada lovelace', 'note=a=b'], trigger: 'schedule')
128
+ env = calls.first[:env]
129
+
130
+ expect(env).to include('RAIDER_FLOW_NAME' => 'report', 'RAIDER_FLOW_TRIGGER' => 'schedule', 'RAIDER_FLOW_HEADED' => nil,
131
+ 'HEADLESS' => 'true', 'RETRY_COUNT' => '0',
132
+ 'RAIDER_INPUT_WHO' => 'ada lovelace', 'RAIDER_INPUT_NOTE' => 'a=b')
133
+ expect(env['RAIDER_FLOW_RUN_ID']).to match(/\A[0-9a-f]{16}\z/)
134
+ expect(calls.first[:argv].join(' ')).not_to include('ada')
135
+ end
136
+
137
+ it 'unsets HEADLESS and sets RAIDER_FLOW_HEADED for --headed' do
138
+ runner.run('report', headed: true)
139
+
140
+ expect(calls.first[:env]).to include('RAIDER_FLOW_HEADED' => '1', 'HEADLESS' => nil)
141
+ end
142
+
143
+ it 'exits 2 for a status it does not know' do
144
+ outcomes << { status: 'running' }
145
+
146
+ expect(runner.run('report')).to eq(2)
147
+ end
148
+
149
+ it 'unsets RAIDER_INPUT_* left in the shell and points bundler at the project Gemfile' do
150
+ ENV['RAIDER_INPUT_STALE'] = 'leak'
151
+ runner.run('report')
152
+
153
+ expect(calls.first[:env]).to include('RAIDER_INPUT_STALE' => nil, 'BUNDLE_GEMFILE' => File.join(dir, 'Gemfile'))
154
+ ensure
155
+ ENV.delete('RAIDER_INPUT_STALE')
156
+ end
157
+
158
+ it 'passes the name-built path, not the target of a linked flow file' do
159
+ File.delete(File.join(dir, 'flows/report_flow.rb'))
160
+ File.write(File.join(dir, 'flows/a&calc.rb'), '')
161
+ File.symlink(File.join(dir, 'flows/a&calc.rb'), File.join(dir, 'flows/report_flow.rb'))
162
+
163
+ runner.run('report')
164
+
165
+ expect(calls.first[:argv]).to include('flows/report_flow.rb')
166
+ end
167
+
168
+ it 'prints control characters from the log as ?' do
169
+ outcomes << { status: 'failed', code: 1, failed_step: { 'title' => "x\e[2Jy\nFlow fake completed", 'line' => 1 } }
170
+
171
+ runner.run('report')
172
+
173
+ expect(out.string).to start_with("Flow report failed at line 1 (x?[2Jy?Flow fake completed)\n")
174
+ end
175
+
176
+ describe '--notify' do
177
+ let(:notes) { [] }
178
+ let(:notify_result) { [true] }
179
+ let(:notifier) do
180
+ sent = notes
181
+ result = notify_result
182
+ Class.new do
183
+ define_method(:notify) do |title, message|
184
+ sent << [title, message]
185
+ result.first
186
+ end
187
+ end.new
188
+ end
189
+ let(:notifying) { described_class.new(project, out: out, spawner: spawner, sleeper: ->(_) {}, notifier: notifier) }
190
+
191
+ it 'sends one notification naming the failed step after the last attempt' do
192
+ 2.times { outcomes << { status: 'failed', code: 1, failed_step: { 'number' => 3, 'title' => "Open\e[2J report" } } }
193
+
194
+ expect(notifying.run('report', retries: 1, notify: true)).to eq(1)
195
+ expect(notes).to eq([['Ruby Raider flow report', "Failed at step 3 (Open\e[2J report)"]])
196
+ end
197
+
198
+ it 'points a scheduled run at the scheduler log on a setup error or when no line was recorded' do
199
+ allow(Gem).to receive(:win_platform?).and_return(false)
200
+ outcomes.push({ status: 'setup_error', code: 1 }, :nothing)
201
+ 2.times { notifying.run('report', trigger: 'schedule', notify: true) }
202
+
203
+ expect(notes.map(&:last)).to eq(['Could not start: see .raider/flows/report/scheduler.log'] * 2)
204
+ end
205
+
206
+ it 'points at the run output when there is no scheduler log: a manual run, or Windows' do
207
+ outcomes.push({ status: 'setup_error', code: 1 }, { status: 'setup_error', code: 1 })
208
+ allow(Gem).to receive(:win_platform?).and_return(false)
209
+ notifying.run('report', notify: true)
210
+ allow(Gem).to receive(:win_platform?).and_return(true)
211
+ notifying.run('report', trigger: 'schedule', notify: true)
212
+
213
+ expect(notes.map(&:last)).to eq(['Could not start: see the run output'] * 2)
214
+ end
215
+
216
+ it 'stays quiet when the run was stopped' do
217
+ outcomes << { status: 'failed', code: 1 }
218
+ stopping = lambda do |*args|
219
+ described_class.stopped = true
220
+ spawner.call(*args)
221
+ end
222
+ described_class.new(project, out: out, spawner: stopping, sleeper: ->(_) {}, notifier: notifier).run('report', notify: true)
223
+
224
+ expect(notes).to eq([])
225
+ ensure
226
+ described_class.stopped = false
227
+ end
228
+
229
+ it 'stays quiet when the flow completed or was busy, and without --notify' do
230
+ outcomes.push({}, { status: 'busy', code: 3 }, { status: 'failed', code: 1 })
231
+ notifying.run('report', notify: true)
232
+ notifying.run('report', notify: true)
233
+ notifying.run('report')
234
+
235
+ expect(notes).to eq([])
236
+ end
237
+
238
+ it 'keeps the exit code when the notification fails' do
239
+ notify_result[0] = false
240
+ outcomes << { status: 'failed', code: 1 }
241
+
242
+ expect([notifying.run('report', notify: true), notes.size]).to eq([1, 1])
243
+ end
244
+ end
245
+
246
+ describe 'refusals (exit 2 through Flows::Error, nothing spawned)' do
247
+ def refuse(name, message, **options)
248
+ expect { runner.run(name, **options) }.to raise_error(RubyRaider::Flows::Error, message)
249
+ expect(calls).to be_empty
250
+ end
251
+
252
+ it('rejects an input name that is not lower snake case') { refuse('report', /Invalid input "WHO"/, inputs: ['WHO=x']) }
253
+ it('rejects an input without a value') { refuse('report', /Invalid input "who"/, inputs: ['who']) }
254
+ it('rejects retries above 3') { refuse('report', /Invalid retries 4/, retries: 4) }
255
+ it('rejects an unknown trigger') { refuse('report', /Invalid trigger/, trigger: 'cron') }
256
+ it('rejects an unsafe flow name') { refuse('../report', /Invalid flow name/) }
257
+ it('rejects the reserved name env') { refuse('env', /Invalid flow name "env"/) }
258
+ it('rejects a NUL byte in an input value') { refuse('report', /NUL byte/, inputs: ["who=a\0b"]) }
259
+
260
+ it 'rejects a project without flow init' do
261
+ File.delete(File.join(dir, 'flows/support/run.rb'))
262
+
263
+ refuse('report', /run raider flow init/)
264
+ end
265
+ end
266
+
267
+ describe '.spawn' do
268
+ it 'runs without a shell and returns the exit status' do
269
+ code = described_class.spawn({ 'X' => '; exit 7' }, [RbConfig.ruby, '-e', 'exit(ENV["X"] == "; exit 7" ? 5 : 6)'], dir)
270
+
271
+ expect(code).to eq(5)
272
+ end
273
+
274
+ it 'passes INT or TERM to the flow once, as INT, and marks the run stopped', skip: Gem.win_platform? do
275
+ ready = File.join(dir, 'ready')
276
+ got = File.join(dir, 'got')
277
+ child = "n = 0; trap('INT') { n += 1 }; trap('TERM') { n += 100 }; File.write(#{ready.inspect}, ''); " \
278
+ "sleep 1.5; File.write(#{got.inspect}, n.to_s)"
279
+ Thread.new do
280
+ sleep 0.05 until File.exist?(ready)
281
+ Process.kill('TERM', Process.pid)
282
+ Process.kill('INT', Process.pid)
283
+ end
284
+
285
+ code = described_class.spawn({}, [RbConfig.ruby, '-e', child], dir)
286
+
287
+ expect([code, File.read(got), described_class.stopped]).to eq([0, '1', true])
288
+ end
289
+
290
+ it 'does not retry a stopped run' do
291
+ outcomes.push({ status: 'setup_error', code: 1 }, { status: 'completed' })
292
+ stopping = lambda do |*args|
293
+ described_class.stopped = true
294
+ spawner.call(*args)
295
+ end
296
+ stopped_runner = described_class.new(project, out: out, spawner: stopping, sleeper: ->(seconds) { sleeps << seconds })
297
+
298
+ expect(stopped_runner.run('report', retries: 2)).to eq(2)
299
+ expect([calls.size, sleeps]).to eq([1, []])
300
+ expect(out.string).to include('Flow report was stopped: no retry')
301
+ ensure
302
+ described_class.stopped = false
303
+ end
304
+
305
+ it 'returns nil with one line on stderr when the command is not found' do
306
+ code = nil
307
+
308
+ expect { code = described_class.spawn({}, ['raider-no-such-command'], dir) }.to output(/Could not start raider-no-such-command/).to_stderr
309
+ expect(code).to be_nil
310
+ end
311
+ end
312
+ end
313
+ # rubocop:enable RSpec/MultipleMemoizedHelpers
@@ -0,0 +1,233 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tmpdir'
4
+ require 'digest'
5
+ require_relative '../../../lib/utilities/flows/schedule'
6
+
7
+ # Flow specs live under spec/utilities/flows, next to the code in lib/utilities/flows
8
+ RSpec.describe RubyRaider::Flows::Schedule do # rubocop:disable RSpec/FilePath
9
+ let(:dir) { File.realpath(Dir.mktmpdir('flows-schedule')) }
10
+ let(:project) { RubyRaider::Flows::Project.new(dir) }
11
+ let(:now) { Time.utc(2026, 11, 9, 10, 0, 0) }
12
+
13
+ def build(**options)
14
+ described_class.build(project: project, name: 'report', every: 'day', at: '08:00', now: now, **options)
15
+ end
16
+
17
+ def error_for(**options)
18
+ build(**options)
19
+ rescue RubyRaider::Flows::Error => e
20
+ e.message
21
+ end
22
+
23
+ after { FileUtils.rm_rf(dir) }
24
+
25
+ describe 'rules' do
26
+ it 'reads the hour and minute and spells the rule' do
27
+ schedule = build(at: '18:05')
28
+
29
+ expect([schedule.hour, schedule.minute, schedule.at, schedule.rule_text]).to eq([18, 5, '18:05', 'every day at 18:05'])
30
+ end
31
+
32
+ it 'takes :MM or HH:MM for every hour, ignores the hour and defaults to :00' do
33
+ texts = [':15', '07:15', nil].map { |at| build(every: 'hour', at: at) }.map { |s| [s.at, s.rule_text] }
34
+
35
+ expect(texts).to eq([['00:15', 'every hour at :15'], ['00:15', 'every hour at :15'], ['00:00', 'every hour at :00']])
36
+ end
37
+
38
+ it 'runs every day and every hour on all days, weekdays mon to fri, a week on its day' do
39
+ days = [build, build(every: 'hour', at: ':00'), build(every: 'weekday'), build(every: 'week', on: 'wed')].map(&:days)
40
+
41
+ expect(days).to eq([%w[mon tue wed thu fri sat sun], %w[mon tue wed thu fri sat sun], %w[mon tue wed thu fri], %w[wed]])
42
+ end
43
+
44
+ it 'spells weekday and week rules' do
45
+ expect([build(every: 'weekday').rule_text, build(every: 'week', on: 'mon').rule_text]).to eq(['every weekday at 08:00', 'every week on mon at 08:00'])
46
+ end
47
+
48
+ it 'refuses an unknown every' do
49
+ expect(error_for(every: 'month')).to include('use hour, day, weekday or week')
50
+ end
51
+
52
+ it 'refuses a missing or malformed at for day, weekday and week' do
53
+ errors = [nil, '8:00', '24:00', '08:60', '08:00x', ':15'].map { |at| error_for(at: at) }
54
+
55
+ expect(errors).to all(include('HH:MM'))
56
+ end
57
+
58
+ it 'refuses a malformed at for every hour' do
59
+ expect([error_for(every: 'hour', at: ':60'), error_for(every: 'hour', at: '25:00')]).to all(include(':MM'))
60
+ end
61
+
62
+ it 'needs on for every week and refuses it otherwise' do
63
+ expect([error_for(every: 'week'), error_for(every: 'week', on: 'monday'), error_for(on: 'mon')]).to eq(
64
+ ['Every week needs on: use mon, tue, wed, thu, fri, sat or sun',
65
+ 'Invalid on "monday": use mon, tue, wed, thu, fri, sat or sun',
66
+ 'on is only for every week']
67
+ )
68
+ end
69
+
70
+ it 'takes retries 0 to 3' do
71
+ expect([build(retries: 3).retries, error_for(retries: 4), error_for(retries: '1')]).to eq([3, 'Invalid retries 4: use 0 to 3', 'Invalid retries "1": use 0 to 3'])
72
+ end
73
+
74
+ it 'refuses a bad flow name' do
75
+ expect(error_for(name: '../x')).to include('Invalid flow name')
76
+ end
77
+
78
+ it 'refuses bad input names and values with control characters' do
79
+ errors = [['Bad=1'], ['nokey'], ["note=a\nb"], ["note=a\tb"], ["note=a\0b"]].map { |inputs| error_for(inputs: inputs) }
80
+
81
+ expect(errors).to all(start_with('Invalid input'))
82
+ end
83
+
84
+ it 'refuses input that is not valid UTF-8' do
85
+ expect([error_for(inputs: ["note=a\xFFb"]), error_for(inputs: ["n\xFFte=ab"])]).to all(include('valid UTF-8'))
86
+ end
87
+
88
+ it 'keeps hostile input values as plain argv items' do
89
+ values = ["'; rm -rf ~; '", '</string>', '%h', '$(id)']
90
+ schedule = build(inputs: values.map { |value| "note=#{value}" })
91
+
92
+ expect(schedule.argv.last(4)).to eq(values.map { |value| "note=#{value}" })
93
+ end
94
+ end
95
+
96
+ describe 'the OS entry' do
97
+ it 'names the entry from the project realpath and the flow name' do
98
+ digest = Digest::SHA256.hexdigest(dir)[0, 10]
99
+
100
+ expect([build.os_id, described_class.os_id_for(dir, 'report')]).to eq(["dev.rubyraider.flow.#{digest}.report"] * 2)
101
+ end
102
+
103
+ it 'runs raider flow run under the absolute ruby and bundle' do
104
+ expect(build.argv).to eq([RbConfig.ruby, Gem.bin_path('bundler', 'bundle'), 'exec', 'raider', 'flow', 'run', 'report',
105
+ '--project', dir, '--trigger', 'schedule'])
106
+ end
107
+
108
+ it 'adds retries only above 0 and --input with each pair' do
109
+ expect(build(retries: 2, inputs: %w[a=1 b=2]).argv.last(5)).to eq(%w[--retries 2 --input a=1 b=2])
110
+ end
111
+
112
+ it 'adds --notify before the inputs when the schedule notifies' do
113
+ expect(build(notify: true, retries: 1, inputs: %w[a=1]).argv.last(5)).to eq(%w[--retries 1 --notify --input a=1])
114
+ expect(build.argv).not_to include('--notify')
115
+ end
116
+
117
+ it 'refuses with a Flows::Error when bundler is not installed for this Ruby' do
118
+ [Gem::GemNotFoundException.new('no bundler'), Gem::MissingSpecError.new('bundler', '>= 0')].each do |error|
119
+ allow(Gem).to receive(:bin_path).with('bundler', 'bundle').and_raise(error)
120
+
121
+ expect(error_for).to start_with('Bundler not found for this Ruby')
122
+ end
123
+ end
124
+
125
+ it 'runs in the project and logs to the flow folder' do
126
+ expect([build.working_directory, build.log_path]).to eq([dir, File.join(dir, '.raider/flows/report/scheduler.log')])
127
+ end
128
+
129
+ it 'records when it was made' do
130
+ expect(build.created_at).to eq('2026-11-09T10:00:00Z')
131
+ end
132
+
133
+ it 'leaves the given time as it was' do
134
+ local = Time.local(2026, 11, 9, 11, 0, 0)
135
+ offset = local.utc_offset
136
+ build(now: local)
137
+
138
+ expect([local.utc?, local.utc_offset]).to eq([false, offset])
139
+ end
140
+
141
+ it 'is frozen' do
142
+ schedule = build(inputs: %w[a=1])
143
+
144
+ expect([schedule, schedule.inputs, schedule.gem_env]).to all(be_frozen)
145
+ end
146
+ end
147
+
148
+ describe 'the env' do
149
+ let(:bin) { File.dirname(RbConfig.ruby) }
150
+
151
+ it 'captures GEM_HOME and GEM_PATH from the env Bundler started with' do
152
+ allow(Bundler).to receive(:original_env).and_return('GEM_HOME' => '/gems/home', 'GEM_PATH' => '/gems/home:/gems/more', 'OTHER' => 'x')
153
+
154
+ expect(build.env).to include('GEM_HOME' => '/gems/home', 'GEM_PATH' => '/gems/home:/gems/more', 'LANG' => 'en_US.UTF-8')
155
+ end
156
+
157
+ it 'leaves GEM_HOME and GEM_PATH out when they are not set' do
158
+ allow(Bundler).to receive(:original_env).and_return('GEM_HOME' => '', 'PATH' => '/x')
159
+
160
+ expect([build.gem_env, build.env.keys.sort]).to eq([{}, %w[LANG PATH]])
161
+ end
162
+
163
+ it 'reads ENV when Bundler is not loaded' do
164
+ hide_const('Bundler')
165
+ stub_const('ENV', { 'GEM_HOME' => '/env/home' })
166
+
167
+ expect(build.gem_env).to eq('GEM_HOME' => '/env/home')
168
+ end
169
+
170
+ it 'puts the ruby and bundle folders on PATH before the system ones, once each' do
171
+ folders = [bin, File.dirname(Gem.bin_path('bundler', 'bundle')), '/usr/bin', '/bin'].uniq
172
+
173
+ expect(build.env['PATH']).to eq(folders.join(File::PATH_SEPARATOR))
174
+ end
175
+ end
176
+
177
+ it 'refuses to build for a project folder with a control character in its path' do
178
+ odd = File.join(dir, "line\nbreak")
179
+ FileUtils.mkdir_p(odd)
180
+ project = RubyRaider::Flows::Project.new(odd)
181
+
182
+ expect { described_class.build(project: project, name: 'report', every: 'day', at: '08:00', now: now) }
183
+ .to raise_error(RubyRaider::Flows::Error, /Invalid project_root/)
184
+ end
185
+
186
+ describe 'the yml shape' do
187
+ let(:schedule) { build(every: 'week', on: 'fri', retries: 1, inputs: %w[a=1]) }
188
+
189
+ it 'writes string keys' do
190
+ expect(schedule.to_h.keys).to eq(%w[name every at on retries inputs notify os_id ruby bundle gem_env created_at])
191
+ end
192
+
193
+ it 'reads back what it wrote' do
194
+ expect(described_class.from_h(schedule.to_h, dir).to_h).to eq(schedule.to_h)
195
+ end
196
+
197
+ it 'refuses an entry whose os_id is not this project\'s, or not in lowercase hex' do
198
+ upper = schedule.os_id.sub(/\.(\h{10})\./) { ".#{Regexp.last_match(1).upcase}." }
199
+
200
+ expect { described_class.from_h(schedule.to_h, '/elsewhere') }.to raise_error(RubyRaider::Flows::Error, /os_id/)
201
+ expect { described_class.from_h(schedule.to_h.merge('os_id' => upper), dir) }.to raise_error(RubyRaider::Flows::Error, /os_id/)
202
+ end
203
+
204
+ it 'refuses entries that were tampered with' do
205
+ bad = [{ 'every' => 'yearly' }, { 'os_id' => 'evil; id' }, { 'ruby' => 'ruby' }, { 'gem_env' => { 'RUBYOPT' => '-rx' } },
206
+ { 'inputs' => ["a=\n"] }, { 'name' => nil }, { 'created_at' => 5 }]
207
+
208
+ bad.each do |change|
209
+ expect { described_class.from_h(schedule.to_h.merge(change), dir) }.to raise_error(RubyRaider::Flows::Error)
210
+ end
211
+ end
212
+
213
+ it 'refuses a project root with a control character or invalid UTF-8' do
214
+ roots = ["/p/a\nb", "/p/a\tb", "/p/\e[2J", "/p/\xFF".dup.force_encoding(Encoding::UTF_8), 'relative']
215
+
216
+ roots.each do |root|
217
+ expect { described_class.from_h(schedule.to_h, root) }.to raise_error(RubyRaider::Flows::Error, /project_root/)
218
+ end
219
+ end
220
+
221
+ it 'keeps notify, and reads an entry written before notify as off' do
222
+ notifying = build(notify: true)
223
+
224
+ expect(described_class.from_h(notifying.to_h, dir).notify).to be(true)
225
+ expect(described_class.from_h(schedule.to_h.except('notify'), dir).notify).to be(false)
226
+ expect { described_class.from_h(schedule.to_h.merge('notify' => 'yes'), dir) }.to raise_error(RubyRaider::Flows::Error, /notify/)
227
+ end
228
+
229
+ it 'refuses a non-hash' do
230
+ expect { described_class.from_h('x', dir) }.to raise_error(RubyRaider::Flows::Error)
231
+ end
232
+ end
233
+ end