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,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
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Asgard
|
|
6
|
+
module Watch
|
|
7
|
+
# The one writer of the watcher's log and per-entry state. Workers and
|
|
8
|
+
# the dispatcher push frozen reports onto a shared queue; the reporter
|
|
9
|
+
# drains it in a main-Ractor thread, appends a log line per report,
|
|
10
|
+
# keeps the latest record per entry on disk through the state, and
|
|
11
|
+
# tracks which pids are in flight so shutdown can wait for them.
|
|
12
|
+
class Reporter
|
|
13
|
+
class << self
|
|
14
|
+
# One log line for a report. behind: is the pid a trigger is queued
|
|
15
|
+
# behind when its task is still running.
|
|
16
|
+
def log_line(report, behind: nil)
|
|
17
|
+
kind, entry, task, pid, at = report.values_at(:kind, :entry, :task, :pid, :at)
|
|
18
|
+
head = "#{Time.at(at).strftime('%Y-%m-%d %H:%M:%S')} #{kind} #{entry} task=#{task}"
|
|
19
|
+
changed = paths(report)
|
|
20
|
+
case kind
|
|
21
|
+
when :trigger then "#{head} #{changed}#{" (queued behind pid #{behind})" if behind}"
|
|
22
|
+
when :spawn then "#{head} pid=#{pid} #{changed}"
|
|
23
|
+
else "#{head} pid=#{pid} status=#{report[:status]} seconds=#{format('%.2f', report[:seconds])}"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The entry's record after this report: a spawn starts a new record
|
|
28
|
+
# (no finished/status yet, which is how `list` shows "running"), an
|
|
29
|
+
# exit completes the previous one.
|
|
30
|
+
def entry_record(report, previous = nil)
|
|
31
|
+
entry, task, pid, at = report.values_at(:entry, :task, :pid, :at)
|
|
32
|
+
stamp = Time.at(at).iso8601
|
|
33
|
+
return { entry:, task:, pid:, started: stamp, changes: report[:changes] } if report[:kind] == :spawn
|
|
34
|
+
|
|
35
|
+
{ entry:, task:, pid:, **previous.to_h, finished: stamp, status: report[:status], seconds: report[:seconds] }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The changed paths of a trigger or spawn report, space-separated; "" for an exit.
|
|
39
|
+
def paths(report) = report.fetch(:changes) { {} }.values.flatten.join(" ")
|
|
40
|
+
|
|
41
|
+
# Whether a process exists; one we may not signal still exists.
|
|
42
|
+
def alive?(pid)
|
|
43
|
+
Process.kill(0, pid)
|
|
44
|
+
true
|
|
45
|
+
rescue Errno::ESRCH
|
|
46
|
+
false
|
|
47
|
+
rescue Errno::EPERM
|
|
48
|
+
true
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# state: responds to write_entry(name, record).
|
|
53
|
+
def initialize(reports, log:, state:)
|
|
54
|
+
@reports = reports
|
|
55
|
+
@log = log
|
|
56
|
+
@state = state
|
|
57
|
+
@records = {}
|
|
58
|
+
@running = {} # pid => task
|
|
59
|
+
@mutex = Mutex.new
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Handles reports until it pops nil.
|
|
63
|
+
def run
|
|
64
|
+
while (report = @reports.pop)
|
|
65
|
+
handle(report)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def start = Thread.new { run }
|
|
70
|
+
|
|
71
|
+
def handle(report)
|
|
72
|
+
trigger = report[:kind] == :trigger
|
|
73
|
+
line = Reporter.log_line(report, behind: trigger ? running_for(report[:task]) : nil)
|
|
74
|
+
File.open(@log, "a") { |file| file.puts(line) }
|
|
75
|
+
track(report) unless trigger
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Pids of tasks in flight, ascending.
|
|
79
|
+
def running = @mutex.synchronize { @running.keys.sort }
|
|
80
|
+
|
|
81
|
+
# The in-flight pid of task, or nil.
|
|
82
|
+
def running_for(task) = @mutex.synchronize { @running.key(task) }
|
|
83
|
+
|
|
84
|
+
# Blocks until no task is in flight, saying what it waits for once
|
|
85
|
+
# per interval. A pid that is gone without an exit report (a worker
|
|
86
|
+
# died between its reports) is forgotten rather than waited on.
|
|
87
|
+
def drain(interval: 1.0, out: $stdout)
|
|
88
|
+
until (pids = running).empty?
|
|
89
|
+
out.puts "watcher: waiting for pid #{pids.join(', ')}"
|
|
90
|
+
sleep interval
|
|
91
|
+
forget_dead
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def track(report)
|
|
98
|
+
entry, pid = report.values_at(:entry, :pid)
|
|
99
|
+
record = Reporter.entry_record(report, @records[entry])
|
|
100
|
+
@records[entry] = record
|
|
101
|
+
@state.write_entry(entry, record)
|
|
102
|
+
@mutex.synchronize { record.key?(:finished) ? @running.delete(pid) : @running[pid] = record[:task] }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def forget_dead
|
|
106
|
+
@mutex.synchronize { @running.delete_if { |pid, _task| !Reporter.alive?(pid) } }
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../schedule/declaration"
|
|
4
|
+
|
|
5
|
+
module Asgard
|
|
6
|
+
module Watch
|
|
7
|
+
# Builds the description of one task run: the argv, the complete
|
|
8
|
+
# environment, and where it runs and logs. The dispatcher builds it in
|
|
9
|
+
# the main Ractor, where ENV and the declarations live, and freezes it
|
|
10
|
+
# with Ractor.make_shareable; the worker Ractor that spawns the task
|
|
11
|
+
# sees nothing else, which is what keeps the daemon's own environment
|
|
12
|
+
# out of the task. Everything here is pure.
|
|
13
|
+
module Spawn
|
|
14
|
+
# The only variables a task inherits from the daemon. Everything else
|
|
15
|
+
# it sees comes from the declaration's env:, from .env and .envrc
|
|
16
|
+
# loaded in the task's own process, or from the trigger itself.
|
|
17
|
+
BASE_ENV_KEYS = %w[PATH HOME USER LOGNAME SHELL LANG TMPDIR SSH_AUTH_SOCK].freeze
|
|
18
|
+
BASE_ENV_PATTERN = /\ALC_/
|
|
19
|
+
|
|
20
|
+
# What every trigger of one daemon run shares: the real project root,
|
|
21
|
+
# the log file tasks append to, the base environment captured at
|
|
22
|
+
# start, the asgard executable, and direnv when it is on PATH.
|
|
23
|
+
Context = Data.define(:root, :log, :base, :asgard, :direnv)
|
|
24
|
+
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
# The whitelisted variables of env (ENV by default).
|
|
28
|
+
def base_env(env = ENV) = env.select { |key, _value| BASE_ENV_KEYS.include?(key) || key.match?(BASE_ENV_PATTERN) }
|
|
29
|
+
|
|
30
|
+
# The task's full environment, lowest precedence first: the context's
|
|
31
|
+
# base, the declaration's env:, the trigger variables.
|
|
32
|
+
def environment(spec, changes, context)
|
|
33
|
+
context.base.merge(spec[:env], { "ASGARD_WATCH" => spec[:name], "ASGARD_WATCH_ROOT" => context.root }, changes.to_env)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The task's argv: the context's asgard executable by absolute path, so
|
|
37
|
+
# a trigger runs the very asgard that started the watcher whatever PATH
|
|
38
|
+
# says, wrapped in direnv exec when available so .envrc loads in the
|
|
39
|
+
# task's process.
|
|
40
|
+
def argv(spec, context)
|
|
41
|
+
direnv = context.direnv
|
|
42
|
+
wrapper = direnv ? [direnv, "exec", context.root] : []
|
|
43
|
+
[*wrapper, context.asgard, spec[:task]]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The asgard executable to run: the one running now when it is an
|
|
47
|
+
# executable file named asgard (bin/asgard in a checkout, a gem
|
|
48
|
+
# binstub when installed), else the first on path. A PATH scan could
|
|
49
|
+
# find a different installed version than the one that started the
|
|
50
|
+
# watcher.
|
|
51
|
+
def executable(program = $PROGRAM_NAME, path = ENV.fetch("PATH", nil))
|
|
52
|
+
running = File.expand_path(program)
|
|
53
|
+
return running if File.basename(running) == "asgard" && File.file?(running) && File.executable?(running)
|
|
54
|
+
|
|
55
|
+
Schedule.which("asgard", path)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Everything a worker needs to run one trigger, deep-frozen and
|
|
59
|
+
# Ractor-shareable: Strings, Arrays and Hashes only.
|
|
60
|
+
def description(spec, changes, context)
|
|
61
|
+
Ractor.make_shareable(
|
|
62
|
+
{
|
|
63
|
+
entry: spec[:name],
|
|
64
|
+
task: spec[:task],
|
|
65
|
+
argv: argv(spec, context),
|
|
66
|
+
env: environment(spec, changes, context),
|
|
67
|
+
root: context.root,
|
|
68
|
+
log: context.log,
|
|
69
|
+
changes: changes.to_h
|
|
70
|
+
}
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "tempfile"
|
|
6
|
+
|
|
7
|
+
module Asgard
|
|
8
|
+
module Watch
|
|
9
|
+
# Where one project's watcher keeps what outlives a daemon run: the
|
|
10
|
+
# pidfile, the log, the declarations recorded at start, and one JSON
|
|
11
|
+
# record per entry with its last run. Everything lives outside the
|
|
12
|
+
# repository under $XDG_STATE_HOME/asgard/watch/<project-slug>/ (by
|
|
13
|
+
# default ~/.local/state), with a file naming the project root so
|
|
14
|
+
# `watcher list --all` can find every project without loading it.
|
|
15
|
+
class State
|
|
16
|
+
ROOT_FILE = "root"
|
|
17
|
+
|
|
18
|
+
attr_reader :dir, :root
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
def base_dir(home: Dir.home, env: ENV)
|
|
22
|
+
File.join(env["XDG_STATE_HOME"] || File.join(home, ".local", "state"), "asgard", "watch")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Every project with a state directory under base, as [slug, root] pairs.
|
|
26
|
+
def all(base: base_dir)
|
|
27
|
+
Dir.glob(File.join(base, "*", ROOT_FILE)).map do |file|
|
|
28
|
+
[File.basename(File.dirname(file)), File.read(file).strip]
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# What `list` needs of a declaration, with the events Set as an Array.
|
|
33
|
+
def declaration_record(spec) = spec.slice(:name, :task, :paths).merge(events: spec[:events].to_a)
|
|
34
|
+
|
|
35
|
+
# Written whole then renamed, so a reader never sees a partial file.
|
|
36
|
+
def write_json(path, value)
|
|
37
|
+
dir = File.dirname(path)
|
|
38
|
+
FileUtils.mkdir_p(dir)
|
|
39
|
+
Tempfile.create(["asgard", ".json"], dir) do |file|
|
|
40
|
+
file.write(JSON.pretty_generate(value))
|
|
41
|
+
file.flush
|
|
42
|
+
File.rename(file.path, path)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def read_json(path) = File.exist?(path) ? JSON.parse(File.read(path), symbolize_names: true) : nil
|
|
47
|
+
|
|
48
|
+
# Whether an entry record describes a task still running: no exit
|
|
49
|
+
# recorded and its pid alive.
|
|
50
|
+
def running?(entry) = !entry.key?(:finished) && Reporter.alive?(entry[:pid])
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# base: is the directory every project's state lives under.
|
|
54
|
+
def initialize(project:, root:, base: State.base_dir)
|
|
55
|
+
@root = root
|
|
56
|
+
@dir = File.join(base, Schedule.slug(project))
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def pid_path = File.join(dir, "watcher.pid")
|
|
60
|
+
def log_path = File.join(dir, "watcher.log")
|
|
61
|
+
def root_path = File.join(dir, ROOT_FILE)
|
|
62
|
+
def declarations_path = File.join(dir, "declarations.json")
|
|
63
|
+
def entries_dir = File.join(dir, "entries")
|
|
64
|
+
def entry_path(name) = File.join(entries_dir, "#{name}.json")
|
|
65
|
+
|
|
66
|
+
# Creates the directories and records the root.
|
|
67
|
+
def prepare
|
|
68
|
+
FileUtils.mkdir_p(entries_dir)
|
|
69
|
+
File.write(root_path, "#{root}\n")
|
|
70
|
+
self
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def write_pid(pid) = File.write(pid_path, "#{pid}\n")
|
|
74
|
+
|
|
75
|
+
def read_pid = File.exist?(pid_path) ? File.read(pid_path).to_i : nil
|
|
76
|
+
|
|
77
|
+
# The daemon's pid when one is running, else nil. A pidfile left by a
|
|
78
|
+
# crash counts as not running.
|
|
79
|
+
def running_pid
|
|
80
|
+
pid = read_pid
|
|
81
|
+
pid if pid&.positive? && Reporter.alive?(pid)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def clear = FileUtils.rm_f(pid_path)
|
|
85
|
+
|
|
86
|
+
# The declarations at start time, trimmed to what `list` shows.
|
|
87
|
+
def write_declarations(specs)
|
|
88
|
+
State.write_json(declarations_path, specs.map { |spec| State.declaration_record(spec) })
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def read_declarations = State.read_json(declarations_path) || []
|
|
92
|
+
|
|
93
|
+
def write_entry(name, record) = State.write_json(entry_path(name), record)
|
|
94
|
+
|
|
95
|
+
def read_entry(name) = State.read_json(entry_path(name))
|
|
96
|
+
|
|
97
|
+
# Every entry's latest record, keyed by entry name.
|
|
98
|
+
def entries = Dir.glob(File.join(entries_dir, "*.json")).to_h { |file| [File.basename(file, ".json"), State.read_json(file)] }
|
|
99
|
+
|
|
100
|
+
# The records of tasks still running.
|
|
101
|
+
def running_entries = entries.values.select { |entry| State.running?(entry) }
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Asgard
|
|
4
|
+
module Watch
|
|
5
|
+
# Runs one task's triggers, one at a time, in the order they arrive on
|
|
6
|
+
# its queue. One worker exists per distinct task, each in its own
|
|
7
|
+
# Ractor, so different tasks run concurrently while a task never
|
|
8
|
+
# overlaps itself: the wait for the child is the mutex. A worker sees
|
|
9
|
+
# nothing but frozen spawn descriptions and pushes a frozen report on
|
|
10
|
+
# every spawn and every exit; it writes no files itself.
|
|
11
|
+
class Worker
|
|
12
|
+
# Starts and reaps a task from a spawn description. Kept apart from
|
|
13
|
+
# the loop so tests can stand in a recorder.
|
|
14
|
+
module Spawner
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# The child's pid. The task runs in the project root with exactly
|
|
18
|
+
# the description's environment, in its own process group, with
|
|
19
|
+
# its output appended to the log.
|
|
20
|
+
def spawn(description)
|
|
21
|
+
Process.spawn(description[:env], *description[:argv],
|
|
22
|
+
chdir: description[:root], pgroup: true, unsetenv_others: true,
|
|
23
|
+
out: [description[:log], "a"], err: %i[child out])
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Blocks until the child exits; its exit code.
|
|
27
|
+
def wait(pid)
|
|
28
|
+
_, status = Process.wait2(pid)
|
|
29
|
+
exit_code(status)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# A signalled child reports 128 + the signal number, as shells do.
|
|
33
|
+
def exit_code(status) = status.exitstatus || (128 + status.termsig)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The worker loop in its own Ractor. The Ractor is handed the two
|
|
37
|
+
# queues and the spawner (all shareable) and nothing else.
|
|
38
|
+
def self.start(queue, reports, spawner: Spawner)
|
|
39
|
+
Ractor.new(queue, reports, spawner) { |inbox, outbox, runner| Asgard::Watch::Worker.new(inbox, outbox, spawner: runner).run }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def initialize(queue, reports, spawner: Spawner, clock: Process)
|
|
43
|
+
@queue = queue
|
|
44
|
+
@reports = reports
|
|
45
|
+
@spawner = spawner
|
|
46
|
+
@clock = clock
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Pops descriptions and runs each to completion. A nil on the queue
|
|
50
|
+
# ends the loop.
|
|
51
|
+
def run
|
|
52
|
+
while (description = @queue.pop)
|
|
53
|
+
run_one(description)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def run_one(description)
|
|
58
|
+
entry, task = description.values_at(:entry, :task)
|
|
59
|
+
pid = @spawner.spawn(description)
|
|
60
|
+
started_at, started = stamp
|
|
61
|
+
report(kind: :spawn, entry:, task:, pid:, at: started_at, changes: description[:changes])
|
|
62
|
+
status = @spawner.wait(pid)
|
|
63
|
+
finished_at, finished = stamp
|
|
64
|
+
report(kind: :exit, entry:, task:, pid:, at: finished_at, status:, seconds: finished - started)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
# [wall-clock seconds since the epoch, monotonic seconds]
|
|
70
|
+
def stamp = [@clock.clock_gettime(Process::CLOCK_REALTIME), @clock.clock_gettime(Process::CLOCK_MONOTONIC)]
|
|
71
|
+
|
|
72
|
+
def report(**fields) = @reports.push(Ractor.make_shareable(fields))
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
data/lib/asgard/watch.rb
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "watch/declaration"
|
|
4
|
+
require_relative "watch/changes"
|
|
5
|
+
require_relative "watch/spawn"
|
|
6
|
+
require_relative "watch/worker"
|
|
7
|
+
require_relative "watch/reporter"
|
|
8
|
+
require_relative "watch/dispatcher"
|
|
9
|
+
require_relative "watch/state"
|
|
10
|
+
require_relative "watch/daemon"
|
|
11
|
+
require_relative "watch/commands"
|
|
12
|
+
|
|
13
|
+
module Asgard
|
|
14
|
+
# Runs asgard tasks when watched files change. Declare entries at class
|
|
15
|
+
# level in a .loki file:
|
|
16
|
+
#
|
|
17
|
+
# class Tasks
|
|
18
|
+
# watch :lint, paths: "lib/**/*.rb"
|
|
19
|
+
# watch :test, paths: %w[lib test], only: /\.rb\z/
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# then manage the watcher with `asgard watcher run|start|stop|list|...`.
|
|
23
|
+
module Watch
|
|
24
|
+
# A watcher could not run: an unknown task, a daemon already running.
|
|
25
|
+
class Error < Asgard::Error; end
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
# Declared entries for this run, keyed by entry name.
|
|
29
|
+
def declarations
|
|
30
|
+
@declarations ||= {}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Records one declaration. Redeclaring an identical entry is a no-op
|
|
34
|
+
# (a .loki file loaded twice); a different entry under the same name
|
|
35
|
+
# is an error.
|
|
36
|
+
def declare(task, **settings)
|
|
37
|
+
spec = normalize(task, **settings)
|
|
38
|
+
name = spec[:name]
|
|
39
|
+
taken = declarations[name]
|
|
40
|
+
raise ArgumentError, "watch: #{name.inspect} is already declared; give one a distinct as: name" if taken && taken != spec
|
|
41
|
+
|
|
42
|
+
declarations[name] = spec
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The class-level `watch` declaration helper for Tasks.
|
|
47
|
+
module DSL
|
|
48
|
+
def watch(task, **) = Watch.declare(task, **)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
data/lib/asgard.rb
CHANGED
|
@@ -15,9 +15,9 @@ module Asgard
|
|
|
15
15
|
loki_up
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
-
# `asgard schedule list`
|
|
19
|
-
#
|
|
20
|
-
def self.machine_wide?(argv) =
|
|
18
|
+
# `asgard schedule list` and `asgard watcher list` report every project's
|
|
19
|
+
# entries, so they work from a directory with no .loki above it.
|
|
20
|
+
def self.machine_wide?(argv) = [%w[schedule list], %w[watcher list]].include?(argv.first(2))
|
|
21
21
|
|
|
22
22
|
# Main entry point invoked by the asgard executable.
|
|
23
23
|
def self.run!(argv)
|