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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7ea304583caa18c45db8f340aecf5e08803f23eebe4f9152eefec7ac77c64b18
4
- data.tar.gz: 72eafeef47b8ae8c2ff41e226756199f085865899532c3d7669dd5025c4b4019
3
+ metadata.gz: a61994d8f82b575a019cfb1171935b334b840cdb5561fcc56e9ba2c141a314d2
4
+ data.tar.gz: a4d4d730c830b4d30de13dc9e0c27e9cc2fa3902c9297154d4fe81ba5365fe94
5
5
  SHA512:
6
- metadata.gz: 7cfa688c6d9d21acc72a6c8d614acefe2942746fb8b8b55f4f72fb08d9f1d37a6eccbd3326551348ea572889e3404790c4a59f64577d394f257e1ab826959c45
7
- data.tar.gz: 535fb35b15ae31e9e861ad0ff32d9e216382b533cb877b12560185cd722724c0d01a0dde350facebf209178166d88c4109328e7882ba6a14ae0273eb111d2215
6
+ metadata.gz: 0e4c0a5f1fbf47550df2929670e647cf7e1e390e7b3aab0b71932dc5f9557295953c39cdf9fafc6ac719d53b94ed14058bd1a2bb32bcaf9bb79ff3b2198fee16
7
+ data.tar.gz: b0012fd33259c5ea63af2d3ea0040c4a618292042f842dbda523f462d90fbc9138c04217c3c86a7d52fd56a6b650249ab2dd9bb6f070e1f1a24497ab68e6dbe2
data/.version CHANGED
@@ -1 +1 @@
1
- 0.3.14
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
 
@@ -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
@@ -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
@@ -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="/">&larr; all jobs</a></p>
202
+ <h2>#{h(path)}</h2>
203
+ <p>
204
+ <code>#{h(job[:cron])}</code> &middot;
205
+ #{status_badge(job)} &middot;
206
+ last run #{h(fmt_time(job[:last_run]))} &middot;
207
+ next run #{h(fmt_time(job[:next_run]))}
208
+ #{job[:duration] ? "&middot; 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} &middot; #{h(@server.root_dir)} &middot; 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
@@ -178,7 +178,7 @@ namespace :prompt do
178
178
  def folders
179
179
  [
180
180
  ['local', File.join(Dir.pwd, 'doc', 'command')],
181
- ['global', File.expand_path('~/dev/skills/command')]
181
+ ['global', File.expand_path('~/dev/ai/command')]
182
182
  ]
183
183
  end
184
184
 
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.14
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-06-27 00:00:00.000000000 Z
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"