asgard 0.5.1 → 0.6.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 (102) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +179 -99
  3. data/README.md +141 -46
  4. data/bin/asgard +10 -0
  5. data/lib/asgard/base/dispatch.rb +257 -31
  6. data/lib/asgard/base/registry.rb +7 -3
  7. data/lib/asgard/base.rb +140 -9
  8. data/lib/asgard/declarations.rb +21 -0
  9. data/lib/asgard/doctor/task_sections.rb +11 -3
  10. data/lib/asgard/doctor.rb +12 -8
  11. data/lib/asgard/jobs/commands.rb +152 -0
  12. data/lib/asgard/jobs/declaration.rb +149 -0
  13. data/lib/asgard/jobs/launcher.rb +109 -0
  14. data/lib/asgard/jobs/state.rb +190 -0
  15. data/lib/asgard/jobs/stopper.rb +141 -0
  16. data/lib/asgard/jobs.rb +25 -0
  17. data/lib/asgard/kernel_methods.rb +19 -14
  18. data/lib/asgard/leading_flag.rb +10 -0
  19. data/lib/asgard/pid.rb +130 -0
  20. data/lib/asgard/schedule/commands.rb +39 -11
  21. data/lib/asgard/schedule/declaration.rb +139 -12
  22. data/lib/asgard/schedule/job_files.rb +24 -0
  23. data/lib/asgard/schedule/launchd.rb +55 -18
  24. data/lib/asgard/schedule/systemd.rb +69 -27
  25. data/lib/asgard/schedule/table.rb +34 -16
  26. data/lib/asgard/schedule.rb +49 -9
  27. data/lib/asgard/shell.rb +372 -44
  28. data/lib/asgard/state_dir.rb +84 -0
  29. data/lib/asgard/tasks.rb +8 -0
  30. data/lib/asgard/version.rb +1 -1
  31. data/lib/asgard/watch/changes.rb +26 -1
  32. data/lib/asgard/watch/commands.rb +52 -12
  33. data/lib/asgard/watch/daemon.rb +334 -43
  34. data/lib/asgard/watch/declaration.rb +135 -40
  35. data/lib/asgard/watch/dispatcher.rb +61 -10
  36. data/lib/asgard/watch/mailbox.rb +50 -0
  37. data/lib/asgard/watch/reporter.rb +50 -34
  38. data/lib/asgard/watch/spawn.rb +41 -16
  39. data/lib/asgard/watch/state.rb +93 -27
  40. data/lib/asgard/watch/worker.rb +44 -9
  41. data/lib/asgard/watch.rb +4 -11
  42. data/lib/asgard.rb +80 -14
  43. metadata +167 -186
  44. data/.envrc +0 -6
  45. data/.github/workflows/deploy-github-pages.yml +0 -52
  46. data/.loki +0 -39
  47. data/.reek.yml +0 -166
  48. data/.rubocop.yml +0 -163
  49. data/Archspec.rb +0 -24
  50. data/CLAUDE.md +0 -139
  51. data/COMMITS.md +0 -196
  52. data/bin/console +0 -11
  53. data/bin/setup +0 -8
  54. data/doc_tasks.loki +0 -21
  55. data/docs/api.md +0 -368
  56. data/docs/assets/css/custom.css +0 -93
  57. data/docs/assets/images/asgard.jpg +0 -0
  58. data/docs/changelog.md +0 -1
  59. data/docs/dependencies.md +0 -313
  60. data/docs/environment.md +0 -129
  61. data/docs/examples.md +0 -194
  62. data/docs/getting-started.md +0 -181
  63. data/docs/helpers.md +0 -262
  64. data/docs/index.md +0 -87
  65. data/docs/options.md +0 -224
  66. data/docs/schedule.md +0 -106
  67. data/docs/shell.md +0 -237
  68. data/docs/subcommands.md +0 -181
  69. data/docs/task-files.md +0 -407
  70. data/docs/tasks.md +0 -326
  71. data/docs/variables.md +0 -338
  72. data/docs/watch.md +0 -123
  73. data/examples/.env +0 -4
  74. data/examples/.envrc +0 -3
  75. data/examples/.loki +0 -24
  76. data/examples/bad.loki +0 -63
  77. data/examples/concurrent.loki +0 -58
  78. data/examples/confirm.loki +0 -48
  79. data/examples/db_subcommands.loki +0 -74
  80. data/examples/depends_on_block/bad/.loki +0 -51
  81. data/examples/depends_on_block/good/.loki +0 -83
  82. data/examples/do.loki +0 -45
  83. data/examples/env_usage.loki +0 -27
  84. data/examples/kitchen_sink.loki +0 -210
  85. data/examples/server_subcommands.loki +0 -65
  86. data/examples/subdir/.loki +0 -12
  87. data/examples/subdir/import_demo.loki +0 -14
  88. data/examples/subdir/import_up_demo.loki +0 -18
  89. data/examples/watcher_examples/.envrc +0 -3
  90. data/examples/watcher_examples/.loki +0 -54
  91. data/examples/watcher_examples/README.md +0 -112
  92. data/examples/watcher_examples/bin/hello +0 -3
  93. data/examples/watcher_examples/data/records.json +0 -1
  94. data/examples/watcher_examples/docs/drafts/ideas.md +0 -5
  95. data/examples/watcher_examples/docs/index.md +0 -4
  96. data/examples/watcher_examples/lib/greeter.rb +0 -7
  97. data/gem_tasks.loki +0 -51
  98. data/git.loki +0 -13
  99. data/mkdocs.yml +0 -166
  100. data/quality.loki +0 -266
  101. data/quality_rails.loki +0 -47
  102. data/xyzzy.loki +0 -12
