asgard 0.4.1 → 0.5.1
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 +54 -0
- data/CLAUDE.md +10 -1
- data/README.md +37 -0
- data/docs/api.md +21 -0
- data/docs/examples.md +54 -0
- data/docs/index.md +1 -0
- data/docs/watch.md +123 -0
- data/examples/.envrc +3 -0
- data/examples/confirm.loki +48 -0
- data/examples/do.loki +45 -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/base.rb +4 -0
- data/lib/asgard/kernel_methods.rb +6 -0
- data/lib/asgard/schedule/table.rb +9 -8
- 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 +3 -3
- data/mkdocs.yml +1 -0
- metadata +52 -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"] }
|
data/lib/asgard/base.rb
CHANGED
|
@@ -18,6 +18,10 @@ module Asgard
|
|
|
18
18
|
include Dispatch
|
|
19
19
|
|
|
20
20
|
class << self
|
|
21
|
+
# A usage error (missing argument, unknown command or option) exits 1,
|
|
22
|
+
# so CI and `asgard do` sequences see the failure. Thor's default is 0.
|
|
23
|
+
def exit_on_failure? = true
|
|
24
|
+
|
|
21
25
|
def header(text = nil)
|
|
22
26
|
return @_header if text.nil?
|
|
23
27
|
(@_header ||= []) << text
|
|
@@ -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
|
|
@@ -23,16 +23,17 @@ module Asgard
|
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
# rows rendered for the terminal, under title when given: colored when io
|
|
26
|
-
# is a tty and NO_COLOR is unset.
|
|
27
|
-
def self.draw(rows, title: nil, io: $stdout)
|
|
28
|
-
table = new(rows, color: io.tty? && !ENV.key?("NO_COLOR"), width: terminal_width(io)).render
|
|
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
29
|
[title, table].compact.join("\n")
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
# color: styles the header, state and last exit with ANSI codes.
|
|
33
|
-
def initialize(rows, color:, width:)
|
|
33
|
+
def initialize(rows, color:, width:, headers: HEADERS)
|
|
34
34
|
require "pastel"
|
|
35
35
|
|
|
36
|
+
@headers = headers
|
|
36
37
|
@cells = rows.map { |row| row.values_at(:name, :command, :schedule, :state, :last_exit) }
|
|
37
38
|
@pastel = Pastel.new(enabled: color)
|
|
38
39
|
@width = width
|
|
@@ -43,7 +44,7 @@ module Asgard
|
|
|
43
44
|
def render
|
|
44
45
|
require "tty-table"
|
|
45
46
|
|
|
46
|
-
grid = TTY::Table.new(header:
|
|
47
|
+
grid = TTY::Table.new(header: @headers.map { |text| @pastel.bold(text) }, rows: @cells.map { |cells| styled(cells) })
|
|
47
48
|
grid.render(:unicode, multiline: true, resize: true, width: total_width, column_widths:, padding: [0, 1]).to_s
|
|
48
49
|
end
|
|
49
50
|
|
|
@@ -57,7 +58,7 @@ module Asgard
|
|
|
57
58
|
|
|
58
59
|
private
|
|
59
60
|
|
|
60
|
-
def natural_widths = [
|
|
61
|
+
def natural_widths = [@headers, *@cells].transpose.map { |column| column.max_by(&:length).length + EXTRA }
|
|
61
62
|
|
|
62
63
|
def overflowing? = total_width > @width
|
|
63
64
|
|
|
@@ -69,8 +70,8 @@ module Asgard
|
|
|
69
70
|
|
|
70
71
|
def styled(cells)
|
|
71
72
|
*lead, state, last_exit = cells
|
|
72
|
-
state_color = { "active" => :green, "stopped" => :yellow }.fetch(state, :red)
|
|
73
|
-
exit_color = last_exit.match?(/\A[1-9]\d*\
|
|
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
|
|
74
75
|
[*lead, @pastel.decorate(state, state_color), @pastel.decorate(last_exit, exit_color)]
|
|
75
76
|
end
|
|
76
77
|
end
|
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
|
data/lib/asgard/version.rb
CHANGED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Asgard
|
|
4
|
+
# The files a watcher trigger saw change, as three lists of paths relative
|
|
5
|
+
# to the project root. The watcher hands them to the task through three
|
|
6
|
+
# environment variables (newline-separated, since paths may hold spaces
|
|
7
|
+
# or colons); the `changes` Kernel method reads them back. Every list is
|
|
8
|
+
# empty when the task is run by hand, so one task body serves both.
|
|
9
|
+
class Changes < Data.define(:modified, :added, :removed)
|
|
10
|
+
VARIABLES = { modified: "ASGARD_MODIFIED", added: "ASGARD_ADDED", removed: "ASGARD_REMOVED" }.freeze
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
# The changes behind this process, read once from ENV. Tests and a
|
|
14
|
+
# watcher-run task that re-execs call forget to read them again.
|
|
15
|
+
def current = @current ||= from_env
|
|
16
|
+
|
|
17
|
+
def forget = @current = nil
|
|
18
|
+
|
|
19
|
+
# Parses the trigger variables; a missing one is an empty list.
|
|
20
|
+
def from_env(env = ENV) = new(**VARIABLES.to_h { |member, variable| [member, env[variable].to_s.split("\n")] })
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(modified: [], added: [], removed: [])
|
|
24
|
+
super(modified: modified.map(&:to_s).freeze, added: added.map(&:to_s).freeze, removed: removed.map(&:to_s).freeze)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Every changed path once, in modified, added, removed order.
|
|
28
|
+
def all = (modified + added + removed).uniq
|
|
29
|
+
|
|
30
|
+
def empty? = modified.empty? && added.empty? && removed.empty?
|
|
31
|
+
|
|
32
|
+
# The trigger variables that reproduce this object through from_env.
|
|
33
|
+
def to_env = VARIABLES.to_h { |member, variable| [variable, public_send(member).join("\n")] }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
module Watch
|
|
37
|
+
# Which Changes member each events: kind fills. Listen reports the first
|
|
38
|
+
# kind as "modified".
|
|
39
|
+
EVENT_MEMBERS = { changed: :modified, added: :added, removed: :removed }.freeze
|
|
40
|
+
|
|
41
|
+
module_function
|
|
42
|
+
|
|
43
|
+
# A listener batch ({ modified:, added:, removed: }) narrowed to the
|
|
44
|
+
# kinds in events (a Set from normalize_events), as a Changes; nil
|
|
45
|
+
# when nothing is left, so the dispatcher skips the trigger.
|
|
46
|
+
def filter_events(batch, events)
|
|
47
|
+
kept = EVENT_MEMBERS.to_h { |event, member| [member, events.include?(event) ? Array(batch[member]) : []] }
|
|
48
|
+
changes = Changes.new(**kept)
|
|
49
|
+
changes.empty? ? nil : changes
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "shellwords"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
# warn is silent when $VERBOSE is nil and bypasses $stderr in Ruby 4.0 (see
|
|
8
|
+
# base/task_dsl.rb); these messages are for the user, so they always print.
|
|
9
|
+
# rubocop:disable Style/StderrPuts
|
|
10
|
+
|
|
11
|
+
module Asgard
|
|
12
|
+
module Watch
|
|
13
|
+
# `asgard watcher SUBCOMMAND` — runs and manages the watcher for the
|
|
14
|
+
# entries declared with `watch :task, paths: ...` in a .loki file.
|
|
15
|
+
# Subclasses Asgard::Base rather than Tasks so `asgard watcher help`
|
|
16
|
+
# lists only these subcommands, not every project task.
|
|
17
|
+
class Commands < Asgard::Base
|
|
18
|
+
namespace "watcher"
|
|
19
|
+
|
|
20
|
+
HEADERS = ["NAME", "TASK", "WATCHES", "DAEMON", "LAST RUN"].freeze
|
|
21
|
+
STOP_WAIT = 1.0
|
|
22
|
+
|
|
23
|
+
class << self
|
|
24
|
+
# The WATCHES column: the declared paths and, unless every kind of
|
|
25
|
+
# change counts, the kinds that do.
|
|
26
|
+
def describe(spec)
|
|
27
|
+
events = spec[:events].to_a.map(&:to_s)
|
|
28
|
+
kinds = events.size == EVENTS.size ? "" : " [#{events.join(', ')}]"
|
|
29
|
+
"#{spec[:paths].join(' ')}#{kinds}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The LAST RUN column from an entry's record: never, still running,
|
|
33
|
+
# or its exit status with duration and finish time.
|
|
34
|
+
def last_run(entry)
|
|
35
|
+
return "never" unless entry
|
|
36
|
+
return "running (pid #{entry[:pid]})" unless entry.key?(:finished)
|
|
37
|
+
|
|
38
|
+
finished = Time.iso8601(entry[:finished]).strftime("%Y-%m-%d %H:%M:%S")
|
|
39
|
+
"#{entry[:status]} (#{format('%.1fs', entry[:seconds])}, #{finished})"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
desc "tree", "Print a tree of the watcher subcommands", hide: true
|
|
44
|
+
# Redefined only to re-register tree as hidden in this namespace.
|
|
45
|
+
def tree = super # rubocop:disable Lint/UselessMethodDefinition
|
|
46
|
+
|
|
47
|
+
no_commands do
|
|
48
|
+
def watches = Watch.declarations
|
|
49
|
+
|
|
50
|
+
def watch_root = @watch_root ||= File.realpath((Asgard.find_task_file&.dirname || Pathname.pwd).to_s)
|
|
51
|
+
|
|
52
|
+
def watch_project = File.basename(watch_root)
|
|
53
|
+
|
|
54
|
+
def state = @state ||= State.new(project: watch_project, root: watch_root)
|
|
55
|
+
|
|
56
|
+
def declared_watches
|
|
57
|
+
abort "No watches declared. Add `watch :task, paths: \"lib\"` inside class Tasks in #{watch_root}/.loki." if watches.empty?
|
|
58
|
+
|
|
59
|
+
unknown = Daemon.unknown_tasks(watches, Tasks.all_commands.keys)
|
|
60
|
+
abort "watcher: no such task(s): #{unknown.join(', ')}" unless unknown.empty?
|
|
61
|
+
|
|
62
|
+
watches
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def watch_direnv
|
|
66
|
+
return unless File.exist?(File.join(watch_root, ".envrc"))
|
|
67
|
+
|
|
68
|
+
Schedule.which("direnv", ENV.fetch("PATH", nil)) or
|
|
69
|
+
$stderr.puts "watcher: .envrc found but direnv is not on PATH; its variables will not be loaded"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def watch_asgard = Spawn.executable || abort("watcher: asgard is not on PATH")
|
|
73
|
+
|
|
74
|
+
def spawn_context
|
|
75
|
+
Spawn::Context.new(root: watch_root, log: state.log_path, base: Spawn.base_env, asgard: watch_asgard, direnv: watch_direnv)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def refuse_if_running
|
|
79
|
+
pid = state.running_pid
|
|
80
|
+
abort "watcher is already running for #{watch_project} (pid #{pid}); `asgard watcher stop` first." if pid
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# `start` records the child's pid before the child reaches here, so a
|
|
84
|
+
# pidfile naming this very process is ours, not another daemon's.
|
|
85
|
+
def refuse_if_another_running
|
|
86
|
+
pid = state.running_pid
|
|
87
|
+
abort "watcher is already running for #{watch_project} (pid #{pid}); `asgard watcher stop` first." if pid && pid != Process.pid
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Thor reserves `run` as a method name, so the command is `daemon`
|
|
92
|
+
# with `run` as its usage and mapped name, as --version is _version.
|
|
93
|
+
desc "run", "Watch in the foreground until INT or TERM (what `start` runs detached)"
|
|
94
|
+
map "run" => :daemon
|
|
95
|
+
def daemon
|
|
96
|
+
specs = declared_watches
|
|
97
|
+
refuse_if_another_running
|
|
98
|
+
config = Daemon::Config.new(declarations: specs, root: watch_root, state:, context: spawn_context, tasks: Tasks.all_commands.keys)
|
|
99
|
+
Daemon.new(config).run
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
desc "start", "Start the watcher for this project in the background"
|
|
103
|
+
def start
|
|
104
|
+
specs = declared_watches
|
|
105
|
+
refuse_if_running
|
|
106
|
+
log = state.log_path
|
|
107
|
+
state.prepare.write_declarations(specs.values)
|
|
108
|
+
pid = Process.spawn(watch_asgard, "watcher", "run", chdir: watch_root, pgroup: true, out: [log, "a"], err: %i[child out])
|
|
109
|
+
Process.detach(pid)
|
|
110
|
+
state.write_pid(pid)
|
|
111
|
+
puts "started watcher for #{watch_project} (pid #{pid}); log: #{log}"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
desc "stop", "Stop the watcher; a task already running finishes first"
|
|
115
|
+
def stop
|
|
116
|
+
pid = state.running_pid or abort("watcher is not running for #{watch_project}.")
|
|
117
|
+
Process.kill("TERM", pid)
|
|
118
|
+
while Reporter.alive?(pid)
|
|
119
|
+
running = state.running_entries.map { |entry| "#{entry[:entry]} (pid #{entry[:pid]})" }
|
|
120
|
+
puts "watcher: waiting for #{running.join(', ')}" unless running.empty?
|
|
121
|
+
sleep STOP_WAIT
|
|
122
|
+
end
|
|
123
|
+
state.clear
|
|
124
|
+
puts "stopped watcher for #{watch_project}"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
no_commands do
|
|
128
|
+
# True when list should cover every project: asked for with --all, or
|
|
129
|
+
# there is no .loki here, so no single project to scope to.
|
|
130
|
+
def list_all? = options[:all] || Asgard.find_task_file.nil?
|
|
131
|
+
|
|
132
|
+
def no_watches_message
|
|
133
|
+
return "No watchers have been started on this machine." if list_all?
|
|
134
|
+
|
|
135
|
+
"No watches declared for #{watch_project}. Add `watch :task, paths: \"lib\"` inside class Tasks in #{watch_root}/.loki.\n" \
|
|
136
|
+
"Use `asgard watcher list --all` to see every project's watches."
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# { title => rows } for every project, or { nil => rows } for this one; empty groups drop out.
|
|
140
|
+
def list_groups
|
|
141
|
+
groups = list_all? ? all_groups : { nil => Commands.project_rows(state, declared_records) }
|
|
142
|
+
groups.reject { |_, rows| rows.empty? }
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def declared_records = watches.values.map { |spec| State.declaration_record(spec) }
|
|
146
|
+
|
|
147
|
+
# Every project with watcher state, titled "Project: slug (/root)". This
|
|
148
|
+
# project's rows come from its live declarations; the others' from what
|
|
149
|
+
# `start` recorded, since their .loki files are not loaded.
|
|
150
|
+
def all_groups
|
|
151
|
+
State.all.to_h do |slug, root|
|
|
152
|
+
other = State.new(project: slug, root:)
|
|
153
|
+
own = Asgard.find_task_file && root == watch_root
|
|
154
|
+
["Project: #{slug} (#{root})", Commands.project_rows(other, own ? declared_records : other.read_declarations)]
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
class << self
|
|
160
|
+
# One `list` row per declaration record, with the project's daemon
|
|
161
|
+
# state and each entry's last run.
|
|
162
|
+
def project_rows(project_state, records)
|
|
163
|
+
daemon = project_state.running_pid ? "running" : "stopped"
|
|
164
|
+
entries = project_state.entries
|
|
165
|
+
records.map { |record| watch_row(record, daemon, entries[record[:name]]) }
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def watch_row(record, daemon, entry)
|
|
169
|
+
{ name: record[:name], command: "asgard #{record[:task]}", schedule: describe(record),
|
|
170
|
+
state: daemon, last_exit: last_run(entry) }
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
desc "list", "Show this project's watches, whether the watcher is running, and each entry's last run (--all: every project's)"
|
|
175
|
+
method_option :all, aliases: "-a", type: :boolean, desc: "List every project's watches, not just this one's"
|
|
176
|
+
def list
|
|
177
|
+
groups = list_groups
|
|
178
|
+
return puts(no_watches_message) if groups.empty?
|
|
179
|
+
|
|
180
|
+
puts groups.map { |title, rows| Schedule::Table.draw(rows, title:, headers: HEADERS) }.join("\n\n")
|
|
181
|
+
puts "\nLogs: #{State.base_dir} (asgard watcher log)"
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
desc "preview", "Show the command and environment each watch would run, without running anything"
|
|
185
|
+
def preview
|
|
186
|
+
context = spawn_context
|
|
187
|
+
declared_watches.each_value { |spec| print_preview(spec, context) }
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
no_commands do
|
|
191
|
+
def print_preview(spec, context)
|
|
192
|
+
description = Spawn.description(spec, Changes.new, context)
|
|
193
|
+
puts "# #{spec[:name]}: #{Shellwords.join(description[:argv])} (#{Commands.describe(spec)})"
|
|
194
|
+
description[:env].sort.each { |key, value| puts "#{key}=#{value}" }
|
|
195
|
+
puts
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
desc "log", "Print this project's watcher log to STDOUT"
|
|
200
|
+
method_option :follow, aliases: "-f", type: :boolean, desc: "Keep printing new output as it arrives (tail -f)"
|
|
201
|
+
def log
|
|
202
|
+
path = state.log_path
|
|
203
|
+
return $stderr.puts "#{watch_project} has no watcher log yet (#{path}); it is created on the first start." unless File.exist?(path)
|
|
204
|
+
return sh("tail -n +1 -f #{path.shellescape}", silent: true, exec: true) if options[:follow]
|
|
205
|
+
|
|
206
|
+
IO.copy_stream(path, $stdout)
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
# rubocop:enable Style/StderrPuts
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Asgard
|
|
4
|
+
module Watch
|
|
5
|
+
# `asgard watcher run`: the whole watcher in one foreground process.
|
|
6
|
+
# One Listen listener per declaration pushes batches onto the inbox;
|
|
7
|
+
# the dispatcher routes them to worker Ractors; the reporter writes the
|
|
8
|
+
# log and the entry records. A change to any .loki file re-execs the
|
|
9
|
+
# process so declarations are never stale. INT or TERM stops listening,
|
|
10
|
+
# drops triggers that have not started, waits for running tasks to
|
|
11
|
+
# exit, removes the pidfile and returns.
|
|
12
|
+
class Daemon
|
|
13
|
+
# What a run is made of: the normalized declarations, the real
|
|
14
|
+
# project root, the State, the Spawn::Context, and the names of the
|
|
15
|
+
# tasks the project defines.
|
|
16
|
+
Config = Data.define(:declarations, :root, :state, :context, :tasks)
|
|
17
|
+
|
|
18
|
+
LOKI_DEBOUNCE = 0.5
|
|
19
|
+
|
|
20
|
+
attr_reader :inbox, :listeners
|
|
21
|
+
|
|
22
|
+
# listen: is the Listen module (loaded only when nothing else is
|
|
23
|
+
# given) or a stand-in; exec: replaces the process on reload; out:
|
|
24
|
+
# is where shutdown progress goes.
|
|
25
|
+
def initialize(config, listen: Daemon.listen_module, exec: Kernel.method(:exec), out: $stdout)
|
|
26
|
+
@config = config
|
|
27
|
+
@listen = listen
|
|
28
|
+
@exec = exec
|
|
29
|
+
@out = out
|
|
30
|
+
@inbox = Queue.new
|
|
31
|
+
@listeners = []
|
|
32
|
+
@stopping = false
|
|
33
|
+
@reporter = nil
|
|
34
|
+
@dispatcher = nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
class << self
|
|
38
|
+
# Listen, loaded only when a daemon is actually built.
|
|
39
|
+
def listen_module
|
|
40
|
+
require "listen"
|
|
41
|
+
Listen
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The Listen options for one declaration.
|
|
45
|
+
def listen_options(spec)
|
|
46
|
+
{ only: spec[:only], ignore: spec[:ignore], wait_for_delay: spec[:debounce], relative: true }.compact
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The tasks declared by watch but not defined by the project.
|
|
50
|
+
def unknown_tasks(declarations, tasks) = declarations.values.map { |spec| spec[:task] }.uniq - tasks
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def run
|
|
54
|
+
check_tasks!
|
|
55
|
+
Dir.chdir(@config.root)
|
|
56
|
+
state = @config.state.prepare
|
|
57
|
+
start_pipeline(state)
|
|
58
|
+
@listeners = build_listeners + [loki_listener]
|
|
59
|
+
@listeners.each(&:start)
|
|
60
|
+
state.write_pid(Process.pid)
|
|
61
|
+
trap_signals
|
|
62
|
+
sleep 0.2 until @stopping
|
|
63
|
+
shutdown(state)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Asks run to stop; the trap handler calls this.
|
|
67
|
+
def stop = @stopping = true
|
|
68
|
+
|
|
69
|
+
# Stops listening and replaces the process with a fresh `watcher run`
|
|
70
|
+
# so new declarations take effect. Running tasks, in their own
|
|
71
|
+
# process groups, carry on; the new daemon does not know about them.
|
|
72
|
+
def reload
|
|
73
|
+
@listeners.each(&:stop)
|
|
74
|
+
@exec.call(@config.context.asgard, "watcher", "run")
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def check_tasks!
|
|
80
|
+
unknown = Daemon.unknown_tasks(@config.declarations, @config.tasks)
|
|
81
|
+
raise Error, "watch declares tasks this project does not define: #{unknown.join(', ')}" unless unknown.empty?
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def start_pipeline(state)
|
|
85
|
+
reports = RactorQueue.new(capacity: 1024)
|
|
86
|
+
@reporter = Reporter.new(reports, log: state.log_path, state:)
|
|
87
|
+
@dispatcher = Dispatcher.new(@config.declarations, @config.context, reports:)
|
|
88
|
+
@reporter.start
|
|
89
|
+
@dispatcher.start(@inbox)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def build_listeners = @config.declarations.values.map { |spec| listener_for(spec) }
|
|
93
|
+
|
|
94
|
+
# One Listen listener whose callback pushes [entry name, batch] onto the inbox.
|
|
95
|
+
def listener_for(spec)
|
|
96
|
+
name = spec[:name]
|
|
97
|
+
@listen.to(*spec[:dirs], **Daemon.listen_options(spec)) do |modified, added, removed|
|
|
98
|
+
@inbox.push([name, { modified:, added:, removed: }])
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def loki_listener
|
|
103
|
+
@listen.to(@config.root, only: /\.loki\z/, ignore: DEFAULT_IGNORES, wait_for_delay: LOKI_DEBOUNCE, relative: true) { reload }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def trap_signals
|
|
107
|
+
%w[INT TERM].each { |signal| trap(signal) { stop } }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def shutdown(state)
|
|
111
|
+
@listeners.each(&:stop)
|
|
112
|
+
@inbox.push(nil)
|
|
113
|
+
@dispatcher.workers.each_value do |worker|
|
|
114
|
+
queue = worker[:queue]
|
|
115
|
+
queue.try_pop until queue.empty?
|
|
116
|
+
end
|
|
117
|
+
@reporter.drain(out: @out)
|
|
118
|
+
state.clear
|
|
119
|
+
@out.puts "watcher: stopped"
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|