asgard 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.loki +3 -0
- data/.reek.yml +12 -0
- data/CHANGELOG.md +80 -0
- data/CLAUDE.md +10 -1
- data/README.md +41 -1
- data/docs/api.md +21 -0
- data/docs/index.md +1 -0
- data/docs/schedule.md +6 -1
- data/docs/watch.md +123 -0
- data/examples/.envrc +3 -0
- data/examples/watcher_examples/.envrc +3 -0
- data/examples/watcher_examples/.loki +54 -0
- data/examples/watcher_examples/README.md +112 -0
- data/examples/watcher_examples/bin/hello +3 -0
- data/examples/watcher_examples/data/records.json +1 -0
- data/examples/watcher_examples/docs/drafts/ideas.md +5 -0
- data/examples/watcher_examples/docs/index.md +4 -0
- data/examples/watcher_examples/lib/greeter.rb +7 -0
- data/lib/asgard/kernel_methods.rb +6 -0
- data/lib/asgard/schedule/commands.rb +102 -36
- data/lib/asgard/schedule/declaration.rb +61 -0
- data/lib/asgard/schedule/launchd.rb +55 -0
- data/lib/asgard/schedule/systemd.rb +47 -0
- data/lib/asgard/schedule/table.rb +79 -0
- data/lib/asgard/schedule.rb +1 -0
- data/lib/asgard/tasks.rb +9 -0
- data/lib/asgard/version.rb +1 -1
- data/lib/asgard/watch/changes.rb +52 -0
- data/lib/asgard/watch/commands.rb +211 -0
- data/lib/asgard/watch/daemon.rb +123 -0
- data/lib/asgard/watch/declaration.rb +173 -0
- data/lib/asgard/watch/dispatcher.rb +55 -0
- data/lib/asgard/watch/reporter.rb +110 -0
- data/lib/asgard/watch/spawn.rb +75 -0
- data/lib/asgard/watch/state.rb +104 -0
- data/lib/asgard/watch/worker.rb +75 -0
- data/lib/asgard/watch.rb +51 -0
- data/lib/asgard.rb +7 -2
- data/mkdocs.yml +1 -0
- metadata +65 -2
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# Watcher example
|
|
2
|
+
|
|
3
|
+
A small project whose tasks run when its files change. The `.loki` here
|
|
4
|
+
declares four watches; `lib/`, `bin/`, `docs/` and `data/` hold one
|
|
5
|
+
placeholder file each so the watcher has something to watch right away.
|
|
6
|
+
|
|
7
|
+
## Before you start
|
|
8
|
+
|
|
9
|
+
The watcher runs its daemon and every triggered task as separate `asgard`
|
|
10
|
+
processes. If you are running asgard from a git checkout of this
|
|
11
|
+
repository rather than an installed gem, install the checkout as the gem
|
|
12
|
+
first, from the repository root:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
asgard install # builds pkg/asgard-<version>.gem and gem-installs it
|
|
16
|
+
gem list asgard # the checkout's version should be listed
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Otherwise an older asgard gem already on the machine can be the one the
|
|
20
|
+
spawned processes run, and it will not understand `watch`.
|
|
21
|
+
|
|
22
|
+
## Walkthrough
|
|
23
|
+
|
|
24
|
+
From this directory:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
asgard watcher start
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
started watcher for watcher_examples (pid 12345); log: ~/.local/state/asgard/watch/watcher-examples/watcher.log
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Now make some changes and watch the log fill up:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
echo '# a change' >> lib/greeter.rb # lint runs, with the path in changes.modified
|
|
38
|
+
echo 'more' >> docs/index.md # docs runs; BUILD_ENV=demo comes from env:
|
|
39
|
+
echo 'more' >> docs/drafts/ideas.md # nothing: drafts/ is ignored
|
|
40
|
+
echo '{}' > data/new.json # reindex runs: a file was added
|
|
41
|
+
echo '{"edited": true}' > data/new.json # nothing: events: is added and removed only
|
|
42
|
+
rm data/new.json # reindex runs again: a file was removed
|
|
43
|
+
asgard watcher log
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
2026-10-18 23:40:01 trigger lint task=lint lib/greeter.rb
|
|
48
|
+
2026-10-18 23:40:01 spawn lint task=lint pid=12351 lib/greeter.rb
|
|
49
|
+
lint: lib/greeter.rb (7 lines) triggered by lint
|
|
50
|
+
2026-10-18 23:40:02 exit lint task=lint pid=12351 status=0 seconds=0.41
|
|
51
|
+
2026-10-18 23:40:05 trigger docs task=docs docs/index.md
|
|
52
|
+
2026-10-18 23:40:05 spawn docs task=docs pid=12360 docs/index.md
|
|
53
|
+
docs: building for demo; changed: docs/index.md
|
|
54
|
+
2026-10-18 23:40:06 exit docs task=docs pid=12360 status=0 seconds=0.40
|
|
55
|
+
2026-10-18 23:40:09 trigger reindex task=reindex data/new.json
|
|
56
|
+
2026-10-18 23:40:09 spawn reindex task=reindex pid=12371 data/new.json
|
|
57
|
+
reindex: 1 added, 0 removed; 2 data file(s) now
|
|
58
|
+
2026-10-18 23:40:10 exit reindex task=reindex pid=12371 status=0 seconds=0.40
|
|
59
|
+
...
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Each trigger line names the entry and the changed paths. Each spawn and
|
|
63
|
+
exit line carries the task's pid, so the lines of overlapping tasks can be
|
|
64
|
+
told apart; a task's own output sits between its spawn and its exit. Save
|
|
65
|
+
`lib/greeter.rb` twice in quick succession and the second trigger says
|
|
66
|
+
`(queued behind pid N)`: the same task never overlaps itself, while
|
|
67
|
+
different tasks run at the same time.
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
asgard watcher list
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
┌──────────┬────────────────┬──────────────────────────────────────┬──────────┬────────────────────────────────┐
|
|
75
|
+
│ NAME │ TASK │ WATCHES │ DAEMON │ LAST RUN │
|
|
76
|
+
├──────────┼────────────────┼──────────────────────────────────────┼──────────┼────────────────────────────────┤
|
|
77
|
+
│ lint │ asgard lint │ lib/**/*.rb │ running │ 0 (0.4s, 2026-10-18 23:40:02) │
|
|
78
|
+
│ docs │ asgard docs │ docs │ running │ 0 (0.4s, 2026-10-18 23:40:06) │
|
|
79
|
+
│ reindex │ asgard reindex │ data/**/*.json [added, removed] │ running │ 0 (0.4s, 2026-10-18 23:40:14) │
|
|
80
|
+
│ lint-bin │ asgard lint │ bin/**/* │ running │ never │
|
|
81
|
+
└──────────┴────────────────┴──────────────────────────────────────┴──────────┴────────────────────────────────┘
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
asgard watcher stop
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
A task that is still running finishes first; `stop` names it while it
|
|
89
|
+
waits.
|
|
90
|
+
|
|
91
|
+
## Running the tasks by hand
|
|
92
|
+
|
|
93
|
+
Every task works without the watcher too. `changes` is then empty, which
|
|
94
|
+
`lint` turns into "lint everything":
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
asgard lint
|
|
98
|
+
asgard docs
|
|
99
|
+
asgard reindex
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## What the watcher restricts
|
|
103
|
+
|
|
104
|
+
A triggered task does not inherit the shell the watcher was started from.
|
|
105
|
+
It sees `PATH`, `HOME`, `USER`, `LANG` and a few other basics, the entry's
|
|
106
|
+
`env:`, a project `.envrc` through `direnv`, and the trigger variables
|
|
107
|
+
`ASGARD_WATCH`, `ASGARD_WATCH_ROOT`, `ASGARD_MODIFIED`, `ASGARD_ADDED` and
|
|
108
|
+
`ASGARD_REMOVED`. `asgard watcher preview` prints the exact command and
|
|
109
|
+
environment each entry would run.
|
|
110
|
+
|
|
111
|
+
Edit `.loki` while the watcher runs and it restarts itself with the new
|
|
112
|
+
declarations.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "records": ["one", "two"] }
|
|
@@ -16,6 +16,12 @@ module Kernel
|
|
|
16
16
|
end
|
|
17
17
|
module_function :env
|
|
18
18
|
|
|
19
|
+
# The files the watcher saw change when it triggered this task, as an
|
|
20
|
+
# Asgard::Changes (modified, added, removed, all). Every list is empty
|
|
21
|
+
# when the task is run by hand.
|
|
22
|
+
def changes = Asgard::Changes.current
|
|
23
|
+
module_function :changes
|
|
24
|
+
|
|
19
25
|
def loki_up(name = ".loki")
|
|
20
26
|
dir = Pathname.new(Dir.pwd)
|
|
21
27
|
loop do
|
|
@@ -54,6 +54,11 @@ module Asgard
|
|
|
54
54
|
|
|
55
55
|
def schedule_summary(spec) = "#{Schedule.command_line(spec[:task], spec[:args])} (#{Schedule.describe(**spec)})"
|
|
56
56
|
|
|
57
|
+
# One `list` row for this project: its declaration (if still declared) plus live state.
|
|
58
|
+
def schedule_row(name)
|
|
59
|
+
{ name:, **Schedule.declared_columns(schedules[name], "no longer declared"), **Schedule.live_columns(scheduler, name) }
|
|
60
|
+
end
|
|
61
|
+
|
|
57
62
|
def schedule_install(spec, asgard:, direnv:)
|
|
58
63
|
name = spec[:name]
|
|
59
64
|
state = scheduler.install(spec, asgard:, direnv:)
|
|
@@ -65,16 +70,6 @@ module Asgard
|
|
|
65
70
|
scheduler.uninstall(name)
|
|
66
71
|
puts "removed #{name}"
|
|
67
72
|
end
|
|
68
|
-
|
|
69
|
-
def require_installed!(name)
|
|
70
|
-
abort "#{name} is not installed (see `asgard schedule list`)." unless scheduler.installed_names.include?(name)
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
def require_known!(name)
|
|
74
|
-
return if schedules.key?(name) || scheduler.installed_names.include?(name)
|
|
75
|
-
|
|
76
|
-
abort "#{name} is neither declared nor installed (see `asgard schedule list`)."
|
|
77
|
-
end
|
|
78
73
|
end
|
|
79
74
|
|
|
80
75
|
desc "preview", "Show the job files install would write, without installing them"
|
|
@@ -100,61 +95,132 @@ module Asgard
|
|
|
100
95
|
scheduler.notes.each { |note| $stderr.puts note }
|
|
101
96
|
end
|
|
102
97
|
|
|
98
|
+
no_commands do
|
|
99
|
+
# [backend, entry name] for an installed entry of this or any project: a
|
|
100
|
+
# bare name, or project/name when several projects use the same one. nil if none.
|
|
101
|
+
def find_installed(name)
|
|
102
|
+
return [scheduler, name] if scheduler.installed_names.include?(name)
|
|
103
|
+
|
|
104
|
+
project, entry = name.include?("/") ? name.split("/", 2) : [nil, name]
|
|
105
|
+
matches = scheduler.installed_entries.select { |slug, found| found == entry && (project.nil? || slug == project) }
|
|
106
|
+
abort "#{name} is in several projects (#{matches.map(&:first).join(', ')}); name one as project/#{entry}." if matches.size > 1
|
|
107
|
+
|
|
108
|
+
matches.first&.then { |slug, found| [backend_for(slug), found] }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def resolve_installed(name) = find_installed(name) || abort("#{name} is not installed (see `asgard schedule list --all`).")
|
|
112
|
+
|
|
113
|
+
# Like resolve_installed, but a declared entry of this project counts even before it is installed.
|
|
114
|
+
def resolve_known(name)
|
|
115
|
+
return [scheduler, name] if schedules.key?(name)
|
|
116
|
+
|
|
117
|
+
find_installed(name) || abort("#{name} is neither declared nor installed (see `asgard schedule list --all`).")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# True when list should cover every project: asked for with --all, or
|
|
121
|
+
# there is no .loki here, so no single project to scope to.
|
|
122
|
+
def list_all? = options[:all] || Asgard.find_task_file.nil?
|
|
123
|
+
|
|
124
|
+
# What `list` says when there is nothing to show; a project-scoped list
|
|
125
|
+
# also tells the user how to look further.
|
|
126
|
+
def no_schedules_message
|
|
127
|
+
backend = scheduler.scheduler
|
|
128
|
+
return "No scheduled tasks installed (#{backend})." if list_all?
|
|
129
|
+
|
|
130
|
+
"No scheduled tasks installed for #{schedule_project} (#{backend}).\n" \
|
|
131
|
+
"Use `asgard schedule list --all` to see all currently scheduled tasks."
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# The rows to list, grouped under a project title: { "Project: name (/path)" => [row, ...] }
|
|
135
|
+
# for every project, or { nil => [row, ...] } for this one. Empty groups drop out.
|
|
136
|
+
def list_groups
|
|
137
|
+
groups = list_all? ? all_schedule_groups : { nil => scheduler.installed_names.map { |name| schedule_row(name) } }
|
|
138
|
+
groups.reject { |_, rows| rows.empty? }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Every installed entry on the machine, grouped by project.
|
|
142
|
+
def all_schedule_groups
|
|
143
|
+
by_project = scheduler.installed_entries.group_by(&:first)
|
|
144
|
+
by_project.to_h { |slug, entries| [project_title(slug, entries.first.last), entry_rows(entries)] }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# "Project: slug (/full/path)", the path read from one of the project's job files.
|
|
148
|
+
def project_title(slug, name)
|
|
149
|
+
dir = slug == current_slug ? schedule_root : backend_for(slug).installed_directory(name)
|
|
150
|
+
["Project: #{slug}", ("(#{dir})" if dir)].compact.join(" ")
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def current_slug = Asgard.find_task_file && Schedule.slug(schedule_project)
|
|
154
|
+
|
|
155
|
+
def backend_for(slug) = Schedule.backend_class.new(project: slug, root: schedule_root)
|
|
156
|
+
|
|
157
|
+
def entry_rows(entries) = entries.map { |slug, name| entry_row(slug, name) }
|
|
158
|
+
|
|
159
|
+
# This project's entries keep their declaration columns; see other_project_row.
|
|
160
|
+
def entry_row(slug, name)
|
|
161
|
+
return schedule_row(name) if slug == current_slug
|
|
162
|
+
|
|
163
|
+
other_project_row(slug, name)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# A row for an entry of another project: its .loki isn't loaded, so the
|
|
167
|
+
# command and schedule are read back from the installed job files.
|
|
168
|
+
def other_project_row(slug, name)
|
|
169
|
+
backend = backend_for(slug)
|
|
170
|
+
shown = backend.installed_columns(name) || Schedule.declared_columns(nil, "(unreadable)")
|
|
171
|
+
{ name:, **shown, **Schedule.live_columns(backend, name) }
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
103
175
|
desc "list", "Show this project's installed entries, their state, and last exit status"
|
|
176
|
+
method_option :all, aliases: "-a", type: :boolean, desc: "List every project's entries, not just this one's"
|
|
104
177
|
def list
|
|
105
|
-
|
|
106
|
-
return puts
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
state = case status[:state]
|
|
111
|
-
when :active then "active; last exit: #{status[:last_exit] || 'never run'}"
|
|
112
|
-
when :stopped then "stopped"
|
|
113
|
-
else "not loaded"
|
|
114
|
-
end
|
|
115
|
-
spec = schedules[name]
|
|
116
|
-
timing = spec ? schedule_summary(spec) : "no longer declared"
|
|
117
|
-
puts "#{name} #{timing} [#{state}] log: #{scheduler.log_path(name)}"
|
|
118
|
-
end
|
|
178
|
+
groups = list_groups
|
|
179
|
+
return puts(no_schedules_message) if groups.empty?
|
|
180
|
+
|
|
181
|
+
puts groups.map { |title, rows| Schedule::Table.draw(rows, title:) }.join("\n\n")
|
|
182
|
+
puts "\nLogs: #{File.dirname(scheduler.log_path(groups.values.first.first[:name]))} (asgard schedule log NAME)"
|
|
119
183
|
end
|
|
120
184
|
|
|
121
185
|
desc "stop NAME", "Stop one scheduled entry; it stays stopped across reboots and installs until started"
|
|
122
186
|
def stop(name)
|
|
123
|
-
|
|
124
|
-
|
|
187
|
+
backend, entry = resolve_installed(name)
|
|
188
|
+
backend.stop(entry)
|
|
125
189
|
puts "stopped #{name}"
|
|
126
190
|
end
|
|
127
191
|
|
|
128
192
|
desc "start NAME", "Start a stopped entry, or install and start just this declared entry"
|
|
129
193
|
def start(name)
|
|
130
|
-
|
|
131
|
-
|
|
194
|
+
backend, entry = resolve_known(name)
|
|
195
|
+
own = backend.equal?(scheduler)
|
|
196
|
+
spec = schedules[entry] if own
|
|
132
197
|
|
|
133
198
|
if spec
|
|
134
199
|
declared_schedules # validates the task exists
|
|
135
200
|
scheduler.install(spec, asgard: schedule_asgard, direnv: schedule_direnv) # refresh the job files
|
|
136
201
|
else
|
|
137
|
-
|
|
202
|
+
why = own ? "is no longer declared" : "belongs to another project"
|
|
203
|
+
$stderr.puts "schedule: #{name} #{why}; starting its installed job as-is"
|
|
138
204
|
end
|
|
139
|
-
|
|
205
|
+
backend.start(entry)
|
|
140
206
|
puts "started #{name}"
|
|
141
207
|
end
|
|
142
208
|
|
|
143
209
|
desc "trigger NAME", "Run an installed entry now, under the scheduler's environment"
|
|
144
210
|
def trigger(name)
|
|
145
|
-
|
|
146
|
-
abort "#{name} is stopped; `asgard schedule start #{name}` first." if
|
|
211
|
+
backend, entry = resolve_installed(name)
|
|
212
|
+
abort "#{name} is stopped; `asgard schedule start #{name}` first." if backend.status(entry)[:state] == :stopped
|
|
147
213
|
|
|
148
|
-
|
|
149
|
-
puts "triggered #{name}; output goes to #{
|
|
214
|
+
backend.trigger(entry)
|
|
215
|
+
puts "triggered #{name}; output goes to #{backend.log_path(entry)}"
|
|
150
216
|
end
|
|
151
217
|
|
|
152
218
|
desc "log NAME", "Print the named entry's log file to STDOUT"
|
|
153
219
|
method_option :follow, aliases: "-f", type: :boolean, desc: "Keep printing new output as it arrives (tail -f)"
|
|
154
220
|
def log(name)
|
|
155
|
-
|
|
221
|
+
backend, entry = resolve_known(name)
|
|
156
222
|
|
|
157
|
-
path =
|
|
223
|
+
path = backend.log_path(entry)
|
|
158
224
|
return $stderr.puts "#{name} has no log yet (#{path}); it is created on the first run." unless File.exist?(path)
|
|
159
225
|
return sh("tail -n +1 -f #{path.shellescape}", silent: true, exec: true) if options[:follow]
|
|
160
226
|
|
|
@@ -24,6 +24,9 @@ module Asgard
|
|
|
24
24
|
# #start(name) / #stop(name) # stop persists across reboots and reinstalls
|
|
25
25
|
# #trigger(name) # run once now, under the scheduler
|
|
26
26
|
# #installed_names # => ["demo", ...] for this project
|
|
27
|
+
# #installed_entries # => [[project_slug, "demo"], ...] for every project
|
|
28
|
+
# #installed_columns(name) # => { command:, schedule: } read back from the job files, or nil
|
|
29
|
+
# #installed_directory(name) # => the project root the job runs in, or nil
|
|
27
30
|
# #status(name) # => { state: :active|:stopped|:not_loaded, last_exit: String|nil }
|
|
28
31
|
# #log_path(name) # => path the job's output is appended to
|
|
29
32
|
# #notes # => [String] platform hints to show after install
|
|
@@ -121,6 +124,64 @@ module Asgard
|
|
|
121
124
|
"#{Array(at).join(', ')} #{Array(on).join(', ')}"
|
|
122
125
|
end
|
|
123
126
|
|
|
127
|
+
# For the `list` table: like describe, but a run of hourly times
|
|
128
|
+
# collapses ("10:00, 11:00 ... 17:00" => "10:00-17:00 hourly").
|
|
129
|
+
def describe_compact(at: nil, on: :daily, every: nil, **)
|
|
130
|
+
return "every #{every}s" if every
|
|
131
|
+
|
|
132
|
+
"#{compact_times(Array(at))} #{Array(on).join(', ')}"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# The command and schedule columns of a `list` row for a declared entry.
|
|
136
|
+
def list_columns(spec) = { command: command_line(spec[:task], spec[:args]), schedule: describe_compact(**spec) }
|
|
137
|
+
|
|
138
|
+
# The command and schedule columns of a `list` row: the declaration's, or
|
|
139
|
+
# `missing` in the schedule column when there is none.
|
|
140
|
+
def declared_columns(spec, missing) = spec ? list_columns(spec) : { command: "-", schedule: missing }
|
|
141
|
+
|
|
142
|
+
# The state and last-exit columns of a `list` row, from a backend's live status.
|
|
143
|
+
def live_columns(backend, name)
|
|
144
|
+
state, last_exit = backend.status(name).values_at(:state, :last_exit)
|
|
145
|
+
{ state: state.to_s.tr("_", " "), last_exit: state == :active ? (last_exit || "never run") : "-" }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# The command a job runs, from its program arguments: everything from
|
|
149
|
+
# `asgard` on, so a direnv wrapper and the asgard install path drop out.
|
|
150
|
+
def command_from_arguments(arguments)
|
|
151
|
+
start = arguments.index { |word| File.basename(word) == "asgard" }
|
|
152
|
+
Shellwords.join(["asgard", *arguments.drop(start ? start + 1 : 0)])
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# describe_compact for calendar entries (the shape `calendar` returns), one
|
|
156
|
+
# group per distinct set of days: "17:30 weekdays; 09:00 saturday".
|
|
157
|
+
def describe_calendar(entries)
|
|
158
|
+
entries.group_by { |entry| entry[:days]&.sort }.map { |days, group| describe_days(days, group) }.join("; ")
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def describe_days(days, group)
|
|
162
|
+
describe_compact(at: group.map { |entry| format("%<hour>02d:%<minute>02d", entry) }, on: day_names(days))
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# The command and schedule columns for a job read back from its files.
|
|
166
|
+
def installed_columns(arguments:, calendar:, every:)
|
|
167
|
+
{ command: command_from_arguments(arguments), schedule: every ? "every #{every}s" : describe_calendar(calendar) }
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Weekday numbers back to what on: took: nil => :daily, [1, 2, 3, 4, 5] => :weekdays, [5] => [:friday].
|
|
171
|
+
def day_names(days)
|
|
172
|
+
return :daily unless days
|
|
173
|
+
|
|
174
|
+
names = days.map { |day| DAYS.fetch(day) }
|
|
175
|
+
DAY_GROUPS.key(names) || names
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def compact_times(times)
|
|
179
|
+
parsed = times.map { |time| parse_time(time) }
|
|
180
|
+
hourly = parsed.size >= 3 && parsed.map(&:last).uniq.size == 1 &&
|
|
181
|
+
parsed.each_cons(2).all? { |(hour, _), (next_hour, _)| next_hour == hour + 1 }
|
|
182
|
+
hourly ? "#{times.first}-#{times.last} hourly" : times.join(", ")
|
|
183
|
+
end
|
|
184
|
+
|
|
124
185
|
# "17:30" => [17, 30]
|
|
125
186
|
def parse_time(time)
|
|
126
187
|
shown = time.inspect
|
|
@@ -64,6 +64,40 @@ module Asgard
|
|
|
64
64
|
end
|
|
65
65
|
end
|
|
66
66
|
|
|
67
|
+
# The parts of a plist (as `plist` writes it) that say what runs and when:
|
|
68
|
+
# { arguments: [String], calendar: [{ hour:, minute:, days: [Integer]|nil }], every: Integer|nil }
|
|
69
|
+
def self.parse_plist(xml)
|
|
70
|
+
{
|
|
71
|
+
arguments: array_body(xml, "ProgramArguments").scan(%r{<string>(.*?)</string>}m).flatten.map { |text| unescape(text) },
|
|
72
|
+
calendar: calendar_entries(array_body(xml, "StartCalendarInterval").scan(%r{<dict>(.*?)</dict>}m).flatten),
|
|
73
|
+
every: xml[%r{<key>StartInterval</key>\s*<integer>(\d+)</integer>}, 1]&.to_i
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# StartCalendarInterval dict bodies => one entry per time, days collected.
|
|
78
|
+
def self.calendar_entries(dicts)
|
|
79
|
+
dicts.map { |body| parse_dict(body) }
|
|
80
|
+
.group_by { |dict| dict.values_at("Hour", "Minute") }
|
|
81
|
+
.map { |(hour, minute), group| calendar_entry(hour, minute, group) }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# <key>Hour</key><integer>13</integer>... => { "Hour" => 13, ... }
|
|
85
|
+
def self.parse_dict(body) = body.scan(%r{<key>(\w+)</key>\s*<integer>(\d+)</integer>}).to_h { |key, num| [key, num.to_i] }
|
|
86
|
+
|
|
87
|
+
# group: the Weekday-bearing dicts for one time; days is nil when there are none (every day).
|
|
88
|
+
def self.calendar_entry(hour, minute, group)
|
|
89
|
+
days = group.filter_map { |dict| dict["Weekday"] }
|
|
90
|
+
{ hour:, minute:, days: days.empty? ? nil : days }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# The text inside the <array> that follows <key>key</key>.
|
|
94
|
+
def self.array_body(xml, key) = xml[%r{<key>#{key}</key>\s*<array>(.*?)</array>}m, 1].to_s
|
|
95
|
+
|
|
96
|
+
# The WorkingDirectory (the project root) of a plist, or nil.
|
|
97
|
+
def self.parse_directory(xml) = xml[%r{<key>WorkingDirectory</key>\s*<string>(.*?)</string>}m, 1]&.then { |text| unescape(text) }
|
|
98
|
+
|
|
99
|
+
def self.unescape(text) = text.gsub("<", "<").gsub(">", ">").gsub("&", "&")
|
|
100
|
+
|
|
67
101
|
def self.escape(text) = text.to_s.gsub("&", "&").gsub("<", "<").gsub(">", ">")
|
|
68
102
|
|
|
69
103
|
# ---- backend API ------------------------------------------------------
|
|
@@ -136,6 +170,27 @@ module Asgard
|
|
|
136
170
|
Dir.glob(plist_path("*")).map { |path| File.basename(path, ".plist").delete_prefix(prefix) }.sort
|
|
137
171
|
end
|
|
138
172
|
|
|
173
|
+
# Every asgard entry on this machine, whatever its project: [[project_slug, name], ...].
|
|
174
|
+
def installed_entries
|
|
175
|
+
paths = Dir.glob(File.join(@home, "Library", "LaunchAgents", "#{LABEL_PREFIX}.*.plist"))
|
|
176
|
+
paths.map { |path| File.basename(path, ".plist").delete_prefix("#{LABEL_PREFIX}.").split(".", 2) }
|
|
177
|
+
.select { |entry| entry.size == 2 }.sort
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# { command:, schedule: } as installed, read from the plist; nil when there isn't one.
|
|
181
|
+
def installed_columns(name)
|
|
182
|
+
path = plist_path(name)
|
|
183
|
+
return unless File.exist?(path)
|
|
184
|
+
|
|
185
|
+
Schedule.installed_columns(**self.class.parse_plist(File.read(path)))
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# The project root the job runs in, read from the plist; nil when there isn't one.
|
|
189
|
+
def installed_directory(name)
|
|
190
|
+
path = plist_path(name)
|
|
191
|
+
self.class.parse_directory(File.read(path)) if File.exist?(path)
|
|
192
|
+
end
|
|
193
|
+
|
|
139
194
|
def status(name)
|
|
140
195
|
out, ok = run("launchctl", "print", target(name))
|
|
141
196
|
return { state: stopped?(name) ? :stopped : :not_loaded, last_exit: nil } unless ok
|
|
@@ -76,6 +76,30 @@ module Asgard
|
|
|
76
76
|
UNIT
|
|
77
77
|
end
|
|
78
78
|
|
|
79
|
+
# ExecStart= words, undoing `quote`.
|
|
80
|
+
def self.parse_exec_start(service)
|
|
81
|
+
line = service[/^ExecStart=(.*)$/, 1].to_s
|
|
82
|
+
line.scan(/"((?:[^"\\]|\\.)*)"/).flatten.map { |word| word.gsub(/\\(.)/, '\1').gsub("$$", "$").gsub("%%", "%") }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# "Mon,Fri *-*-* 17:30:00" => { hour: 17, minute: 30, days: [1, 5] }
|
|
86
|
+
def self.parse_on_calendar(text)
|
|
87
|
+
days = text[/\A([A-Z][a-z]{2}(?:,[A-Z][a-z]{2})*) /, 1]
|
|
88
|
+
hour, minute = text.scan(/(\d+):(\d+)/).first.map(&:to_i)
|
|
89
|
+
{ hour:, minute:, days: days&.split(",")&.map { |abbr| DAY_ABBREVIATIONS.index(abbr) } }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# The WorkingDirectory= (the project root) of a service unit, or nil.
|
|
93
|
+
def self.parse_directory(service) = service[/^WorkingDirectory=(.*)$/, 1]&.gsub("%%", "%")
|
|
94
|
+
|
|
95
|
+
# { calendar: [entry], every: Integer|nil } from a timer unit.
|
|
96
|
+
def self.parse_timer(timer)
|
|
97
|
+
{
|
|
98
|
+
calendar: timer.scan(/^OnCalendar=(.*)$/).flatten.map { |text| parse_on_calendar(text) },
|
|
99
|
+
every: timer[/^OnUnitActiveSec=(\d+)/, 1]&.to_i
|
|
100
|
+
}
|
|
101
|
+
end
|
|
102
|
+
|
|
79
103
|
# `systemctl show -p A -p B` output => { "A" => "...", "B" => "..." }
|
|
80
104
|
def self.parse_show(output) = output.lines.to_h { |line| line.chomp.split("=", 2) }.reject { |k, _| k.to_s.empty? }
|
|
81
105
|
|
|
@@ -156,6 +180,29 @@ module Asgard
|
|
|
156
180
|
Dir.glob(timer_path("*")).map { |path| File.basename(path, ".timer").delete_prefix(prefix) }.sort
|
|
157
181
|
end
|
|
158
182
|
|
|
183
|
+
# Every asgard entry on this machine, whatever its project: [[project_slug, name], ...].
|
|
184
|
+
def installed_entries
|
|
185
|
+
paths = Dir.glob(File.join(@config, "systemd", "user", "#{UNIT_PREFIX}.*.timer"))
|
|
186
|
+
paths.map { |path| File.basename(path, ".timer").delete_prefix("#{UNIT_PREFIX}.").split(".", 2) }
|
|
187
|
+
.select { |entry| entry.size == 2 }.sort
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# { command:, schedule: } as installed, read from the unit files; nil when there are none.
|
|
191
|
+
def installed_columns(name)
|
|
192
|
+
paths = [service_path(name), timer_path(name)]
|
|
193
|
+
return unless paths.all? { |path| File.exist?(path) }
|
|
194
|
+
|
|
195
|
+
klass = self.class
|
|
196
|
+
service, timer = paths.map { |path| File.read(path) }
|
|
197
|
+
Schedule.installed_columns(arguments: klass.parse_exec_start(service), **klass.parse_timer(timer))
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# The project root the job runs in, read from the service unit; nil when there isn't one.
|
|
201
|
+
def installed_directory(name)
|
|
202
|
+
path = service_path(name)
|
|
203
|
+
self.class.parse_directory(File.read(path)) if File.exist?(path)
|
|
204
|
+
end
|
|
205
|
+
|
|
159
206
|
def status(name)
|
|
160
207
|
_, active = systemctl("is-active", "--quiet", timer(name))
|
|
161
208
|
state = if active then :active
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Asgard
|
|
4
|
+
module Schedule
|
|
5
|
+
# The table `asgard schedule list` prints, drawn by tty-table. rows are
|
|
6
|
+
# hashes with :name, :command, :schedule, :state and :last_exit (strings).
|
|
7
|
+
# A cell that doesn't fit wraps onto extra lines; only COMMAND and SCHEDULE
|
|
8
|
+
# give up width, so the short columns always stay whole.
|
|
9
|
+
class Table
|
|
10
|
+
HEADERS = ["NAME", "COMMAND", "SCHEDULE", "STATE", "LAST EXIT"].freeze
|
|
11
|
+
FLEXIBLE = [1, 2].freeze
|
|
12
|
+
MIN_WIDTH = 14
|
|
13
|
+
EXTRA = 4 # a space either side of a cell, plus slack for tty-table's rounding
|
|
14
|
+
BORDERS = HEADERS.size + 1 # one before each column and one after the last
|
|
15
|
+
|
|
16
|
+
# Columns available for output: the terminal's when io is one, else
|
|
17
|
+
# $COLUMNS, else a roomy default (piped output shouldn't wrap at 80).
|
|
18
|
+
def self.terminal_width(io = $stdout, env: ENV, default: 120)
|
|
19
|
+
return Integer(env["COLUMNS"], exception: false) || default unless io.tty?
|
|
20
|
+
|
|
21
|
+
require "tty-screen"
|
|
22
|
+
TTY::Screen.width
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# rows rendered for the terminal, under title when given: colored when io
|
|
26
|
+
# is a tty and NO_COLOR is unset. headers: relabels the five columns.
|
|
27
|
+
def self.draw(rows, title: nil, io: $stdout, headers: HEADERS)
|
|
28
|
+
table = new(rows, color: io.tty? && !ENV.key?("NO_COLOR"), width: terminal_width(io), headers:).render
|
|
29
|
+
[title, table].compact.join("\n")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# color: styles the header, state and last exit with ANSI codes.
|
|
33
|
+
def initialize(rows, color:, width:, headers: HEADERS)
|
|
34
|
+
require "pastel"
|
|
35
|
+
|
|
36
|
+
@headers = headers
|
|
37
|
+
@cells = rows.map { |row| row.values_at(:name, :command, :schedule, :state, :last_exit) }
|
|
38
|
+
@pastel = Pastel.new(enabled: color)
|
|
39
|
+
@width = width
|
|
40
|
+
@column_widths = natural_widths
|
|
41
|
+
shrink_widest while overflowing? && shrinkable?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def render
|
|
45
|
+
require "tty-table"
|
|
46
|
+
|
|
47
|
+
grid = TTY::Table.new(header: @headers.map { |text| @pastel.bold(text) }, rows: @cells.map { |cells| styled(cells) })
|
|
48
|
+
grid.render(:unicode, multiline: true, resize: true, width: total_width, column_widths:, padding: [0, 1]).to_s
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Width of each column, padding included. Every column starts at its
|
|
52
|
+
# natural width; when the table would be wider than the screen, the
|
|
53
|
+
# flexible columns give up a character at a time until it fits.
|
|
54
|
+
attr_reader :column_widths
|
|
55
|
+
|
|
56
|
+
# Rendered width: the columns plus their borders.
|
|
57
|
+
def total_width = column_widths.sum + BORDERS
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def natural_widths = [@headers, *@cells].transpose.map { |column| column.max_by(&:length).length + EXTRA }
|
|
62
|
+
|
|
63
|
+
def overflowing? = total_width > @width
|
|
64
|
+
|
|
65
|
+
def widest_flexible = FLEXIBLE.max_by { |index| @column_widths[index] }
|
|
66
|
+
|
|
67
|
+
def shrinkable? = @column_widths[widest_flexible] > MIN_WIDTH
|
|
68
|
+
|
|
69
|
+
def shrink_widest = @column_widths[widest_flexible] -= 1
|
|
70
|
+
|
|
71
|
+
def styled(cells)
|
|
72
|
+
*lead, state, last_exit = cells
|
|
73
|
+
state_color = { "active" => :green, "running" => :green, "stopped" => :yellow }.fetch(state, :red)
|
|
74
|
+
exit_color = last_exit.match?(/\A[1-9]\d*\b/) ? :red : :dim
|
|
75
|
+
[*lead, @pastel.decorate(state, state_color), @pastel.decorate(last_exit, exit_color)]
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
data/lib/asgard/schedule.rb
CHANGED
data/lib/asgard/tasks.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "schedule"
|
|
4
|
+
require_relative "watch"
|
|
4
5
|
|
|
5
6
|
# Tasks is the single conventional entry point for all .loki files.
|
|
6
7
|
# It is pre-defined by the gem so .loki files never need to declare a class.
|
|
@@ -38,10 +39,18 @@ class Tasks < Asgard::Base
|
|
|
38
39
|
# Class-level `schedule :task, at: "HH:MM"` declarations (see Asgard::Schedule).
|
|
39
40
|
extend Asgard::Schedule::DSL
|
|
40
41
|
|
|
42
|
+
# Class-level `watch :task, paths: "lib"` declarations (see Asgard::Watch).
|
|
43
|
+
extend Asgard::Watch::DSL
|
|
44
|
+
|
|
41
45
|
# Gem-owned, so the command is _schedule; the map keeps `asgard schedule`
|
|
42
46
|
# as the name users type, and ancestor_name keeps it in subcommand help.
|
|
43
47
|
desc "schedule SUBCOMMAND", "Manage schedules declared with `schedule :task, ...` (launchd on macOS, systemd on Linux)"
|
|
44
48
|
subcommand "_schedule", Asgard::Schedule::Commands
|
|
45
49
|
map "schedule" => :_schedule
|
|
46
50
|
Asgard::Schedule::Commands.commands.each_value { |command| command.ancestor_name = "schedule" }
|
|
51
|
+
|
|
52
|
+
desc "watcher SUBCOMMAND", "Run and manage the file watcher declared with `watch :task, paths: ...`"
|
|
53
|
+
subcommand "_watcher", Asgard::Watch::Commands
|
|
54
|
+
map "watcher" => :_watcher
|
|
55
|
+
Asgard::Watch::Commands.commands.each_value { |command| command.ancestor_name = "watcher" }
|
|
47
56
|
end
|