lux-hammer 0.3.14 → 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 +37 -2
- data/README.md +99 -1
- data/bin/hammer +10 -0
- data/lib/hammer/builtins.rb +44 -0
- data/lib/hammer/command.rb +15 -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/lux-hammer.rb +7 -0
- data/recipes/llm.rb +1 -1
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a61994d8f82b575a019cfb1171935b334b840cdb5561fcc56e9ba2c141a314d2
|
|
4
|
+
data.tar.gz: a4d4d730c830b4d30de13dc9e0c27e9cc2fa3902c9297154d4fe81ba5365fe94
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0e4c0a5f1fbf47550df2929670e647cf7e1e390e7b3aab0b71932dc5f9557295953c39cdf9fafc6ac719d53b94ed14058bd1a2bb32bcaf9bb79ff3b2198fee16
|
|
7
|
+
data.tar.gz: b0012fd33259c5ea63af2d3ea0040c4a618292042f842dbda523f462d90fbc9138c04217c3c86a7d52fd56a6b650249ab2dd9bb6f070e1f1a24497ab68e6dbe2
|
data/.version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.3.
|
|
1
|
+
0.3.15
|
data/AGENTS.md
CHANGED
|
@@ -41,6 +41,9 @@ lib/hammer/shell.rb # ANSI/IO helpers
|
|
|
41
41
|
lib/hammer/option.rb # One option definition
|
|
42
42
|
lib/hammer/parser.rb # ARGV -> [positional, opts_hash]
|
|
43
43
|
lib/hammer/command.rb # One registered command (name, opts, alts, handler)
|
|
44
|
+
lib/hammer/cron.rb # `cron '<expr>'` schedule parser (cron/interval/@shortcut)
|
|
45
|
+
lib/hammer/cron_server.rb # `h:cron` job server (scheduler, logs, state; lazy-loaded)
|
|
46
|
+
lib/hammer/cron_web.rb # job server web UI (stdlib TCPServer; lazy-loaded)
|
|
44
47
|
lib/hammer/loader.rb # `*_hammer.rb` fragment loader (auto/glob/file)
|
|
45
48
|
lib/hammer/builder.rb # Block-DSL context (Hammerfile / Hammer.run)
|
|
46
49
|
lib/hammer/command_builder.rb # `task :name do ... end` context
|
|
@@ -54,6 +57,8 @@ test/option_test.rb # Option declaration / casting
|
|
|
54
57
|
test/command_test.rb # Command data type
|
|
55
58
|
test/shell_test.rb # ANSI / IO helpers
|
|
56
59
|
test/cli_test.rb # `hammer` binary end-to-end
|
|
60
|
+
test/cron_test.rb # schedule parser (matches?/due?/next_run)
|
|
61
|
+
test/cron_server_test.rb # job discovery, state, rotation, web UI
|
|
57
62
|
examples/Hammerfile # Reference Hammerfile
|
|
58
63
|
examples/class_dsl.rb # Reference class DSL usage
|
|
59
64
|
examples/block_dsl.rb # Reference block DSL usage
|
|
@@ -73,11 +78,18 @@ Inside a `task :name do ... end` block (CommandBuilder context):
|
|
|
73
78
|
resolved against root (same lookup as `hammer`), deduped per
|
|
74
79
|
top-level `start` so each prereq fires at most once. Dedupe also
|
|
75
80
|
spans `+`-chained segments. Unknown prereq raises `Hammer::Error`.
|
|
81
|
+
* `cron '<expr>'` - schedule for the `h:cron` job server. Accepts
|
|
82
|
+
5-field cron (`'*/10 * * * *'`), an interval (`'10s'`, `'10m'`,
|
|
83
|
+
`'2h'`, `'1d'`, counted from the last run) or `@hourly` / `@daily` /
|
|
84
|
+
`@weekly` / `@monthly`. Parsed eagerly (`Hammer::Cron.new`) so an invalid
|
|
85
|
+
expression raises `Hammer::Error` at definition time. Stored on the
|
|
86
|
+
Command (`cmd.cron` raw string, `cmd.cron_schedule` parsed), exported
|
|
87
|
+
by `h:json`, shown in per-command help.
|
|
76
88
|
* `proc do |opts| ... end` - **the last expression**, becomes handler
|
|
77
89
|
|
|
78
90
|
At class scope (for `def`-style commands):
|
|
79
91
|
|
|
80
|
-
* `desc`, `example`, `opt`, `alt`, `needs` set pending state
|
|
92
|
+
* `desc`, `example`, `opt`, `alt`, `needs`, `cron` set pending state
|
|
81
93
|
* The next `def` consumes the pending state IF `desc` was set; otherwise
|
|
82
94
|
the method is treated as a plain helper
|
|
83
95
|
* Methods with arity 0 are called without opts; methods that take an arg
|
|
@@ -249,7 +261,7 @@ reserved `h:` namespace.
|
|
|
249
261
|
|
|
250
262
|
* Root: `:default` (hidden, carries `--version` / `-v`).
|
|
251
263
|
* `h:` namespace: `h:help`, `h:update`, `h:agents`, `h:version`,
|
|
252
|
-
`h:recipes`, `h:init`.
|
|
264
|
+
`h:recipes`, `h:init`, `h:json`, `h:cron`.
|
|
253
265
|
|
|
254
266
|
`h:` is the reserved built-in namespace - keeping the tool-meta commands
|
|
255
267
|
there means they never collide with a project's root tasks, so there's
|
|
@@ -282,6 +294,29 @@ Task contracts:
|
|
|
282
294
|
* `h:json` - `puts JSON` of `root.export_spec` (tasks grouped exactly
|
|
283
295
|
like the bare listing via `section_for`, root group keyed `__root`).
|
|
284
296
|
`--all` keeps the `h:` tree, `--compact` minifies.
|
|
297
|
+
* `h:cron` - foreground job server for tasks with a `cron` schedule.
|
|
298
|
+
Lazy-requires `hammer/cron_server`. Ticks each minute (each second
|
|
299
|
+
when any job has a sub-minute interval); every due job
|
|
300
|
+
runs in a fresh subprocess (`hammer ns:task`, env `HAMMER_QUIET=1
|
|
301
|
+
NO_COLOR=1`, stdin closed, chdir to the Hammerfile dir), stdout+stderr
|
|
302
|
+
appended to `log/hammer/<slug>.log` (`:` -> `-`; rotated to `.log.1`
|
|
303
|
+
past 1 MB, max 2 files). Last runs persist in
|
|
304
|
+
`tmp/hammer/cron.state.json` (atomic tmp+rename) so restarts don't
|
|
305
|
+
re-fire; a live pid recorded there blocks a second instance.
|
|
306
|
+
Running state persists too: each active run's child pid is saved, and
|
|
307
|
+
on restart a still-alive orphan is restored as `running` (a watcher
|
|
308
|
+
polls until it exits; the overlap guard keeps skipping), while a dead
|
|
309
|
+
one becomes status `unknown` (exit status lost) instead of a stale
|
|
310
|
+
`running`. Overlapping runs of the same job are skipped, never
|
|
311
|
+
queued. Web UI on
|
|
312
|
+
`127.0.0.1` only (default port 4267, hand-rolled HTTP in
|
|
313
|
+
`hammer/cron_web.rb` - no webrick, keep it dependency-free): jobs
|
|
314
|
+
table, per-job log tail, POST run-now, `/json` status. Flags:
|
|
315
|
+
`--port`, `--pass` (HTTP basic auth on every route, any username,
|
|
316
|
+
constant-time compare; falls back to `HAMMER_CRON_PASS` env),
|
|
317
|
+
`--list` (print jobs + next runs, exit), `--service` (print launchd
|
|
318
|
+
plist / systemd unit, carries `--pass` when set, install hints on
|
|
319
|
+
stderr).
|
|
285
320
|
|
|
286
321
|
The `:default` task and the `help` / `-h` / `--help` requests are
|
|
287
322
|
invoked via `run_command(cmd, argv, full: name, quiet: true)` - the
|
data/README.md
CHANGED
|
@@ -563,9 +563,12 @@ Under the `h:` namespace:
|
|
|
563
563
|
* `h:recipes` - list / install / show / edit recipes.
|
|
564
564
|
* `h:init` - write a starter Hammerfile in cwd (refuses if one exists).
|
|
565
565
|
* `h:json` - dump the CLI definition as JSON (tasks grouped like the
|
|
566
|
-
bare listing, with desc/options/examples/aliases/needs); `--all`
|
|
566
|
+
bare listing, with desc/options/examples/aliases/needs/cron); `--all`
|
|
567
567
|
includes the `h:` tasks, `--compact` minifies. Output is plain stdout
|
|
568
568
|
(the run banner goes to stderr), so `hammer h:json | jq` just works.
|
|
569
|
+
* `h:cron` - job server: runs every task that declares a
|
|
570
|
+
`cron '<expr>'` schedule, with a localhost web UI for status and
|
|
571
|
+
logs. See [Cron / job server](#cron--job-server).
|
|
569
572
|
|
|
570
573
|
Each is guarded by `unless commands.key?` within the namespace, so you
|
|
571
574
|
can override one by reopening `namespace :h` in your Hammerfile.
|
|
@@ -625,6 +628,101 @@ end
|
|
|
625
628
|
`-h` / `--help` stay reserved on every command - you can't shadow them
|
|
626
629
|
with an `opt`.
|
|
627
630
|
|
|
631
|
+
## Cron / job server
|
|
632
|
+
|
|
633
|
+
Any task can declare a schedule with `cron`; `hammer h:cron` then runs
|
|
634
|
+
those tasks on time, like a project-local crontab with a web UI:
|
|
635
|
+
|
|
636
|
+
```ruby
|
|
637
|
+
task :backup do
|
|
638
|
+
desc 'Snapshot the database'
|
|
639
|
+
cron '@daily' # 00:00 every day
|
|
640
|
+
proc { |_| sh 'bin/backup' }
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
namespace :cache do
|
|
644
|
+
task :warm do
|
|
645
|
+
desc 'Refresh hot caches'
|
|
646
|
+
cron '10m' # every 10 minutes
|
|
647
|
+
proc { |_| sh 'bin/warm-cache' }
|
|
648
|
+
end
|
|
649
|
+
end
|
|
650
|
+
```
|
|
651
|
+
|
|
652
|
+
Accepted schedule forms:
|
|
653
|
+
|
|
654
|
+
| form | example | meaning |
|
|
655
|
+
|---|---|---|
|
|
656
|
+
| 5-field cron | `*/10 * * * *` | standard crontab (`min hour dom mon dow`) |
|
|
657
|
+
| interval | `10s`, `10m`, `2h`, `1d` | every N seconds/minutes/hours/days, counted from the last run |
|
|
658
|
+
| shortcut | `@hourly`, `@daily`, `@weekly`, `@monthly` | expands to the cron equivalent |
|
|
659
|
+
|
|
660
|
+
Careful with raw cron fields: `10 * * * *` means "minute 10 of every
|
|
661
|
+
hour", not "every 10 minutes" - that's `*/10 * * * *`, or just `10m`.
|
|
662
|
+
Cron fields support `*`, `a`, `a-b`, `*/n`, `a-b/n` and comma lists
|
|
663
|
+
(numeric values only). Weekday `7` equals `0` (Sunday). Intervals are
|
|
664
|
+
not clock-aligned, so odd periods like `90m` work; a job that never ran
|
|
665
|
+
fires on the server's first tick. The scheduler ticks once per minute,
|
|
666
|
+
or once per second when any job declares a sub-minute interval.
|
|
667
|
+
Invalid expressions fail at Hammerfile load, not at runtime.
|
|
668
|
+
|
|
669
|
+
Run the server:
|
|
670
|
+
|
|
671
|
+
```sh
|
|
672
|
+
hammer h:cron # scheduler + web UI, Ctrl-C to stop
|
|
673
|
+
hammer h:cron --port=7777 # UI on another port (default 4267)
|
|
674
|
+
hammer h:cron --list # print jobs + next runs, then exit
|
|
675
|
+
```
|
|
676
|
+
|
|
677
|
+
The web UI at `http://127.0.0.1:4267` (localhost only, never a public
|
|
678
|
+
interface) lists every job with its schedule, last/next run, status and
|
|
679
|
+
duration, shows per-job logs, and has a "run now" button for manual
|
|
680
|
+
triggers. `GET /json` serves the same status for scripting.
|
|
681
|
+
|
|
682
|
+
On a shared machine, protect the UI with HTTP basic auth - useful since
|
|
683
|
+
"run now" executes tasks:
|
|
684
|
+
|
|
685
|
+
```sh
|
|
686
|
+
hammer h:cron --pass=secret # or: HAMMER_CRON_PASS=secret hammer h:cron
|
|
687
|
+
curl -u :secret http://127.0.0.1:4267/json # any username, password checked
|
|
688
|
+
```
|
|
689
|
+
|
|
690
|
+
Prefer the `HAMMER_CRON_PASS` env var when other users can read your
|
|
691
|
+
process list (`ps` shows flag values). A `--service` unit generated
|
|
692
|
+
while `--pass` is set carries the flag along, so keep that file private.
|
|
693
|
+
Note this is plain HTTP - credentials are encoded, not encrypted; for
|
|
694
|
+
anything beyond localhost put a TLS proxy in front.
|
|
695
|
+
|
|
696
|
+
Each run executes in a fresh subprocess (`hammer ns:task` - same
|
|
697
|
+
isolation as the GUI runner), through the full normal pipeline: Bundler,
|
|
698
|
+
dotenv, `before` hooks, `needs`. Output goes to a per-job log:
|
|
699
|
+
|
|
700
|
+
```
|
|
701
|
+
log/hammer/backup.log # current log (db:backup -> db-backup.log)
|
|
702
|
+
log/hammer/backup.log.1 # previous, rotated past 1 MB - max 2 files
|
|
703
|
+
tmp/hammer/cron.state.json # last runs + server pid, survives restarts
|
|
704
|
+
```
|
|
705
|
+
|
|
706
|
+
You probably want `log/hammer/` and `tmp/hammer/` in `.gitignore`.
|
|
707
|
+
|
|
708
|
+
Overlap policy: if a run is still going when the next one comes due,
|
|
709
|
+
the new run is skipped (noted in the job log) - jobs never pile up.
|
|
710
|
+
Because last runs persist in the state file, a restart neither re-fires
|
|
711
|
+
jobs nor resets interval clocks, and a second `h:cron` for the same
|
|
712
|
+
project refuses to start while one is already running. Running state
|
|
713
|
+
survives restarts too: a job whose subprocess outlived a server crash
|
|
714
|
+
is picked up again as `running` (no double-start), and one that died
|
|
715
|
+
with the server shows `unknown` rather than a stale `running`.
|
|
716
|
+
|
|
717
|
+
To keep it running supervised, `--service` prints a ready-to-save
|
|
718
|
+
launchd plist (macOS) or systemd user unit (linux) - it only prints,
|
|
719
|
+
installing is up to you:
|
|
720
|
+
|
|
721
|
+
```sh
|
|
722
|
+
hammer h:cron --service > ~/.config/systemd/user/hammer-cron.service
|
|
723
|
+
systemctl --user enable --now hammer-cron
|
|
724
|
+
```
|
|
725
|
+
|
|
628
726
|
## Prereqs (`needs`)
|
|
629
727
|
|
|
630
728
|
Declare commands that must run before this one (Rake-style task deps):
|
data/bin/hammer
CHANGED
|
@@ -23,5 +23,15 @@ if File.exist?('Gemfile') && !defined?(Bundler)
|
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
require 'lux-hammer'
|
|
26
|
+
|
|
27
|
+
# `hammer --dir` prints the gem root of the lux-hammer that actually loaded
|
|
28
|
+
# (repo checkout or installed gem). Lets recipes locate the lib without
|
|
29
|
+
# hardcoding a path: require File.join(`hammer --dir`.chomp, 'lib/lux-hammer')
|
|
30
|
+
if ARGV.first == '--dir'
|
|
31
|
+
lib = $LOADED_FEATURES.grep(%r{/lux-hammer\.rb$}).last
|
|
32
|
+
puts File.expand_path('../..', lib) # lib/lux-hammer.rb -> gem root
|
|
33
|
+
exit
|
|
34
|
+
end
|
|
35
|
+
|
|
26
36
|
Hammer.cli(ARGV)
|
|
27
37
|
|
data/lib/hammer/builtins.rb
CHANGED
|
@@ -32,6 +32,7 @@ class Hammer
|
|
|
32
32
|
register_recipes(h) unless h.commands.key?('recipes')
|
|
33
33
|
register_init(h) unless h.commands.key?('init')
|
|
34
34
|
register_json(h) unless h.commands.key?('json')
|
|
35
|
+
register_cron(h) unless h.commands.key?('cron')
|
|
35
36
|
end
|
|
36
37
|
|
|
37
38
|
def register_help(klass)
|
|
@@ -113,6 +114,49 @@ class Hammer
|
|
|
113
114
|
end
|
|
114
115
|
end
|
|
115
116
|
|
|
117
|
+
# The job server: runs every task that declares `cron '<expr>'` on
|
|
118
|
+
# its schedule, in the foreground, with a localhost web UI for
|
|
119
|
+
# status, logs and manual triggers. Per-job logs land in log/hammer/
|
|
120
|
+
# (rotated at 1 MB, max 2 files), state in tmp/hammer/cron.state.json.
|
|
121
|
+
def register_cron(klass)
|
|
122
|
+
klass.class_eval do
|
|
123
|
+
task :cron do
|
|
124
|
+
desc <<~TXT
|
|
125
|
+
Job server: run tasks that declare a `cron '<expr>'` schedule.
|
|
126
|
+
|
|
127
|
+
Runs in the foreground (Ctrl-C to stop) with a web UI on
|
|
128
|
+
localhost for job status, per-job logs and manual runs. Use
|
|
129
|
+
--service to print a launchd plist / systemd unit and let the
|
|
130
|
+
OS supervise it.
|
|
131
|
+
TXT
|
|
132
|
+
example 'h:cron'
|
|
133
|
+
example 'h:cron --port=7777'
|
|
134
|
+
example 'h:cron --pass=secret'
|
|
135
|
+
example 'h:cron --list'
|
|
136
|
+
example 'h:cron --service > ~/.config/systemd/user/hammer-cron.service'
|
|
137
|
+
opt :port, type: :integer, default: 4267, desc: 'web UI port (binds 127.0.0.1 only)'
|
|
138
|
+
opt :pass, type: :string, desc: 'HTTP basic-auth password for the web UI (or HAMMER_CRON_PASS env)'
|
|
139
|
+
opt :list, type: :boolean, desc: 'print scheduled jobs + next runs, then exit'
|
|
140
|
+
opt :service, type: :boolean, desc: 'print a launchd plist (macOS) / systemd unit (linux)'
|
|
141
|
+
proc do |opts|
|
|
142
|
+
if self.class.root.instance_variable_get(:@no_hammerfile)
|
|
143
|
+
error "no Hammerfile found - h:cron needs a project with scheduled tasks"
|
|
144
|
+
end
|
|
145
|
+
require_relative 'cron_server'
|
|
146
|
+
server = Hammer::CronServer.new(self.class.root, port: opts[:port],
|
|
147
|
+
pass: opts[:pass] || ENV['HAMMER_CRON_PASS'])
|
|
148
|
+
if opts[:service]
|
|
149
|
+
server.print_service_unit
|
|
150
|
+
elsif opts[:list]
|
|
151
|
+
server.print_startup_summary
|
|
152
|
+
else
|
|
153
|
+
server.run!
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
116
160
|
def register_json(klass)
|
|
117
161
|
klass.class_eval do
|
|
118
162
|
task :json do
|
data/lib/hammer/command.rb
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
class Hammer
|
|
2
2
|
# A single registered command on a Hammer class.
|
|
3
3
|
class Command
|
|
4
|
-
attr_reader :name, :desc, :options, :examples, :alts, :needs
|
|
4
|
+
attr_reader :name, :desc, :options, :examples, :alts, :needs, :cron
|
|
5
5
|
attr_accessor :handler, :location, :prev_location
|
|
6
6
|
|
|
7
7
|
def initialize(name:, desc: '', handler: nil)
|
|
@@ -35,6 +35,19 @@ class Hammer
|
|
|
35
35
|
@needs << name.to_s
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
+
# Store a `cron '<expr>'` schedule. The raw string is kept for
|
|
39
|
+
# display/export; parsing happens here so an invalid expression
|
|
40
|
+
# raises Hammer::Error at definition time, not when the job server
|
|
41
|
+
# starts. The parsed Hammer::Cron is what the scheduler consumes.
|
|
42
|
+
def set_cron(expr)
|
|
43
|
+
@cron_schedule = Hammer::Cron.new(expr)
|
|
44
|
+
@cron = @cron_schedule.source
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def cron_schedule
|
|
48
|
+
@cron_schedule
|
|
49
|
+
end
|
|
50
|
+
|
|
38
51
|
def matches?(name)
|
|
39
52
|
name = name.to_s
|
|
40
53
|
name == @name || @alts.include?(name)
|
|
@@ -78,6 +91,7 @@ class Hammer
|
|
|
78
91
|
location: location,
|
|
79
92
|
alts: alts,
|
|
80
93
|
needs: needs,
|
|
94
|
+
cron: cron,
|
|
81
95
|
examples: examples,
|
|
82
96
|
options: options.map(&:to_h)
|
|
83
97
|
}
|
|
@@ -26,5 +26,13 @@ class Hammer
|
|
|
26
26
|
def needs(*names)
|
|
27
27
|
names.each { |n| @cmd.add_need(n) }
|
|
28
28
|
end
|
|
29
|
+
|
|
30
|
+
# Schedule this task for the `hammer h:cron` job server. Accepts a
|
|
31
|
+
# 5-field cron expression ('*/10 * * * *'), a simple interval ('10s',
|
|
32
|
+
# '10m', '2h', '1d') or a shortcut (@hourly, @daily, @weekly,
|
|
33
|
+
# @monthly). Validated immediately, so a typo fails at Hammerfile load.
|
|
34
|
+
def cron(expr)
|
|
35
|
+
@cmd.set_cron(expr)
|
|
36
|
+
end
|
|
29
37
|
end
|
|
30
38
|
end
|
data/lib/hammer/cron.rb
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
class Hammer
|
|
2
|
+
# Schedule of a task declared with `cron '<expr>'`. Parses the
|
|
3
|
+
# expression once (at Hammerfile eval, so typos fail at load time) and
|
|
4
|
+
# answers the scheduler's questions: does this wall-clock minute match,
|
|
5
|
+
# is a run due given the last one, and when is the next run.
|
|
6
|
+
#
|
|
7
|
+
# Accepted forms:
|
|
8
|
+
#
|
|
9
|
+
# cron '*/10 * * * *' # standard 5-field crontab
|
|
10
|
+
# cron '10m' # simple interval: every 10 minutes
|
|
11
|
+
# cron '10s' / '2h' / '1d' # seconds / hours / days
|
|
12
|
+
# cron '@daily' # shortcut, expands to '0 0 * * *'
|
|
13
|
+
#
|
|
14
|
+
# Cron fields support '*', 'a', 'a-b', '*/n', 'a-b/n' and comma lists,
|
|
15
|
+
# numeric values only (no 'jan'/'mon' names). Weekday 7 equals 0
|
|
16
|
+
# (Sunday). Intervals are measured from the last run - not aligned to
|
|
17
|
+
# the clock - which makes odd periods like '90m' possible and keeps
|
|
18
|
+
# restarts from re-firing (last run persists in the state file).
|
|
19
|
+
class Cron
|
|
20
|
+
SHORTCUTS ||= {
|
|
21
|
+
'@hourly' => '0 * * * *',
|
|
22
|
+
'@daily' => '0 0 * * *',
|
|
23
|
+
'@weekly' => '0 0 * * 0',
|
|
24
|
+
'@monthly' => '0 0 1 * *'
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
# [name, min, max] per cron field, in crontab order.
|
|
28
|
+
FIELDS ||= [
|
|
29
|
+
[:minute, 0, 59], [:hour, 0, 23], [:day, 1, 31],
|
|
30
|
+
[:month, 1, 12], [:weekday, 0, 7]
|
|
31
|
+
].freeze
|
|
32
|
+
|
|
33
|
+
INTERVAL_UNITS ||= { 's' => 1, 'm' => 60, 'h' => 3600, 'd' => 86_400 }.freeze
|
|
34
|
+
|
|
35
|
+
attr_reader :source, :interval
|
|
36
|
+
|
|
37
|
+
def initialize(expr)
|
|
38
|
+
@source = expr.to_s.strip
|
|
39
|
+
|
|
40
|
+
if (m = @source.match(/\A(\d+)([smhd])\z/))
|
|
41
|
+
@interval = m[1].to_i * INTERVAL_UNITS[m[2]]
|
|
42
|
+
raise Hammer::Error, "cron: interval must be > 0 in #{@source.inspect}" if @interval.zero?
|
|
43
|
+
return
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
if @source.start_with?('@') && !SHORTCUTS.key?(@source)
|
|
47
|
+
raise Hammer::Error, "cron: unknown shortcut #{@source.inspect} (valid: #{SHORTCUTS.keys.join(', ')})"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
parts = (SHORTCUTS[@source] || @source).split(/\s+/)
|
|
51
|
+
unless parts.size == 5
|
|
52
|
+
raise Hammer::Error, "cron: #{@source.inspect} - expected 5 fields ('*/10 * * * *'), " \
|
|
53
|
+
"an interval ('10s', '10m', '2h', '1d') or a shortcut (#{SHORTCUTS.keys.join(', ')})"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
@fields = FIELDS.each_with_index.map { |(name, lo, hi), i| parse_field(name, parts[i], lo, hi) }
|
|
57
|
+
@dom_star = parts[2] == '*'
|
|
58
|
+
@dow_star = parts[4] == '*'
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def interval?
|
|
62
|
+
!@interval.nil?
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# True when local wall-clock time `t` (minute resolution) matches the
|
|
66
|
+
# cron expression. Vixie-cron day rule: when BOTH day-of-month and
|
|
67
|
+
# day-of-week are restricted, matching either one is enough;
|
|
68
|
+
# otherwise both must match.
|
|
69
|
+
def matches?(t)
|
|
70
|
+
return false if interval?
|
|
71
|
+
min, hour, dom, mon, dow = @fields
|
|
72
|
+
return false unless min[t.min] && hour[t.hour] && mon[t.month]
|
|
73
|
+
dom_ok = dom[t.day]
|
|
74
|
+
dow_ok = dow[t.wday]
|
|
75
|
+
@dom_star || @dow_star ? dom_ok && dow_ok : dom_ok || dow_ok
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Should the scheduler fire at `tick` (a boundary-aligned Time),
|
|
79
|
+
# given the persisted last run? Cron mode fires once per matching
|
|
80
|
+
# minute; interval mode fires when a full interval has elapsed (or
|
|
81
|
+
# the job never ran, so a fresh job gives immediate feedback on the
|
|
82
|
+
# first tick).
|
|
83
|
+
def due?(tick, last_run)
|
|
84
|
+
if interval?
|
|
85
|
+
last_run.nil? || tick.to_i - last_run.to_i >= @interval
|
|
86
|
+
else
|
|
87
|
+
matches?(tick) && (last_run.nil? || last_run.to_i / 60 < tick.to_i / 60)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Next fire time strictly after `from`. Cron mode walks minute by
|
|
92
|
+
# minute - real-world expressions match within days, and the walk is
|
|
93
|
+
# ~100 hash lookups per simulated minute - capped at 500 days so an
|
|
94
|
+
# impossible date (a Feb 30 style expression) fails loudly instead of
|
|
95
|
+
# spinning forever.
|
|
96
|
+
def next_run(from = Time.now, last_run: nil)
|
|
97
|
+
if interval?
|
|
98
|
+
return from if last_run.nil?
|
|
99
|
+
Time.at(last_run.to_i + @interval)
|
|
100
|
+
else
|
|
101
|
+
t = Time.at((from.to_i / 60 + 1) * 60)
|
|
102
|
+
(500 * 24 * 60).times do
|
|
103
|
+
return t if matches?(t)
|
|
104
|
+
t += 60
|
|
105
|
+
end
|
|
106
|
+
raise Hammer::Error, "cron: #{@source.inspect} never matches"
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
# Parse one crontab field into a {int => true} lookup hash. Each
|
|
113
|
+
# comma-separated item is '*', 'a', 'a-b', '*/n' or 'a-b/n'.
|
|
114
|
+
# Weekday 7 is folded into 0 - both mean Sunday.
|
|
115
|
+
def parse_field(name, part, lo, hi)
|
|
116
|
+
seen = {}
|
|
117
|
+
part.split(',', -1).each do |item|
|
|
118
|
+
base, step = item.split('/', 2)
|
|
119
|
+
step = step ? parse_int(name, step, 1, hi) : 1
|
|
120
|
+
a, b =
|
|
121
|
+
if base == '*'
|
|
122
|
+
[lo, hi]
|
|
123
|
+
elsif base.include?('-')
|
|
124
|
+
base.split('-', 2).map { |v| parse_int(name, v, lo, hi) }
|
|
125
|
+
elsif item.include?('/')
|
|
126
|
+
raise Hammer::Error, "cron: #{name} - step needs '*' or a range: #{item.inspect}"
|
|
127
|
+
else
|
|
128
|
+
v = parse_int(name, base, lo, hi)
|
|
129
|
+
[v, v]
|
|
130
|
+
end
|
|
131
|
+
raise Hammer::Error, "cron: #{name} - inverted range #{item.inspect}" if a > b
|
|
132
|
+
(a..b).step(step) { |v| seen[name == :weekday && v == 7 ? 0 : v] = true }
|
|
133
|
+
end
|
|
134
|
+
seen
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Strict integer within [lo, hi], or a Hammer::Error naming the field
|
|
138
|
+
# so the message points straight at the typo.
|
|
139
|
+
def parse_int(name, str, lo, hi)
|
|
140
|
+
v = begin
|
|
141
|
+
Integer(str, 10)
|
|
142
|
+
rescue ArgumentError, TypeError
|
|
143
|
+
nil
|
|
144
|
+
end
|
|
145
|
+
unless v && v.between?(lo, hi)
|
|
146
|
+
raise Hammer::Error, "cron: bad #{name} value #{str.inspect} (allowed #{lo}-#{hi})"
|
|
147
|
+
end
|
|
148
|
+
v
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -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
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
require 'socket'
|
|
2
|
+
require 'cgi'
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
class Hammer
|
|
6
|
+
# Localhost web UI for Hammer::CronServer. Hand-rolled HTTP on stdlib
|
|
7
|
+
# TCPServer - the gem has no runtime dependencies and the protocol
|
|
8
|
+
# surface needed here is tiny: parse the request line, drain headers,
|
|
9
|
+
# route on verb+path, answer with Connection: close. No keep-alive, no
|
|
10
|
+
# chunked encoding, one short-lived thread per request so a slow
|
|
11
|
+
# client cannot block the scheduler or other viewers.
|
|
12
|
+
#
|
|
13
|
+
# Routes:
|
|
14
|
+
# GET / jobs table (schedule, last/next run, status)
|
|
15
|
+
# GET /job/<path> log viewer; ?file=1 shows the rotated log
|
|
16
|
+
# POST /job/<path>/run trigger a manual run, redirect back (303)
|
|
17
|
+
# GET /json live status as JSON, for scripting
|
|
18
|
+
class CronWeb
|
|
19
|
+
REFRESH_SECONDS ||= 10
|
|
20
|
+
TAIL_BYTES ||= 65_536 # log viewer shows at most the last 64 KB
|
|
21
|
+
|
|
22
|
+
def initialize(server, port:, pass: nil)
|
|
23
|
+
@server = server
|
|
24
|
+
@port = port
|
|
25
|
+
@pass = pass && !pass.empty? ? pass : nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Actual bound port - differs from the requested one only when the
|
|
29
|
+
# caller asked for port 0 (tests grab a free ephemeral port that way).
|
|
30
|
+
def bound_port
|
|
31
|
+
@tcp&.addr&.fetch(1)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Bind 127.0.0.1 only - the UI can trigger task runs, it must never
|
|
35
|
+
# listen on a public interface. Returns self so the caller can hold
|
|
36
|
+
# the instance for #stop.
|
|
37
|
+
def start
|
|
38
|
+
@tcp = TCPServer.new('127.0.0.1', @port)
|
|
39
|
+
@thread = Thread.new do
|
|
40
|
+
loop do
|
|
41
|
+
sock = begin
|
|
42
|
+
@tcp.accept
|
|
43
|
+
rescue IOError, Errno::EBADF
|
|
44
|
+
break # #stop closed the listener
|
|
45
|
+
end
|
|
46
|
+
Thread.new(sock) do |s|
|
|
47
|
+
begin
|
|
48
|
+
handle(s)
|
|
49
|
+
rescue StandardError
|
|
50
|
+
nil # a broken client must not take the UI down
|
|
51
|
+
ensure
|
|
52
|
+
s.close rescue nil
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
self
|
|
58
|
+
rescue Errno::EADDRINUSE
|
|
59
|
+
raise Hammer::Error, "h:cron web ui: port #{@port} is taken - pick another with --port"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Closing the listener pops the accept loop out with IOError.
|
|
63
|
+
def stop
|
|
64
|
+
@tcp&.close
|
|
65
|
+
@thread&.join(2)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
# Minimal HTTP: request line + headers, drain any Content-Length body
|
|
71
|
+
# (our POSTs carry none we care about), then route. With a --pass
|
|
72
|
+
# configured every route requires HTTP Basic auth first.
|
|
73
|
+
def handle(sock)
|
|
74
|
+
line = sock.gets or return
|
|
75
|
+
verb, target, = line.split(' ', 3)
|
|
76
|
+
length = 0
|
|
77
|
+
auth = nil
|
|
78
|
+
while (h = sock.gets) && h.strip != ''
|
|
79
|
+
length = h.split(':', 2).last.to_i if h =~ /\Acontent-length:/i
|
|
80
|
+
auth = h.split(':', 2).last.strip if h =~ /\Aauthorization:/i
|
|
81
|
+
end
|
|
82
|
+
sock.read(length) if length > 0
|
|
83
|
+
|
|
84
|
+
return unauthorized(sock) if @pass && !authorized?(auth)
|
|
85
|
+
|
|
86
|
+
path, query = target.to_s.split('?', 2)
|
|
87
|
+
route(sock, verb, CGI.unescape(path.to_s), query.to_s)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Only the password half of `user:pass` is checked - any username
|
|
91
|
+
# works (`curl -u :secret`). Constant-time compare so the password
|
|
92
|
+
# can't be guessed byte-by-byte from response timing.
|
|
93
|
+
def authorized?(header)
|
|
94
|
+
return false unless header && header =~ /\ABasic (.+)\z/
|
|
95
|
+
decoded = begin
|
|
96
|
+
Regexp.last_match(1).unpack1('m')
|
|
97
|
+
rescue ArgumentError
|
|
98
|
+
return false
|
|
99
|
+
end
|
|
100
|
+
pass = decoded.to_s.split(':', 2)[1].to_s
|
|
101
|
+
secure_compare(pass, @pass)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def secure_compare(a, b)
|
|
105
|
+
return false unless a.bytesize == b.bytesize
|
|
106
|
+
diff = 0
|
|
107
|
+
a.bytes.zip(b.bytes) { |x, y| diff |= x ^ y }
|
|
108
|
+
diff.zero?
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# 401 + the WWW-Authenticate challenge that makes browsers pop the
|
|
112
|
+
# native login prompt.
|
|
113
|
+
def unauthorized(sock)
|
|
114
|
+
body = layout('unauthorized', '<p>401 - password required</p>')
|
|
115
|
+
sock.write "HTTP/1.1 401 Unauthorized\r\n" \
|
|
116
|
+
"WWW-Authenticate: Basic realm=\"h:cron\"\r\n" \
|
|
117
|
+
"Content-Type: text/html; charset=utf-8\r\n" \
|
|
118
|
+
"Content-Length: #{body.bytesize}\r\n" \
|
|
119
|
+
"Connection: close\r\n\r\n"
|
|
120
|
+
sock.write body
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def route(sock, verb, path, query)
|
|
124
|
+
case
|
|
125
|
+
when verb == 'GET' && path == '/'
|
|
126
|
+
respond(sock, 200, layout('jobs', index_html))
|
|
127
|
+
when verb == 'GET' && path == '/json'
|
|
128
|
+
respond(sock, 200, JSON.pretty_generate(json_status), type: 'application/json')
|
|
129
|
+
when verb == 'POST' && path =~ %r{\A/job/(.+)/run\z}
|
|
130
|
+
job_path = Regexp.last_match(1)
|
|
131
|
+
if @server.run_now(job_path)
|
|
132
|
+
redirect(sock, "/job/#{CGI.escape(job_path)}")
|
|
133
|
+
else
|
|
134
|
+
respond(sock, 404, layout('not found', "<p>unknown job #{h(job_path)}</p>"))
|
|
135
|
+
end
|
|
136
|
+
when verb == 'GET' && path =~ %r{\A/job/(.+)\z}
|
|
137
|
+
job_path = Regexp.last_match(1)
|
|
138
|
+
if @server.job?(job_path)
|
|
139
|
+
respond(sock, 200, layout(job_path, job_html(job_path, rotated: query == 'file=1')))
|
|
140
|
+
else
|
|
141
|
+
respond(sock, 404, layout('not found', "<p>unknown job #{h(job_path)}</p>"))
|
|
142
|
+
end
|
|
143
|
+
else
|
|
144
|
+
respond(sock, 404, layout('not found', '<p>404</p>'))
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# ----- responses -------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
def respond(sock, status, body, type: 'text/html; charset=utf-8')
|
|
151
|
+
text = { 200 => 'OK', 303 => 'See Other', 404 => 'Not Found' }[status] || 'OK'
|
|
152
|
+
sock.write "HTTP/1.1 #{status} #{text}\r\n" \
|
|
153
|
+
"Content-Type: #{type}\r\n" \
|
|
154
|
+
"Content-Length: #{body.bytesize}\r\n" \
|
|
155
|
+
"Connection: close\r\n\r\n"
|
|
156
|
+
sock.write body
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Post-redirect-get: the browser lands back on a plain GET, so a
|
|
160
|
+
# refresh never re-triggers the run.
|
|
161
|
+
def redirect(sock, location)
|
|
162
|
+
sock.write "HTTP/1.1 303 See Other\r\n" \
|
|
163
|
+
"Location: #{location}\r\n" \
|
|
164
|
+
"Content-Length: 0\r\n" \
|
|
165
|
+
"Connection: close\r\n\r\n"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# ----- views -----------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
def index_html
|
|
171
|
+
rows = @server.snapshot.map do |j|
|
|
172
|
+
badge = status_badge(j)
|
|
173
|
+
<<~ROW
|
|
174
|
+
<tr>
|
|
175
|
+
<td><a href="/job/#{CGI.escape(j[:path])}">#{h(j[:path])}</a></td>
|
|
176
|
+
<td><code>#{h(j[:cron])}</code></td>
|
|
177
|
+
<td>#{h(fmt_time(j[:last_run]))}</td>
|
|
178
|
+
<td>#{badge}</td>
|
|
179
|
+
<td>#{h(fmt_time(j[:next_run]))}</td>
|
|
180
|
+
<td>
|
|
181
|
+
<form method="post" action="/job/#{CGI.escape(j[:path])}/run">
|
|
182
|
+
<button#{j[:running] ? ' disabled' : ''}>run now</button>
|
|
183
|
+
</form>
|
|
184
|
+
</td>
|
|
185
|
+
</tr>
|
|
186
|
+
ROW
|
|
187
|
+
end
|
|
188
|
+
<<~HTML
|
|
189
|
+
<table>
|
|
190
|
+
<tr><th>task</th><th>schedule</th><th>last run</th><th>status</th><th>next run</th><th></th></tr>
|
|
191
|
+
#{rows.join}
|
|
192
|
+
</table>
|
|
193
|
+
HTML
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def job_html(path, rotated: false)
|
|
197
|
+
job = @server.snapshot.find { |j| j[:path] == path }
|
|
198
|
+
file = log_file(path, rotated: rotated)
|
|
199
|
+
log = tail_file(file)
|
|
200
|
+
<<~HTML
|
|
201
|
+
<p><a href="/">← all jobs</a></p>
|
|
202
|
+
<h2>#{h(path)}</h2>
|
|
203
|
+
<p>
|
|
204
|
+
<code>#{h(job[:cron])}</code> ·
|
|
205
|
+
#{status_badge(job)} ·
|
|
206
|
+
last run #{h(fmt_time(job[:last_run]))} ·
|
|
207
|
+
next run #{h(fmt_time(job[:next_run]))}
|
|
208
|
+
#{job[:duration] ? "· took #{format('%.1f', job[:duration])}s" : ''}
|
|
209
|
+
</p>
|
|
210
|
+
<form method="post" action="/job/#{CGI.escape(path)}/run">
|
|
211
|
+
<button#{job[:running] ? ' disabled' : ''}>run now</button>
|
|
212
|
+
</form>
|
|
213
|
+
<p class="files">
|
|
214
|
+
#{rotated ? "<a href=\"/job/#{CGI.escape(path)}\">current log</a>" : '<b>current log</b>'} |
|
|
215
|
+
#{rotated ? '<b>rotated log</b>' : "<a href=\"/job/#{CGI.escape(path)}?file=1\">rotated log</a>"}
|
|
216
|
+
</p>
|
|
217
|
+
<pre>#{h(log)}</pre>
|
|
218
|
+
HTML
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def layout(title, body)
|
|
222
|
+
<<~HTML
|
|
223
|
+
<!doctype html>
|
|
224
|
+
<html>
|
|
225
|
+
<head>
|
|
226
|
+
<meta charset="utf-8">
|
|
227
|
+
<meta http-equiv="refresh" content="#{REFRESH_SECONDS}">
|
|
228
|
+
<title>h:cron - #{h(title)}</title>
|
|
229
|
+
<style>
|
|
230
|
+
body { font: 14px/1.5 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
231
|
+
margin: 2rem auto; max-width: 60rem; padding: 0 1rem; color: #222; }
|
|
232
|
+
h1 a { color: inherit; text-decoration: none; }
|
|
233
|
+
table { border-collapse: collapse; width: 100%; }
|
|
234
|
+
th, td { text-align: left; padding: .4rem .8rem; border-bottom: 1px solid #ddd; }
|
|
235
|
+
th { color: #888; font-weight: 600; }
|
|
236
|
+
code { background: #f4f4f4; padding: .1rem .3rem; border-radius: 3px; }
|
|
237
|
+
pre { background: #1a1a1a; color: #ddd; padding: 1rem; border-radius: 6px;
|
|
238
|
+
overflow-x: auto; white-space: pre-wrap; word-break: break-all; }
|
|
239
|
+
button { cursor: pointer; }
|
|
240
|
+
.ok { color: #2a7d2a; }
|
|
241
|
+
.failed, .error { color: #c02626; }
|
|
242
|
+
.running { color: #b8860b; }
|
|
243
|
+
.never, .unknown { color: #888; }
|
|
244
|
+
.files { color: #888; }
|
|
245
|
+
footer { color: #aaa; margin-top: 2rem; font-size: 12px; }
|
|
246
|
+
@media (prefers-color-scheme: dark) {
|
|
247
|
+
body { background: #111; color: #ccc; }
|
|
248
|
+
th, td { border-color: #333; }
|
|
249
|
+
code { background: #222; }
|
|
250
|
+
}
|
|
251
|
+
</style>
|
|
252
|
+
</head>
|
|
253
|
+
<body>
|
|
254
|
+
<h1><a href="/">h:cron</a></h1>
|
|
255
|
+
#{body}
|
|
256
|
+
<footer>hammer #{Hammer::VERSION} · #{h(@server.root_dir)} · refreshes every #{REFRESH_SECONDS}s</footer>
|
|
257
|
+
</body>
|
|
258
|
+
</html>
|
|
259
|
+
HTML
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def status_badge(j)
|
|
263
|
+
label = j[:running] ? 'running' : (j[:status] || 'never ran')
|
|
264
|
+
css = j[:running] ? 'running' : (j[:status] || 'never')
|
|
265
|
+
extra = !j[:running] && j[:exit_code] ? " (exit #{j[:exit_code]})" : ''
|
|
266
|
+
"<span class=\"#{h(css)}\">#{h(label)}#{h(extra)}</span>"
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def json_status
|
|
270
|
+
@server.snapshot.map do |j|
|
|
271
|
+
j.merge(last_run: j[:last_run]&.to_i, next_run: j[:next_run]&.to_i)
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# ----- helpers ----------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
def log_file(path, rotated: false)
|
|
278
|
+
slug = path.tr(':', '-').gsub(/[^\w.-]/, '-')
|
|
279
|
+
file = File.join(@server.root_dir, 'log/hammer', "#{slug}.log")
|
|
280
|
+
rotated ? "#{file}.1" : file
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Last TAIL_BYTES of the file - enough for a viewer, cheap even when
|
|
284
|
+
# the log sits right under the 1 MB rotation cap.
|
|
285
|
+
def tail_file(file)
|
|
286
|
+
size = File.size(file)
|
|
287
|
+
File.open(file) do |f|
|
|
288
|
+
f.seek([size - TAIL_BYTES, 0].max)
|
|
289
|
+
data = f.read.to_s
|
|
290
|
+
size > TAIL_BYTES ? "... (truncated)\n#{data}" : data
|
|
291
|
+
end
|
|
292
|
+
rescue Errno::ENOENT
|
|
293
|
+
'(no log yet)'
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def fmt_time(t)
|
|
297
|
+
t ? t.strftime('%F %T') : 'never'
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def h(text)
|
|
301
|
+
CGI.escapeHTML(text.to_s)
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
end
|
data/lib/lux-hammer.rb
CHANGED
|
@@ -2,6 +2,7 @@ require_relative 'hammer/shell'
|
|
|
2
2
|
require_relative 'hammer/option'
|
|
3
3
|
require_relative 'hammer/parser'
|
|
4
4
|
require_relative 'hammer/command'
|
|
5
|
+
require_relative 'hammer/cron'
|
|
5
6
|
require_relative 'hammer/loader'
|
|
6
7
|
require_relative 'hammer/builder'
|
|
7
8
|
require_relative 'hammer/command_builder'
|
|
@@ -62,6 +63,7 @@ class Hammer
|
|
|
62
63
|
sub.instance_variable_set(:@pending_options, [])
|
|
63
64
|
sub.instance_variable_set(:@pending_alts, [])
|
|
64
65
|
sub.instance_variable_set(:@pending_needs, [])
|
|
66
|
+
sub.instance_variable_set(:@pending_cron, nil)
|
|
65
67
|
end
|
|
66
68
|
|
|
67
69
|
# ----- class-level DSL for `def`-style commands ---------------------
|
|
@@ -80,6 +82,7 @@ class Hammer
|
|
|
80
82
|
def opt(name, **o) ; @pending_options << Option.new(name, **o) end
|
|
81
83
|
def alt(*names) ; @pending_alts.concat(names) end
|
|
82
84
|
def needs(*names) ; @pending_needs.concat(names) end
|
|
85
|
+
def cron(expr) ; @pending_cron = expr end
|
|
83
86
|
|
|
84
87
|
def method_added(method_name)
|
|
85
88
|
super
|
|
@@ -90,6 +93,7 @@ class Hammer
|
|
|
90
93
|
@pending_options.each { |o| cmd.add_option(o) }
|
|
91
94
|
@pending_alts.each { |n| cmd.add_alt(n) }
|
|
92
95
|
@pending_needs.each { |n| cmd.add_need(n) }
|
|
96
|
+
cmd.set_cron(@pending_cron) if @pending_cron
|
|
93
97
|
|
|
94
98
|
# If the method takes no args, call it without opts. Otherwise pass
|
|
95
99
|
# opts. So both `def build` and `def build(opts)` work.
|
|
@@ -104,6 +108,7 @@ class Hammer
|
|
|
104
108
|
@pending_options = []
|
|
105
109
|
@pending_alts = []
|
|
106
110
|
@pending_needs = []
|
|
111
|
+
@pending_cron = nil
|
|
107
112
|
end
|
|
108
113
|
|
|
109
114
|
# Resolved lazily on first read and memoized, so callers that need the
|
|
@@ -183,6 +188,7 @@ class Hammer
|
|
|
183
188
|
@pending_options = []
|
|
184
189
|
@pending_alts = []
|
|
185
190
|
@pending_needs = []
|
|
191
|
+
@pending_cron = nil
|
|
186
192
|
end
|
|
187
193
|
|
|
188
194
|
# Open a namespace (group of commands). Everything inside the block
|
|
@@ -905,6 +911,7 @@ class Hammer
|
|
|
905
911
|
Shell.say(stripped.empty? ? '' : " #{stripped}")
|
|
906
912
|
end unless cmd.desc.empty?
|
|
907
913
|
Shell.say " alias: #{cmd.alts.join(', ')}" unless cmd.alts.empty?
|
|
914
|
+
Shell.say " cron: #{cmd.cron}" if cmd.cron
|
|
908
915
|
unless cmd.options.empty?
|
|
909
916
|
Shell.say ''
|
|
910
917
|
Shell.say 'Options:', :yellow
|
data/recipes/llm.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: lux-hammer
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.3.
|
|
4
|
+
version: 0.3.15
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dino Reic
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-07-11 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: minitest
|
|
@@ -42,6 +42,9 @@ files:
|
|
|
42
42
|
- "./lib/hammer/builtins.rb"
|
|
43
43
|
- "./lib/hammer/command.rb"
|
|
44
44
|
- "./lib/hammer/command_builder.rb"
|
|
45
|
+
- "./lib/hammer/cron.rb"
|
|
46
|
+
- "./lib/hammer/cron_server.rb"
|
|
47
|
+
- "./lib/hammer/cron_web.rb"
|
|
45
48
|
- "./lib/hammer/dotenv.rb"
|
|
46
49
|
- "./lib/hammer/loader.rb"
|
|
47
50
|
- "./lib/hammer/option.rb"
|