asgard 0.4.1 → 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 +40 -0
- data/CLAUDE.md +10 -1
- data/README.md +37 -0
- data/docs/api.md +21 -0
- data/docs/index.md +1 -0
- 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/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 +50 -2
|
@@ -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)
|
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
|
|
@@ -51,6 +51,34 @@ dependencies:
|
|
|
51
51
|
- - "~>"
|
|
52
52
|
- !ruby/object:Gem::Version
|
|
53
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'
|
|
54
82
|
description: |
|
|
55
83
|
A powerful Ruby-based task runner for any kind of project with task dependency tracking
|
|
56
84
|
and concurrent execution of designated tasks. Uses Thor for its rich CLI options, var
|
|
@@ -95,7 +123,9 @@ files:
|
|
|
95
123
|
- docs/task-files.md
|
|
96
124
|
- docs/tasks.md
|
|
97
125
|
- docs/variables.md
|
|
126
|
+
- docs/watch.md
|
|
98
127
|
- examples/.env
|
|
128
|
+
- examples/.envrc
|
|
99
129
|
- examples/.loki
|
|
100
130
|
- examples/bad.loki
|
|
101
131
|
- examples/concurrent.loki
|
|
@@ -108,6 +138,14 @@ files:
|
|
|
108
138
|
- examples/subdir/.loki
|
|
109
139
|
- examples/subdir/import_demo.loki
|
|
110
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
|
|
111
149
|
- gem_tasks.loki
|
|
112
150
|
- git.loki
|
|
113
151
|
- lib/asgard.rb
|
|
@@ -130,6 +168,16 @@ files:
|
|
|
130
168
|
- lib/asgard/shell.rb
|
|
131
169
|
- lib/asgard/tasks.rb
|
|
132
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
|
|
133
181
|
- mkdocs.yml
|
|
134
182
|
- quality.loki
|
|
135
183
|
- quality_rails.loki
|
|
@@ -157,7 +205,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
157
205
|
- !ruby/object:Gem::Version
|
|
158
206
|
version: '0'
|
|
159
207
|
requirements: []
|
|
160
|
-
rubygems_version: 4.0.
|
|
208
|
+
rubygems_version: 4.0.23
|
|
161
209
|
specification_version: 4
|
|
162
210
|
summary: A powerful Ruby-based task runner
|
|
163
211
|
test_files: []
|