@@ -1,8 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
- require "json"
5
- require "tempfile"
4
+ require_relative "../state_dir"
6
5
 
7
6
  module Asgard
8
7
  module Watch
@@ -13,41 +12,61 @@ module Asgard
13
12
  # default ~/.local/state), with a file naming the project root so
14
13
  # `watcher list --all` can find every project without loading it.
15
14
  class State
16
- ROOT_FILE = "root"
15
+ ROOT_FILE = StateDir::ROOT_FILE
16
+
17
+ # Names the inherited descriptor of the held watcher.lock, from
18
+ # `start` to the daemon it spawns and across a reload's exec.
19
+ LOCK_ENV = "ASGARD_WATCH_LOCK_FD"
20
+
21
+ # Names the file of changes a reloading daemon saw but did not run,
22
+ # for the daemon its exec starts to run instead.
23
+ PENDING_ENV = "ASGARD_WATCH_PENDING"
17
24
 
18
25
  attr_reader :dir, :root
19
26
 
20
27
  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
28
+ def base_dir(home: Dir.home, env: ENV) = StateDir.base_dir("watch", home:, env:)
24
29
 
25
30
  # 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
+ def all(base: base_dir) = StateDir.all(base:)
31
32
 
32
33
  # What `list` needs of a declaration, with the events Set as an Array.
33
34
  def declaration_record(spec) = spec.slice(:name, :task, :paths).merge(events: spec[:events].to_a)
34
35
 
35
36
  # 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
37
+ def write_json(path, value) = StateDir.write_json(path, value)
38
+
39
+ def read_json(path) = StateDir.read_json(path)
40
+
41
+ # The pending triggers a reload left at +path+ ([{ entry:, changes: }]),
42
+ # removing the file so they run once; [] for no path or no file.
43
+ def take_pending(path)
44
+ return [] unless path
45
+
46
+ pending = read_json(path) || []
47
+ FileUtils.rm_f(path)
48
+ pending
44
49
  end
45
50
 
46
- def read_json(path) = File.exist?(path) ? JSON.parse(File.read(path), symbolize_names: true) : nil
51
+ # Whether pid is a watcher daemon, not merely a live process.
52
+ def daemon?(pid) = Reporter.asgard_process?(pid, "watcher", "run")
47
53
 
