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.
- checksums.yaml +4 -4
- data/.version +1 -1
- data/AGENTS.md +40 -2
- data/README.md +112 -0
- data/bin/hammer +10 -0
- data/gui/Hammer.app/Contents/Info.plist +16 -0
- data/gui/Hammer.app/Contents/MacOS/HammerGUI +0 -0
- data/lib/hammer/builtins.rb +68 -0
- data/lib/hammer/command.rb +35 -1
- data/lib/hammer/command_builder.rb +8 -0
- data/lib/hammer/cron.rb +151 -0
- data/lib/hammer/cron_server.rb +416 -0
- data/lib/hammer/cron_web.rb +304 -0
- data/lib/hammer/option.rb +19 -0
- data/lib/lux-hammer.rb +90 -32
- data/recipes/git-helper.rb +2 -2
- data/recipes/llm.rb +1 -1
- metadata +7 -2
|
@@ -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
|