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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/.loki +3 -0
  3. data/.reek.yml +12 -0
  4. data/CHANGELOG.md +80 -0
  5. data/CLAUDE.md +10 -1
  6. data/README.md +41 -1
  7. data/docs/api.md +21 -0
  8. data/docs/index.md +1 -0
  9. data/docs/schedule.md +6 -1
  10. data/docs/watch.md +123 -0
  11. data/examples/.envrc +3 -0
  12. data/examples/watcher_examples/.envrc +3 -0
  13. data/examples/watcher_examples/.loki +54 -0
  14. data/examples/watcher_examples/README.md +112 -0
  15. data/examples/watcher_examples/bin/hello +3 -0
  16. data/examples/watcher_examples/data/records.json +1 -0
  17. data/examples/watcher_examples/docs/drafts/ideas.md +5 -0
  18. data/examples/watcher_examples/docs/index.md +4 -0
  19. data/examples/watcher_examples/lib/greeter.rb +7 -0
  20. data/lib/asgard/kernel_methods.rb +6 -0
  21. data/lib/asgard/schedule/commands.rb +102 -36
  22. data/lib/asgard/schedule/declaration.rb +61 -0
  23. data/lib/asgard/schedule/launchd.rb +55 -0
  24. data/lib/asgard/schedule/systemd.rb +47 -0
  25. data/lib/asgard/schedule/table.rb +79 -0
  26. data/lib/asgard/schedule.rb +1 -0
  27. data/lib/asgard/tasks.rb +9 -0
  28. data/lib/asgard/version.rb +1 -1
  29. data/lib/asgard/watch/changes.rb +52 -0
  30. data/lib/asgard/watch/commands.rb +211 -0
  31. data/lib/asgard/watch/daemon.rb +123 -0
  32. data/lib/asgard/watch/declaration.rb +173 -0
  33. data/lib/asgard/watch/dispatcher.rb +55 -0
  34. data/lib/asgard/watch/reporter.rb +110 -0
  35. data/lib/asgard/watch/spawn.rb +75 -0
  36. data/lib/asgard/watch/state.rb +104 -0
  37. data/lib/asgard/watch/worker.rb +75 -0
  38. data/lib/asgard/watch.rb +51 -0
  39. data/lib/asgard.rb +7 -2
  40. data/mkdocs.yml +1 -0
  41. metadata +65 -2
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Asgard
4
- VERSION = "0.4.0"
4
+ VERSION = "0.5.0"
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
@@ -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