48
54
  # 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])
55
+ # recorded and its pid still that task's asgard process. The
56
+ # current process always counts, as for asgard_process?.
57
+ def running?(entry)
58
+ return false if entry.key?(:finished)
59
+
60
+ pid = entry[:pid]
61
+ pid == Process.pid || task_process?(entry[:task].to_s, command: Pid.command_line(pid))
62
+ end
63
+
64
+ # Whether +command+ (a command line, or nil for no process) runs
65
+ # asgard with +task+ as a whole word: "t" is not "test_check".
66
+ def task_process?(task, command:)
67
+ words = command.to_s.split
68
+ words.any? { |word| File.basename(word) == "asgard" } && words.include?(task)
69
+ end
51
70
  end
52
71
 
53
72
  # base: is the directory every project's state lives under.
@@ -57,33 +76,74 @@ module Asgard
57
76
  end
58
77
 
59
78
  def pid_path = File.join(dir, "watcher.pid")
79
+ def lock_path = File.join(dir, "watcher.lock")
60
80
  def log_path = File.join(dir, "watcher.log")
61
81
  def root_path = File.join(dir, ROOT_FILE)
62
82
  def declarations_path = File.join(dir, "declarations.json")
83
+ def pending_path = File.join(dir, "pending.json")
63
84
  def entries_dir = File.join(dir, "entries")
64
85
  def entry_path(name) = File.join(entries_dir, "#{name}.json")
65
86
 
66
87
  # Creates the directories and records the root.
67
88
  def prepare
89
+ StateDir.record_root(dir, root)
68
90
  FileUtils.mkdir_p(entries_dir)
69
- File.write(root_path, "#{root}\n")
70
91
  self
71
92
  end
72
93
 
94
+ # One watcher per project: whoever holds an exclusive flock on
95
+ # watcher.lock. The open File while held, nil when another process
96
+ # holds it. The lock ends when the last descriptor of this open
97
+ # closes, so a daemon that inherits it keeps it for its lifetime.
98
+ # The descriptor is close-on-exec: only `start` and a reload hand it
99
+ # on, by name (see LOCK_ENV); a task the daemon spawns, and anything
100
+ # that task leaves running, never holds it.
101
+ def acquire_lock
102
+ FileUtils.mkdir_p(dir)
103
+ file = File.open(lock_path, File::RDWR | File::CREAT, 0o600) # rubocop:disable Style/FileOpen -- held, not read
104
+ file.close_on_exec = true
105
+ return file if file.flock(File::LOCK_EX | File::LOCK_NB)
106
+
107
+ file.close
108
+ nil
109
+ end
110
+
111
+ # The lock a parent handed down (see LOCK_ENV), or nil when none was
112
+ # or the descriptor is not this project's lock file. Handing it down
113
+ # cleared close-on-exec, so it is set again here: otherwise every
114
+ # task spawned would inherit the lock and could outlive the daemon
115
+ # holding it.
116
+ def adopt_lock(env = ENV)
117
+ fd = Integer(env[LOCK_ENV].to_s, exception: false) or return nil
118
+ io = File.for_fd(fd, autoclose: false)
119
+ return nil unless File.exist?(lock_path) && File.identical?(io, lock_path) && io.flock(File::LOCK_EX | File::LOCK_NB)
120
+
121
+ io.autoclose = true
122
+ io.close_on_exec = true
123
+ io
124
+ rescue Errno::EBADF, ArgumentError
125
+ nil
126
+ end
127
+
128
+ # The inherited lock, else a fresh one; nil when another holds it.
129
+ def lock(env = ENV) = adopt_lock(env) || acquire_lock
130
+
73
131
  def write_pid(pid) = File.write(pid_path, "#{pid}\n")
74
132
 
75
133
  def read_pid = File.exist?(pid_path) ? File.read(pid_path).to_i : nil
76
134
 
77
135
  # The daemon's pid when one is running, else nil. A pidfile left by a
