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
@@ -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
@@ -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 or abort "asgard: no .loki file found in #{Dir.pwd}"
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
@@ -159,6 +159,7 @@ nav:
159
159
  - Shell Helpers: shell.md
160
160
  - Environment: environment.md
161
161
  - Scheduled Tasks: schedule.md
162
+ - Watched Files: watch.md
162
163
  - Task Files: task-files.md
163
164
  - API Reference: api.md
164
165
  - Examples: examples.md
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.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.21
208
+ rubygems_version: 4.0.23
146
209
  specification_version: 4
147
210
  summary: A powerful Ruby-based task runner
148
211
  test_files: []