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
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
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Asgard
|
|
4
|
+
# The platform-neutral half of the watcher: validates `watch` declarations
|
|
5
|
+
# and turns their paths into what Listen needs (directories to watch plus
|
|
6
|
+
# only:/ignore: filters). Everything here is pure, so each method can be
|
|
7
|
+
# tested on its own.
|
|
8
|
+
module Watch
|
|
9
|
+
# The kinds of change a declaration can react to. Listen calls the first
|
|
10
|
+
# one "modified"; that name is accepted as an alias.
|
|
11
|
+
EVENTS = %i[changed added removed].freeze
|
|
12
|
+
EVENT_ALIASES = { modified: :changed }.freeze
|
|
13
|
+
|
|
14
|
+
# Output and scratch directories a task may write inside the project;
|
|
15
|
+
# always ignored so a task never retriggers itself. Matched against the
|
|
16
|
+
# path relative to the watched directory, at any depth.
|
|
17
|
+
DEFAULT_IGNORES = %w[.git coverage pkg tmp log node_modules].map { |dir| %r{(?:\A|/)#{Regexp.escape(dir)}/} }.freeze
|
|
18
|
+
|
|
19
|
+
GLOB_CHARS = /[*?{]/
|
|
20
|
+
GLOB_TOKENS = %r!\*\*/|\*\*|\*|\?|\{[^}]*\}|\{|[^*?{]+!
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
# Validates a declaration and returns it as a plain Hash. paths: entries
|
|
25
|
+
# are directories, files or globs relative to the project root; the
|
|
26
|
+
# Hash carries the directories Listen watches and one only: Regexp (or
|
|
27
|
+
# nil) that stands for every pattern. events: picks which kinds of
|
|
28
|
+
# change trigger the task; as: names the entry. Every ArgumentError
|
|
29
|
+
# from the helpers below comes back prefixed with the task.
|
|
30
|
+
def normalize(task, paths:, only: nil, ignore: nil, events: :all, debounce: 0.5, env: {}, as: nil)
|
|
31
|
+
task = task.to_s
|
|
32
|
+
raise ArgumentError, "task name #{task.inspect} must be a single word" unless task.match?(/\A[\w:-]+\z/)
|
|
33
|
+
|
|
34
|
+
paths = relative_paths(paths)
|
|
35
|
+
dirs, matched = watched(paths)
|
|
36
|
+
{
|
|
37
|
+
name: entry_name(as || task),
|
|
38
|
+
task:,
|
|
39
|
+
paths:,
|
|
40
|
+
dirs:,
|
|
41
|
+
only: combine_only(matched, regexps(:only, only)),
|
|
42
|
+
ignore: [*regexps(:ignore, ignore), *DEFAULT_IGNORES],
|
|
43
|
+
events: normalize_events(events),
|
|
44
|
+
debounce: non_negative_debounce(debounce),
|
|
45
|
+
env: env.to_h { |key, value| [key.to_s, value.to_s] }
|
|
46
|
+
}
|
|
47
|
+
rescue ArgumentError => e
|
|
48
|
+
raise ArgumentError, "watch :#{task} #{e.message}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The entry name (as:, or the task), checked for the characters a file
|
|
52
|
+
# or label can carry.
|
|
53
|
+
def entry_name(name)
|
|
54
|
+
name = name.to_s
|
|
55
|
+
raise ArgumentError, "as: #{name.inspect} may only contain letters, digits, _ . -" unless name.match?(/\A[\w.-]+\z/)
|
|
56
|
+
|
|
57
|
+
name
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# paths: as a non-empty Array of relative, non-empty Strings.
|
|
61
|
+
def relative_paths(paths)
|
|
62
|
+
paths = Array(paths).map(&:to_s)
|
|
63
|
+
raise ArgumentError, "needs at least one path" if paths.empty?
|
|
64
|
+
|
|
65
|
+
bad = paths.find { |path| path.empty? || File.absolute_path?(path) }
|
|
66
|
+
raise ArgumentError, "paths are relative to the project root (got #{bad.inspect})" if bad
|
|
67
|
+
|
|
68
|
+
paths
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# [directories, patterns] for a declaration's paths. A bare directory
|
|
72
|
+
# takes everything beneath it, so it cannot share a declaration with a
|
|
73
|
+
# pattern or a file: Listen applies one only: filter to every directory
|
|
74
|
+
# it watches, and the pattern would silently filter the directory too.
|
|
75
|
+
def watched(paths)
|
|
76
|
+
splits = paths.map { |path| split_glob(path) }
|
|
77
|
+
patterns = splits.filter_map(&:last)
|
|
78
|
+
if !patterns.empty? && patterns.size < splits.size
|
|
79
|
+
raise ArgumentError,
|
|
80
|
+
"mixes a bare directory with patterns or files, which would filter the directory too; " \
|
|
81
|
+
"declare them separately or write the directory as dir/**/*"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
[splits.map(&:first).uniq, patterns]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# [directory, Regexp or nil] for one paths: entry.
|
|
88
|
+
# "lib/**/*.rb" => ["lib", /\A(?:.*\/)?[^\/]*\.rb\z/]
|
|
89
|
+
# "lib/asgard.rb" => ["lib", /\Aasgard\.rb\z/]
|
|
90
|
+
# "docs" => ["docs", nil]
|
|
91
|
+
# The Regexp is matched against the path relative to the directory,
|
|
92
|
+
# which is how Listen applies only:. A plain path names a file when
|
|
93
|
+
# stat (File, or a stand-in in tests) finds one there or when it has
|
|
94
|
+
# an extension, so a file that does not exist yet can be watched.
|
|
95
|
+
def split_glob(path, stat: File)
|
|
96
|
+
segments = path.delete_suffix("/").split("/")
|
|
97
|
+
first = segments.index { |segment| segment.match?(GLOB_CHARS) }
|
|
98
|
+
return [directory(segments[0...first]), glob_regexp(segments[first..].join("/"))] if first
|
|
99
|
+
|
|
100
|
+
*parents, last = segments
|
|
101
|
+
last = last.to_s
|
|
102
|
+
return [directory(segments), nil] unless stat.file?(path) || !File.extname(last).empty?
|
|
103
|
+
|
|
104
|
+
[directory(parents), /\A#{Regexp.escape(last)}\z/]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def directory(segments) = segments.empty? ? "." : segments.join("/")
|
|
108
|
+
|
|
109
|
+
# The Regexp a glob matches, shell-style: ** spans directories, * and ?
|
|
110
|
+
# stay within one, {a,b} is an alternative. Anything else is literal.
|
|
111
|
+
def glob_regexp(pattern)
|
|
112
|
+
body = pattern.scan(GLOB_TOKENS).map { |token| glob_token(token) }.join
|
|
113
|
+
/\A#{body}\z/
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def glob_token(token)
|
|
117
|
+
case token
|
|
118
|
+
when "**/" then "(?:.*/)?"
|
|
119
|
+
when "**" then ".*"
|
|
120
|
+
when "*" then "[^/]*"
|
|
121
|
+
when "?" then "[^/]"
|
|
122
|
+
when /\A\{(.*)\}\z/ then "(?:#{Regexp.last_match(1).split(',', -1).map { |alt| Regexp.escape(alt) }.join('|')})"
|
|
123
|
+
else Regexp.escape(token)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# One only: Regexp (or nil for no filter) from the patterns the paths
|
|
128
|
+
# produced and the declared only:. Within each group any may match;
|
|
129
|
+
# when both groups are present a path must satisfy one from each.
|
|
130
|
+
def combine_only(patterns, only)
|
|
131
|
+
any_pattern = patterns.empty? ? nil : Regexp.union(patterns)
|
|
132
|
+
any_only = only.empty? ? nil : Regexp.union(only)
|
|
133
|
+
return any_pattern || any_only unless any_pattern && any_only
|
|
134
|
+
|
|
135
|
+
/(?=#{any_pattern})(?=.*?(?:#{any_only}))/
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# only:/ignore: as an Array of Regexps.
|
|
139
|
+
def regexps(option, value)
|
|
140
|
+
list = Array(value)
|
|
141
|
+
bad = list.find { |item| !item.is_a?(Regexp) }
|
|
142
|
+
raise ArgumentError, "#{option}: expects a Regexp or an Array of them (got #{bad.inspect})" if bad
|
|
143
|
+
|
|
144
|
+
list
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# events: as a frozen Set of EVENTS. :all expands to every kind;
|
|
148
|
+
# :modified means :changed.
|
|
149
|
+
def normalize_events(value)
|
|
150
|
+
list = Array(value).map { |event| event_name(event) }
|
|
151
|
+
unknown = list - EVENTS - [:all]
|
|
152
|
+
raise ArgumentError, "events: unknown #{unknown.first.inspect}; use :changed, :added, :removed or :all" unless unknown.empty?
|
|
153
|
+
|
|
154
|
+
list = EVENTS if list.include?(:all)
|
|
155
|
+
raise ArgumentError, "events: must name at least one of :changed, :added, :removed" if list.empty?
|
|
156
|
+
|
|
157
|
+
Set.new(list).freeze
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def event_name(event)
|
|
161
|
+
name = event.to_sym
|
|
162
|
+
EVENT_ALIASES.fetch(name) { name }
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def non_negative_debounce(debounce)
|
|
166
|
+
unless debounce.is_a?(Numeric) && !debounce.negative?
|
|
167
|
+
raise ArgumentError, "debounce: must be a number of seconds, zero or more (got #{debounce.inspect})"
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
debounce
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ractor_queue"
|
|
4
|
+
|
|
5
|
+
module Asgard
|
|
6
|
+
module Watch
|
|
7
|
+
# Routes listener batches to worker queues. Built once at startup from
|
|
8
|
+
# the declarations: a dispatch table from listener id (the entry name)
|
|
9
|
+
# to its spec, and one worker per distinct task, so two declarations
|
|
10
|
+
# for the same task share a worker and never overlap. Nothing here is
|
|
11
|
+
# mutated after construction, and the loop never blocks on a worker.
|
|
12
|
+
class Dispatcher
|
|
13
|
+
# Triggers a busy task can have waiting. RactorQueue rounds this up.
|
|
14
|
+
QUEUE_CAPACITY = 256
|
|
15
|
+
|
|
16
|
+
attr_reader :dispatch, :workers
|
|
17
|
+
|
|
18
|
+
# start: makes a worker from (queue, reports); the default starts a
|
|
19
|
+
# real Ractor. clock: supplies the trigger timestamp.
|
|
20
|
+
def initialize(declarations, context, reports:, start: Worker.method(:start), clock: Time)
|
|
21
|
+
specs = declarations.values
|
|
22
|
+
@context = context
|
|
23
|
+
@reports = reports
|
|
24
|
+
@clock = clock
|
|
25
|
+
@dispatch = specs.to_h { |spec| [spec[:name], spec] }.freeze
|
|
26
|
+
@workers = specs.map { |spec| spec[:task] }.uniq.to_h do |task|
|
|
27
|
+
queue = RactorQueue.new(capacity: QUEUE_CAPACITY)
|
|
28
|
+
[task, { queue:, ractor: start.call(queue, reports) }.freeze]
|
|
29
|
+
end.freeze
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Pops [listener id, batch] pairs from inbox until it pops nil.
|
|
33
|
+
def run(inbox)
|
|
34
|
+
while (id, batch = inbox.pop)
|
|
35
|
+
route(id, batch)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def start(inbox) = Thread.new { run(inbox) }
|
|
40
|
+
|
|
41
|
+
# One batch: drop the kinds the entry does not watch, build the frozen
|
|
42
|
+
# description, report the trigger, hand it to the task's worker. The
|
|
43
|
+
# description, or nil when nothing was left to run.
|
|
44
|
+
def route(id, batch)
|
|
45
|
+
spec = @dispatch.fetch(id)
|
|
46
|
+
changes = Watch.filter_events(batch, spec[:events]) or return nil
|
|
47
|
+
task = spec[:task]
|
|
48
|
+
description = Spawn.description(spec, changes, @context)
|
|
49
|
+
@reports.push(Ractor.make_shareable({ kind: :trigger, entry: spec[:name], task:, at: @clock.now.to_f, changes: changes.to_h }))
|
|
50
|
+
@workers.fetch(task)[:queue].push(description)
|
|
51
|
+
description
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|