78
- # crash counts as not running.
136
+ # crash counts as not running, even when another process has since
137
+ # been given its pid.
79
138
  def running_pid
80
139
  pid = read_pid
81
- pid if pid&.positive? && Reporter.alive?(pid)
140
+ pid if pid&.positive? && State.daemon?(pid)
82
141
  end
83
142
 
84
143
  def clear = FileUtils.rm_f(pid_path)
85
144
 
86
- # The declarations at start time, trimmed to what `list` shows.
145
+ # The declarations of the running daemon (written by every `watcher
146
+ # run`, a reload included), trimmed to what `list` shows.
87
147
  def write_declarations(specs)
88
148
  State.write_json(declarations_path, specs.map { |spec| State.declaration_record(spec) })
89
149
  end
@@ -95,7 +155,13 @@ module Asgard
95
155
  def read_entry(name) = State.read_json(entry_path(name))
96
156
 
97
157
  # 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)] }
158
+ # A corrupt record is left out (see StateDir.read_json).
159
+ def entries
160
+ Dir.glob(File.join(entries_dir, "*.json")).filter_map do |file|
161
+ record = State.read_json(file)
162
+ [File.basename(file, ".json"), record] if record
163
+ end.to_h
164
+ end
99
165
 
100
166
  # The records of tasks still running.
101
167
  def running_entries = entries.values.select { |entry| State.running?(entry) }
@@ -17,10 +17,14 @@ module Asgard
17
17
  # The child's pid. The task runs in the project root with exactly
18
18
  # the description's environment, in its own process group, with
19
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])
20
+ def spawn(description) = Process.spawn(description[:env], *description[:argv], **spawn_options(description))
21
+
22
+ # stdin is /dev/null: the task's group is a background group on any
23
+ # terminal the daemon has, where a read would stop it with SIGTTIN
24
+ # and leave its worker waiting forever.
25
+ def spawn_options(description)
26
+ { chdir: description[:root], pgroup: true, unsetenv_others: true, in: File::NULL,
27
+ out: [description[:log], "a"], err: %i[child out] }
24
28
  end
25
29
 
26
30
  # Blocks until the child exits; its exit code.
@@ -30,13 +34,17 @@ module Asgard
30
34
  end
31
35
 
32
36
  # A signalled child reports 128 + the signal number, as shells do.
33
- def exit_code(status) = status.exitstatus || (128 + status.termsig)
37
+ def exit_code(status) = Shell.exit_code(status)
34
38
  end
35
39
 
36
- # The worker loop in its own Ractor. The Ractor is handed the two
37
- # queues and the spawner (all shareable) and nothing else.
40
+ # The worker loop in its own Ractor. The Ractor is handed its queue,
41
+ # the reports and the spawner (all shareable) and nothing else, and
42
+ # pops through a Mailbox so it sleeps while idle: whoever pushes onto
43
+ # +queue+ must wake it, through Mailbox.new(queue, the_ractor).
38
44
  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 }
45
+ Ractor.new(queue, reports, spawner) do |inbox, outbox, runner|
46
+ Asgard::Watch::Worker.new(Asgard::Watch::Mailbox.new(inbox, Ractor.current), outbox, spawner: runner).run
47
+ end
40
48
  end
41
49
 
42
50
  def initialize(queue, reports, spawner: Spawner, clock: Process)
@@ -54,18 +62,45 @@ module Asgard
54
62
  end
55
63
  end
56
64
 
65
+ # One trigger. A spawn that fails (the root or the executable gone,
66
+ # an environment too large or malformed) is reported as an exit with
67
+ # no pid, so the worker carries on with the next trigger instead of
68
+ # ending.
57
69
  def run_one(description)
58
70
  entry, task = description.values_at(:entry, :task)
59
- pid = @spawner.spawn(description)
71
+ pid = spawn_or_report(description) or return
60
72
  started_at, started = stamp
61
73
  report(kind: :spawn, entry:, task:, pid:, at: started_at, changes: description[:changes])
