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.
@@ -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,3 @@
1
+ #!/bin/sh
2
+ # Edit this file while the watcher runs: the lint-bin entry picks it up.
3
+ echo "hello from bin/"
@@ -0,0 +1 @@
1
+ { "records": ["one", "two"] }
@@ -0,0 +1,5 @@
1
+ # Drafts are ignored
2
+
3
+ Editing this file never triggers the docs entry: its declaration has
4
+ `ignore: %r{\Adrafts/}`.
5
+ more
@@ -0,0 +1,4 @@
1
+ # Watcher example
2
+
3
+ Edit this file while the watcher runs: the docs entry picks it up.
4
+ more
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Edit this file while the watcher runs: the lint entry picks it up.
4
+ module Greeter
5
+ def self.greet(name) = "Hello, #{name}!"
6
+ end
7
+ # a change
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: HEADERS.map { |text| @pastel.bold(text) }, rows: @cells.map { |cells| styled(cells) })
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 = [HEADERS, *@cells].transpose.map { |column| column.max_by(&:length).length + EXTRA }
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*\z/) ? :red : :dim
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Asgard
4
- VERSION = "0.4.1"
4
+ VERSION = "0.5.1"
5
5
  end
@@ -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