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
|
@@ -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,6 +15,10 @@ module Asgard
|
|
|
15
15
|
loki_up
|
|
16
16
|
end
|
|
17
17
|
|
|
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
|
+
|
|
18
22
|
# Main entry point invoked by the asgard executable.
|
|
19
23
|
def self.run!(argv)
|
|
20
24
|
first = argv.first
|
|
@@ -27,9 +31,10 @@ module Asgard
|
|
|
27
31
|
Asgard::Doctor.new.run
|
|
28
32
|
exit
|
|
29
33
|
end
|
|
30
|
-
task_file = find_task_file
|
|
34
|
+
task_file = find_task_file
|
|
35
|
+
abort "asgard: no .loki file found in #{Dir.pwd}" unless task_file || machine_wide?(argv)
|
|
31
36
|
before = Asgard::Base.subclasses.dup
|
|
32
|
-
load task_file
|
|
37
|
+
load task_file if task_file
|
|
33
38
|
newly_defined = Asgard::Base.subclasses - before
|
|
34
39
|
(newly_defined + [Tasks]).uniq.each(&:validate_deps!)
|
|
35
40
|
Tasks._reset_ran!
|
data/mkdocs.yml
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: asgard
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dewayne VanHoozer
|
|
@@ -37,6 +37,48 @@ dependencies:
|
|
|
37
37
|
- - "~>"
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
39
|
version: '3.0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: tty-table
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '0.12'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '0.12'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: ractor_queue
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '0.2'
|
|
61
|
+
type: :runtime
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '0.2'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: listen
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - "~>"
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '3.9'
|
|
75
|
+
type: :runtime
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - "~>"
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '3.9'
|
|
40
82
|
description: |
|
|
41
83
|
A powerful Ruby-based task runner for any kind of project with task dependency tracking
|
|
42
84
|
and concurrent execution of designated tasks. Uses Thor for its rich CLI options, var
|
|
@@ -81,7 +123,9 @@ files:
|
|
|
81
123
|
- docs/task-files.md
|
|
82
124
|
- docs/tasks.md
|
|
83
125
|
- docs/variables.md
|
|
126
|
+
- docs/watch.md
|
|
84
127
|
- examples/.env
|
|
128
|
+
- examples/.envrc
|
|
85
129
|
- examples/.loki
|
|
86
130
|
- examples/bad.loki
|
|
87
131
|
- examples/concurrent.loki
|
|
@@ -94,6 +138,14 @@ files:
|
|
|
94
138
|
- examples/subdir/.loki
|
|
95
139
|
- examples/subdir/import_demo.loki
|
|
96
140
|
- examples/subdir/import_up_demo.loki
|
|
141
|
+
- examples/watcher_examples/.envrc
|
|
142
|
+
- examples/watcher_examples/.loki
|
|
143
|
+
- examples/watcher_examples/README.md
|
|
144
|
+
- examples/watcher_examples/bin/hello
|
|
145
|
+
- examples/watcher_examples/data/records.json
|
|
146
|
+
- examples/watcher_examples/docs/drafts/ideas.md
|
|
147
|
+
- examples/watcher_examples/docs/index.md
|
|
148
|
+
- examples/watcher_examples/lib/greeter.rb
|
|
97
149
|
- gem_tasks.loki
|
|
98
150
|
- git.loki
|
|
99
151
|
- lib/asgard.rb
|
|
@@ -112,9 +164,20 @@ files:
|
|
|
112
164
|
- lib/asgard/schedule/declaration.rb
|
|
113
165
|
- lib/asgard/schedule/launchd.rb
|
|
114
166
|
- lib/asgard/schedule/systemd.rb
|
|
167
|
+
- lib/asgard/schedule/table.rb
|
|
115
168
|
- lib/asgard/shell.rb
|
|
116
169
|
- lib/asgard/tasks.rb
|
|
117
170
|
- lib/asgard/version.rb
|
|
171
|
+
- lib/asgard/watch.rb
|
|
172
|
+
- lib/asgard/watch/changes.rb
|
|
173
|
+
- lib/asgard/watch/commands.rb
|
|
174
|
+
- lib/asgard/watch/daemon.rb
|
|
175
|
+
- lib/asgard/watch/declaration.rb
|
|
176
|
+
- lib/asgard/watch/dispatcher.rb
|
|
177
|
+
- lib/asgard/watch/reporter.rb
|
|
178
|
+
- lib/asgard/watch/spawn.rb
|
|
179
|
+
- lib/asgard/watch/state.rb
|
|
180
|
+
- lib/asgard/watch/worker.rb
|
|
118
181
|
- mkdocs.yml
|
|
119
182
|
- quality.loki
|
|
120
183
|
- quality_rails.loki
|
|
@@ -142,7 +205,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
142
205
|
- !ruby/object:Gem::Version
|
|
143
206
|
version: '0'
|
|
144
207
|
requirements: []
|
|
145
|
-
rubygems_version: 4.0.
|
|
208
|
+
rubygems_version: 4.0.23
|
|
146
209
|
specification_version: 4
|
|
147
210
|
summary: A powerful Ruby-based task runner
|
|
148
211
|
test_files: []
|