62
74
  status = @spawner.wait(pid)
63
75
  finished_at, finished = stamp
64
76
  report(kind: :exit, entry:, task:, pid:, at: finished_at, status:, seconds: finished - started)
77
+ ensure
78
+ Worker.remove_changes_file(description[:changes_file])
79
+ end
80
+
81
+ # The exit code a spawn failure reports, as a shell would: 126 when
82
+ # the system could not run it, 127 when something was not found or
83
+ # spawn refused its arguments (an ArgumentError: a bad env: name).
84
+ def self.spawn_failure_status(error) = error.is_a?(SystemCallError) && !error.is_a?(Errno::ENOENT) ? 126 : 127
85
+
86
+ # The task has exited, so the change lists spilled for it are done.
87
+ def self.remove_changes_file(path)
88
+ File.delete(path) if path
89
+ rescue Errno::ENOENT
90
+ nil
65
91
  end
66
92
 
67
93
  private
68
94
 
95
+ def spawn_or_report(description)
96
+ @spawner.spawn(description)
97
+ rescue StandardError => e
98
+ at, = stamp
99
+ report(kind: :exit, entry: description[:entry], task: description[:task], pid: nil, at:,
100
+ status: Worker.spawn_failure_status(e), seconds: 0.0, error: e.message)
101
+ nil
102
+ end
103
+
69
104
  # [wall-clock seconds since the epoch, monotonic seconds]
70
105
  def stamp = [@clock.clock_gettime(Process::CLOCK_REALTIME), @clock.clock_gettime(Process::CLOCK_MONOTONIC)]
71
106
 
data/lib/asgard/watch.rb CHANGED
@@ -1,8 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "declarations"
3
4
  require_relative "watch/declaration"
4
5
  require_relative "watch/changes"
5
6
  require_relative "watch/spawn"
7
+ require_relative "watch/mailbox"
6
8
  require_relative "watch/worker"
7
9
  require_relative "watch/reporter"
8
10
  require_relative "watch/dispatcher"
@@ -30,17 +32,8 @@ module Asgard
30
32
  @declarations ||= {}
31
33
  end
32
34
 
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
35
+ # Records one declaration (see Declarations.record).
36
+ def declare(task, **) = Declarations.record(declarations, normalize(task, **), "watch")
44
37
  end
45
38
 
46
39
  # The class-level `watch` declaration helper for Tasks.
data/lib/asgard.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "asgard/errors"
4
4
  require_relative "asgard/version"
5
+ require_relative "asgard/leading_flag"
5
6
  require_relative "asgard/kernel_methods"
6
7
  require_relative "asgard/shell"
7
8
  require_relative "asgard/base"
@@ -15,29 +16,92 @@ module Asgard
15
16
  loki_up
16
17
  end
17
18
 
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))
19
+ # The directory of the nearest .loki, or the current directory without
20
+ # one: where the watcher and background jobs file their state. Fixed
21
+ # for the length of a run! (see pin_project_root), so a task that
22
+ # changes directory first still files its jobs under this project.
23
+ def self.project_root = @project_root || File.realpath((find_task_file&.dirname || Dir.pwd).to_s)
21
24
 
