lux-hammer 0.3.13 → 0.3.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,416 @@
1
+ require 'json'
2
+ require 'fileutils'
3
+
4
+ class Hammer
5
+ # The `hammer h:cron` job server. Discovers every task that declares a
6
+ # `cron '<expr>'` schedule, ticks once per minute and runs each due job
7
+ # in its own subprocess (a fresh `hammer ns:task`, exactly like the
8
+ # macOS GUI runs tasks), streaming stdout+stderr into a per-job log.
9
+ # A small web UI (Hammer::CronWeb) serves job status and logs on
10
+ # localhost.
11
+ #
12
+ # On-disk layout, relative to the Hammerfile dir:
13
+ #
14
+ # log/hammer/<slug>.log current job log (db:backup -> db-backup.log)
15
+ # log/hammer/<slug>.log.1 previous log, rotation keeps max 2 files
16
+ # tmp/hammer/cron.state.json last-run timestamps + daemon pid/port
17
+ class CronServer
18
+ MAX_LOG_BYTES ||= 1_048_576 # rotate the job log past 1 MB
19
+
20
+ # One scheduled task. `pid` doubles as the running flag: nil when
21
+ # idle, :starting between due-check and spawn, then the child pid.
22
+ Job = Struct.new(:path, :command, :schedule, :last_run, :status,
23
+ :exit_code, :duration, :pid, keyword_init: true) do
24
+ def running?
25
+ !pid.nil?
26
+ end
27
+
28
+ # Log-safe file name: 'db:backup' -> 'db-backup'.
29
+ def file_slug
30
+ path.tr(':', '-').gsub(/[^\w.-]/, '-')
31
+ end
32
+ end
33
+
34
+ attr_reader :jobs, :port, :root_dir
35
+
36
+ def initialize(root_klass, port:, pass: nil)
37
+ @root_dir = Dir.pwd # Hammer.cli already chdir-ed to the Hammerfile dir
38
+ @port = port
39
+ @pass = pass && !pass.to_s.empty? ? pass.to_s : nil
40
+ @hammer_bin = begin
41
+ File.realpath($PROGRAM_NAME)
42
+ rescue Errno::ENOENT
43
+ File.expand_path($PROGRAM_NAME)
44
+ end
45
+ @log_dir = File.join(@root_dir, 'log/hammer')
46
+ @state_path = File.join(@root_dir, 'tmp/hammer/cron.state.json')
47
+ @mutex = Mutex.new
48
+ @threads = []
49
+
50
+ @jobs = {}
51
+ root_klass.each_command(include_builtins: false) do |path, cmd|
52
+ next unless cmd.cron
53
+ @jobs[path] = Job.new(path: path, command: cmd, schedule: cmd.cron_schedule)
54
+ end
55
+ if @jobs.empty?
56
+ raise Hammer::Error,
57
+ "no tasks declare a cron schedule - add `cron '*/10 * * * *'` (or '10m', '@daily') inside a task block"
58
+ end
59
+
60
+ load_state
61
+ end
62
+
63
+ # ----- foreground service --------------------------------------------
64
+
65
+ # Run scheduler + web UI until SIGINT/SIGTERM. Ticks on minute
66
+ # boundaries (or every second when a sub-minute interval job exists,
67
+ # see tick_step). The scheduler owns the main thread (so Ctrl-C
68
+ # lands in the thread that is sleeping); the HTTP accept loop runs
69
+ # in a background thread. Trap handlers only flip a flag - no IO or
70
+ # locking is allowed inside a trap.
71
+ def run!
72
+ ensure_single_instance!
73
+ @stop = false
74
+ trap('INT') { @stop = true }
75
+ trap('TERM') { @stop = true }
76
+
77
+ save_state
78
+ @jobs.each_value { |job| watch_orphan(job) if job.running? }
79
+ require_relative 'cron_web'
80
+ @web = CronWeb.new(self, port: @port, pass: @pass).start
81
+ print_startup_summary
82
+ Shell.say "web ui: http://127.0.0.1:#{@port}#{' (password protected)' if @pass}", :cyan
83
+ Shell.say 'Ctrl-C to stop', :gray
84
+
85
+ until @stop
86
+ tick = wait_for_tick or break
87
+ each_job_snapshot do |job|
88
+ launch(job, tick, trigger: 'cron') if job.schedule.due?(tick, job.last_run)
89
+ end
90
+ end
91
+
92
+ shutdown!
93
+ end
94
+
95
+ # Table of scheduled jobs + next runs; doubles as `h:cron --list`.
96
+ def print_startup_summary
97
+ now = Time.now
98
+ rows = @jobs.values.map do |job|
99
+ [job.path, job.schedule.source, format_time(job.last_run),
100
+ format_time(job.schedule.next_run(now, last_run: job.last_run))]
101
+ end
102
+ widths = ['task', 'schedule', 'last run', 'next run'].each_with_index.map do |h, i|
103
+ [h.length, *rows.map { |r| r[i].length }].max
104
+ end
105
+ header = ['task', 'schedule', 'last run', 'next run']
106
+ Shell.say header.each_with_index.map { |h, i| h.ljust(widths[i]) }.join(' '), :yellow
107
+ rows.each do |r|
108
+ Shell.say r.each_with_index.map { |v, i| v.ljust(widths[i]) }.join(' ')
109
+ end
110
+ end
111
+
112
+ # Trigger one job outside its schedule (web UI "run now" button).
113
+ # Same code path as a scheduled run; the log header says 'manual'.
114
+ # Returns false when the job is unknown.
115
+ def run_now(path)
116
+ job = @jobs[path] or return false
117
+ launch(job, Time.now, trigger: 'manual')
118
+ true
119
+ end
120
+
121
+ # ----- job execution --------------------------------------------------
122
+
123
+ # Fire one run in a subprocess. Overlap policy: if the previous run
124
+ # of the SAME job is still alive, skip and note it in the job log -
125
+ # jobs that outlive their interval must not pile up. Different jobs
126
+ # run freely in parallel. The child runs the full normal hammer
127
+ # pipeline (Bundler, dotenv, before hooks, needs) in @root_dir with
128
+ # stdin closed, so anything interactive fails fast instead of
129
+ # hanging the scheduler.
130
+ def launch(job, at, trigger:)
131
+ @mutex.synchronize do
132
+ if job.running?
133
+ append_line(job, "[h:cron] #{at.strftime('%F %T')} skipped (#{trigger}) - previous run still active")
134
+ return
135
+ end
136
+ job.pid = :starting
137
+ job.status = 'running'
138
+ job.last_run = at
139
+ save_state
140
+ end
141
+
142
+ @threads << Thread.new do
143
+ begin
144
+ rotate_log(job)
145
+ FileUtils.mkdir_p(@log_dir)
146
+ File.open(log_path(job), 'a') do |log|
147
+ log.sync = true
148
+ log.puts "===== #{job.path} | #{trigger} | #{at.strftime('%F %T')} ====="
149
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
150
+ pid = Process.spawn(
151
+ { 'NO_COLOR' => '1', 'HAMMER_QUIET' => '1' },
152
+ @hammer_bin, job.path,
153
+ in: File::NULL, out: log, err: log, chdir: @root_dir
154
+ )
155
+ # Persist the real child pid right away - it's what lets a
156
+ # restarted server find this run again if we die mid-flight.
157
+ @mutex.synchronize do
158
+ job.pid = pid
159
+ save_state
160
+ end
161
+ _, status = Process.wait2(pid)
162
+ dur = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
163
+ log.puts "===== exit #{status.exitstatus.inspect} | #{format('%.1f', dur)}s ====="
164
+ @mutex.synchronize do
165
+ job.pid = nil
166
+ job.exit_code = status.exitstatus
167
+ job.duration = dur
168
+ job.status = status.success? ? 'ok' : 'failed'
169
+ save_state
170
+ end
171
+ end
172
+ rescue => e
173
+ @mutex.synchronize do
174
+ job.pid = nil
175
+ job.status = 'error'
176
+ save_state
177
+ end
178
+ append_line(job, "[h:cron] run failed: #{e.class}: #{e.message}")
179
+ end
180
+ end
181
+ end
182
+
183
+ # ----- logs -----------------------------------------------------------
184
+
185
+ def log_path(job)
186
+ File.join(@log_dir, "#{job.file_slug}.log")
187
+ end
188
+
189
+ # Called before each run: past 1 MB the current log rolls to .log.1
190
+ # (POSIX rename clobbers the previous .1) and the run starts fresh.
191
+ # Max two files per job, ever.
192
+ def rotate_log(job)
193
+ path = log_path(job)
194
+ size = begin
195
+ File.size(path)
196
+ rescue Errno::ENOENT
197
+ 0
198
+ end
199
+ File.rename(path, "#{path}.1") if size > MAX_LOG_BYTES
200
+ end
201
+
202
+ # ----- state file -----------------------------------------------------
203
+
204
+ # tmp/hammer/cron.state.json remembers last runs across restarts so a
205
+ # relaunched scheduler neither re-fires jobs that just ran nor
206
+ # forgets interval clocks. Also records our pid/port so a second
207
+ # `h:cron` (and the web UI) can see who is running.
208
+ def save_state
209
+ data = {
210
+ schema: 1,
211
+ pid: Process.pid,
212
+ port: @port,
213
+ started_at: (@started_at ||= Time.now).to_i,
214
+ jobs: @jobs.transform_values do |j|
215
+ { last_run: j.last_run&.to_i, exit: j.exit_code, duration: j.duration,
216
+ status: j.status, pid: (j.pid if j.pid.is_a?(Integer)) }
217
+ end
218
+ }
219
+ FileUtils.mkdir_p(File.dirname(@state_path))
220
+ tmp = "#{@state_path}.tmp"
221
+ File.write(tmp, JSON.pretty_generate(data))
222
+ File.rename(tmp, @state_path)
223
+ end
224
+
225
+ # Tolerates a missing or corrupt file - scheduling state is
226
+ # best-effort, a fresh start is always safe. Jobs no longer declared
227
+ # in the Hammerfile simply are not restored (and get pruned on the
228
+ # next save).
229
+ def load_state
230
+ data = begin
231
+ JSON.parse(File.read(@state_path))
232
+ rescue Errno::ENOENT
233
+ return
234
+ rescue JSON::ParserError
235
+ warn Shell.paint("h:cron: ignoring corrupt #{@state_path}", :gray)
236
+ return
237
+ end
238
+ @saved_pid = data['pid']
239
+ (data['jobs'] || {}).each do |path, j|
240
+ job = @jobs[path] or next
241
+ job.last_run = j['last_run'] ? Time.at(j['last_run']) : nil
242
+ job.exit_code = j['exit']
243
+ job.duration = j['duration']
244
+ job.status = j['status']
245
+ # A job saved as 'running' means the previous server went away
246
+ # mid-run. If its subprocess is still alive we keep reporting it
247
+ # as running (and the overlap guard keeps skipping new runs);
248
+ # otherwise the run's exit status is lost - mark it 'unknown'
249
+ # instead of leaving a stale 'running' badge forever.
250
+ next unless job.status == 'running'
251
+ if j['pid'] && process_alive?(j['pid'])
252
+ job.pid = j['pid']
253
+ else
254
+ job.pid = nil
255
+ job.status = 'unknown'
256
+ end
257
+ end
258
+ end
259
+
260
+ # ----- service units ---------------------------------------------------
261
+
262
+ # Print a launchd plist (macOS) or systemd user unit (everything
263
+ # else) for running `h:cron` supervised. Unit text goes to stdout so
264
+ # it can be redirected into a file; install hints go to stderr.
265
+ def print_service_unit
266
+ if RUBY_PLATFORM.include?('darwin')
267
+ label = "com.lux-hammer.cron.#{File.basename(@root_dir)}"
268
+ puts <<~XML
269
+ <?xml version="1.0" encoding="UTF-8"?>
270
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
271
+ <plist version="1.0">
272
+ <dict>
273
+ <key>Label</key><string>#{label}</string>
274
+ <key>ProgramArguments</key>
275
+ <array>
276
+ <string>#{@hammer_bin}</string>
277
+ <string>h:cron</string>
278
+ <string>--port=#{@port}</string>#{@pass ? "\n <string>--pass=#{@pass}</string>" : ''}
279
+ </array>
280
+ <key>WorkingDirectory</key><string>#{@root_dir}</string>
281
+ <key>RunAtLoad</key><true/>
282
+ <key>KeepAlive</key><true/>
283
+ <key>StandardOutPath</key><string>#{@root_dir}/log/hammer/cron-server.log</string>
284
+ <key>StandardErrorPath</key><string>#{@root_dir}/log/hammer/cron-server.log</string>
285
+ </dict>
286
+ </plist>
287
+ XML
288
+ warn Shell.paint("# save to ~/Library/LaunchAgents/#{label}.plist, then:", :gray)
289
+ warn Shell.paint("# launchctl load ~/Library/LaunchAgents/#{label}.plist", :gray)
290
+ else
291
+ puts <<~UNIT
292
+ [Unit]
293
+ Description=hammer h:cron job server (#{File.basename(@root_dir)})
294
+
295
+ [Service]
296
+ ExecStart=#{@hammer_bin} h:cron --port=#{@port}#{" --pass=#{@pass}" if @pass}
297
+ WorkingDirectory=#{@root_dir}
298
+ Restart=on-failure
299
+
300
+ [Install]
301
+ WantedBy=default.target
302
+ UNIT
303
+ warn Shell.paint('# save to ~/.config/systemd/user/hammer-cron.service, then:', :gray)
304
+ warn Shell.paint('# systemctl --user enable --now hammer-cron', :gray)
305
+ end
306
+ end
307
+
308
+ # ----- snapshots for the web UI ----------------------------------------
309
+
310
+ # Plain-value copy of every job, taken under the mutex, so the web
311
+ # thread never renders half-updated state.
312
+ def snapshot
313
+ @mutex.synchronize do
314
+ @jobs.values.map do |j|
315
+ { path: j.path, desc: j.command.brief, cron: j.schedule.source,
316
+ last_run: j.last_run, status: j.status, exit_code: j.exit_code,
317
+ duration: j.duration, running: j.running?,
318
+ next_run: j.schedule.next_run(Time.now, last_run: j.last_run) }
319
+ end
320
+ end
321
+ end
322
+
323
+ def job?(path)
324
+ @jobs.key?(path)
325
+ end
326
+
327
+ private
328
+
329
+ # Refuse to start when the state file points at another live h:cron -
330
+ # two schedulers would double-fire every job. Best-effort (no lock
331
+ # file): kill(0) tells us whether that pid is still alive.
332
+ def ensure_single_instance!
333
+ return unless @saved_pid && @saved_pid != Process.pid
334
+ return unless process_alive?(@saved_pid)
335
+ raise Hammer::Error, "h:cron already running (pid #{@saved_pid}) - stop it first"
336
+ end
337
+
338
+ # kill(0) probes without signaling. EPERM means the pid exists but
339
+ # belongs to someone else - that still counts as alive. Best-effort:
340
+ # a recycled pid after reboot can false-positive, acceptable here.
341
+ def process_alive?(pid)
342
+ Process.kill(0, pid)
343
+ true
344
+ rescue Errno::ESRCH
345
+ false
346
+ rescue Errno::EPERM
347
+ true
348
+ end
349
+
350
+ # Tick resolution: classic minute alignment, dropped to 1s when any
351
+ # interval job runs sub-minute (cron '10s') - those are the only
352
+ # schedules that can be due between minute boundaries.
353
+ def tick_step
354
+ sub_minute = @jobs.values.any? do |j|
355
+ j.schedule.interval && j.schedule.interval < 60
356
+ end
357
+ sub_minute ? 1 : 60
358
+ end
359
+
360
+ # This server restarted while a subprocess spawned by the previous
361
+ # one is still alive (restored from the state file). It is not our
362
+ # child, so wait2 is off the table - poll once a second until it
363
+ # disappears. Its exit status is lost, hence 'unknown'; the overlap
364
+ # guard keeps skipping new runs of the job until then.
365
+ def watch_orphan(job)
366
+ @threads << Thread.new do
367
+ sleep 1 while !@stop && process_alive?(job.pid)
368
+ unless @stop
369
+ @mutex.synchronize do
370
+ job.pid = nil
371
+ job.status = 'unknown'
372
+ save_state
373
+ end
374
+ append_line(job, "[h:cron] orphaned run from previous server finished - exit status unknown")
375
+ end
376
+ end
377
+ end
378
+
379
+ # Sleep in <=1s slices until the next tick boundary so Ctrl-C is
380
+ # honored within a second. Returns the boundary Time, or nil on stop.
381
+ def wait_for_tick
382
+ step = tick_step
383
+ target = (Time.now.to_i / step + 1) * step
384
+ while Time.now.to_i < target
385
+ return nil if @stop
386
+ sleep [target - Time.now.to_f, 1].min
387
+ end
388
+ Time.at(target)
389
+ end
390
+
391
+ # Due-checks read job state the runner threads mutate - grab the
392
+ # mutex for the read, launch outside it (launch re-locks itself).
393
+ def each_job_snapshot(&block)
394
+ @jobs.values.each(&block)
395
+ end
396
+
397
+ # Stop accepting HTTP, then give in-flight runs a short grace period
398
+ # to finish - a mid-backup kill is worse than a late exit. The
399
+ # subprocesses themselves are never signaled.
400
+ def shutdown!
401
+ Shell.say 'shutting down...', :gray
402
+ @web&.stop
403
+ @threads.each { |t| t.join(10) }
404
+ save_state
405
+ end
406
+
407
+ def append_line(job, line)
408
+ FileUtils.mkdir_p(@log_dir)
409
+ File.open(log_path(job), 'a') { |f| f.puts(line) }
410
+ end
411
+
412
+ def format_time(t)
413
+ t ? t.strftime('%F %T') : 'never'
414
+ end
415
+ end
416
+ end