22
- # Main entry point invoked by the asgard executable.
23
- def self.run!(argv)
25
+ def self.pin_project_root(root) = @project_root = root
26
+
27
+ # Whether an import path, as written, is a glob pattern.
28
+ def self.glob?(path) = path.match?(/[*?\[{]/)
29
+
30
+ # +path+ with every glob metacharacter escaped, so Dir.glob matches it
31
+ # literally: a project in "glob[1]" still finds its own files.
32
+ def self.glob_escape(path) = path.to_s.gsub(/[\\*?\[\]{}]/) { "\\#{it}" }
33
+
34
+ # `asgard schedule list`, `asgard watcher list` and `asgard jobs list`
35
+ # report every project's entries, so they work from a directory with no
36
+ # .loki above it.
37
+ def self.machine_wide?(argv) = [%w[schedule list], %w[watcher list], %w[jobs list], %w[jobs]].include?(argv.first(2))
38
+
39
+ # The root .loki is loaded directly rather than through `import`, so
40
+ # record it the way import would. Otherwise an `import_up` in a child
41
+ # file finds the root, reloads it, and the two import each other without
42
+ # end.
43
+ def self.load_root(path)
44
+ path = File.expand_path(path.to_s)
45
+ $LOADED_FEATURES << path unless $LOADED_FEATURES.include?(path)
46
+ load path
47
+ end
48
+
49
+ # A frame of the backtrace inside a task file: "/proj/.loki:12" from
50
+ # "/proj/.loki:12:in '<class:Tasks>'".
51
+ LOKI_FRAME = /\A(.*?\.loki:\d+)/
52
+
53
+ # One line for an error raised while the task files load: the nearest
54
+ # .loki location, the message's first line and the class. A SyntaxError
55
+ # names its own location in its message, so that is used as is.
56
+ def self.load_error_message(error)
57
+ first = error.message.lines.first.to_s.strip
58
+ return "#{first} (#{error.class})" if error.is_a?(SyntaxError) && first.match?(LOKI_FRAME)
59
+
60
+ where = Array(error.backtrace).lazy.filter_map { |frame| frame[LOKI_FRAME, 1] }.first
61
+ [where, "#{first} (#{error.class})"].compact.join(": ")
62
+ end
63
+
64
+ # Loads the task files; anything they raise ends asgard with one line
65
+ # (see load_error_message), or the full backtrace with ASGARD_TRACE set.
66
+ def self.load_tasks(task_file)
67
+ load_root(task_file) if task_file
68
+ rescue Error
69
+ raise
70
+ rescue ScriptError, StandardError => e
71
+ raise if ENV["ASGARD_TRACE"]
72
+
73
+ abort "asgard: #{load_error_message(e)}"
74
+ end
75
+
76
+ # --version and --doctor answer and exit before any task file loads.
77
+ def self.answer_flags(argv)
24
78
  first = argv.first
25
79
  abort "asgard: unknown command '#{first}'" if first&.start_with?("_")
26
- if argv.include?("--version")
80
+ if leading_flag?(argv, "--version")
27
81
  puts Asgard::VERSION
28
82
  exit
29
83
  end
30
- if argv.include?("--doctor")
31
- Asgard::Doctor.new.run
32
- exit
33
- end
84
+ return unless leading_flag?(argv, "--doctor")
85
+
86
+ Asgard::Doctor.new.run
87
+ exit
88
+ end
89
+
90
+ # Finds and loads the task files and checks every class they defined.
91
+ def self.boot(argv)
34
92
  task_file = find_task_file
35
93
  abort "asgard: no .loki file found in #{Dir.pwd}" unless task_file || machine_wide?(argv)
36
- before = Asgard::Base.subclasses.dup
37
- load task_file if task_file
38
- newly_defined = Asgard::Base.subclasses - before
39
- (newly_defined + [Tasks]).uniq.each(&:validate_deps!)
94
+ before = Asgard::Base._asgard_subclasses.dup
95
+ load_tasks(task_file)
96
+ ((Asgard::Base._asgard_subclasses - before) + [Tasks]).uniq.each(&:validate_deps!)
97
+ end
98
+
99
+ # Main entry point invoked by the asgard executable.
100
+ def self.run!(argv)
101
+ answer_flags(argv)
102
+ boot(argv)
40
103
  Tasks._reset_ran!
104
+ pin_project_root(project_root)
41
105
  result = Tasks.start(argv)
42
106
  # Quality-gate convention: a task signals failure by returning :fail
43
107
  # (see dev/quality.loki's *_check tasks). Surface that as a nonzero
@@ -50,5 +114,7 @@ module Asgard
50
114
  abort "asgard: #{e.message}"
51
115
  rescue Interrupt
52
116
  exit(130)
117
+ ensure
118
+ pin_project_root(nil)
53
119
  end
54
120
  end