asgard 0.3.0 → 0.3.2
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/.envrc +5 -0
- data/.loki +19 -23
- data/.reek.yml +112 -0
- data/.rubocop.yml +6 -0
- data/Archspec.rb +24 -0
- data/CHANGELOG.md +98 -2
- data/CLAUDE.md +4 -7
- data/README.md +93 -2
- data/bin/asgard +1 -1
- data/docs/api.md +130 -10
- data/docs/changelog.md +28 -0
- data/docs/dependencies.md +83 -2
- data/docs/getting-started.md +4 -2
- data/docs/helpers.md +84 -0
- data/docs/index.md +3 -3
- data/docs/options.md +51 -7
- data/docs/task-files.md +2 -2
- data/examples/bad.loki +63 -0
- data/examples/server_subcommands.loki +31 -22
- data/gem_tasks.loki +51 -0
- data/git.loki +13 -0
- data/lib/asgard/base/dependency_graph.rb +124 -0
- data/lib/asgard/base/dispatch.rb +160 -0
- data/lib/asgard/base/registry.rb +38 -0
- data/lib/asgard/base/task_dsl.rb +65 -0
- data/lib/asgard/base.rb +44 -222
- data/lib/asgard/doctor/report.rb +73 -0
- data/lib/asgard/doctor/task_sections.rb +67 -0
- data/lib/asgard/doctor.rb +150 -0
- data/lib/asgard/kernel_methods.rb +12 -6
- data/lib/asgard/tasks.rb +18 -6
- data/lib/asgard/version.rb +1 -1
- data/lib/asgard.rb +11 -1
- data/quality.loki +261 -0
- data/quality_rails.loki +46 -0
- data/xyzzy.loki +12 -0
- metadata +17 -17
- data/Rakefile +0 -101
data/examples/bad.loki
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# Demonstrates review.md Critical Issue #1: parallel depends_on tasks that
|
|
3
|
+
# write to shared instance state on `self` race each other.
|
|
4
|
+
#
|
|
5
|
+
# quality.loki's *_check tasks each write a *different* named ivar
|
|
6
|
+
# (@test_result, @rubocop_result, ...), so MRI's GVL happens to hide the
|
|
7
|
+
# race in practice — every write lands on a distinct slot. This file
|
|
8
|
+
# isolates the actual hazard by having 4 parallel workers read-modify-write
|
|
9
|
+
# the SAME @hits counter, the way any real *_check task would if it
|
|
10
|
+
# accumulated into one shared results structure instead of one ivar each.
|
|
11
|
+
#
|
|
12
|
+
# `@hits = @hits + 1` is not one atomic operation — it's a read, an add,
|
|
13
|
+
# then a write — and MRI can switch threads between those steps. Thread.pass
|
|
14
|
+
# inside the loop forces exactly those switches, so the lost-update race
|
|
15
|
+
# shows up reliably instead of "most of the time."
|
|
16
|
+
#
|
|
17
|
+
# Run with:
|
|
18
|
+
# asgard bad_race
|
|
19
|
+
#
|
|
20
|
+
# Expected @hits: 4000 (4 workers x 1000 increments each)
|
|
21
|
+
# Actual: consistently lower — proof of the lost-update race described in
|
|
22
|
+
# review.md item #1.
|
|
23
|
+
|
|
24
|
+
BAD_RACE_WORKERS = 4
|
|
25
|
+
BAD_RACE_REPS = 1000
|
|
26
|
+
|
|
27
|
+
class Tasks
|
|
28
|
+
desc "Increment the shared @hits counter, unsynchronized (worker)"
|
|
29
|
+
def racer_1 = bump_shared_counter
|
|
30
|
+
|
|
31
|
+
desc "Increment the shared @hits counter, unsynchronized (worker)"
|
|
32
|
+
def racer_2 = bump_shared_counter
|
|
33
|
+
|
|
34
|
+
desc "Increment the shared @hits counter, unsynchronized (worker)"
|
|
35
|
+
def racer_3 = bump_shared_counter
|
|
36
|
+
|
|
37
|
+
desc "Increment the shared @hits counter, unsynchronized (worker)"
|
|
38
|
+
def racer_4 = bump_shared_counter
|
|
39
|
+
|
|
40
|
+
depends_on [:racer_1, :racer_2, :racer_3, :racer_4]
|
|
41
|
+
desc "Show the lost-update race in @hits"
|
|
42
|
+
def bad_race
|
|
43
|
+
expected = BAD_RACE_WORKERS * BAD_RACE_REPS
|
|
44
|
+
puts "expected @hits == #{expected}, got #{@hits}"
|
|
45
|
+
|
|
46
|
+
if @hits == expected
|
|
47
|
+
puts "no corruption this run — the race is timing-dependent, rerun a few times"
|
|
48
|
+
else
|
|
49
|
+
puts "DATA RACE CONFIRMED: #{expected - @hits} update(s) lost"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
no_commands do
|
|
54
|
+
def bump_shared_counter
|
|
55
|
+
@hits ||= 0
|
|
56
|
+
BAD_RACE_REPS.times do
|
|
57
|
+
current = @hits # read
|
|
58
|
+
Thread.pass # force a context switch before the write lands
|
|
59
|
+
@hits = current + 1 # write — another thread's read may already be stale
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -1,29 +1,38 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
# Demonstrates Thor subcommands registered on the top-level Tasks class.
|
|
3
3
|
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
4
|
+
# Port state is persisted to .server.port so stop and restart always
|
|
5
|
+
# operate on the same port the server was started on.
|
|
6
6
|
#
|
|
7
7
|
# Usage:
|
|
8
|
-
# asgard server
|
|
8
|
+
# asgard server # shows subcommand help
|
|
9
9
|
# asgard server start
|
|
10
|
-
# asgard server start
|
|
10
|
+
# asgard server start -p 8000
|
|
11
|
+
# asgard server start -p 4000 --workers 4 --daemon
|
|
12
|
+
# asgard server stop
|
|
11
13
|
# asgard server stop --force
|
|
12
14
|
# asgard server status
|
|
13
|
-
# asgard server restart
|
|
15
|
+
# asgard server restart # stops then starts on the persisted port
|
|
16
|
+
|
|
17
|
+
SERVER_PORT_FILE = "tmp/.server.port".freeze
|
|
14
18
|
|
|
15
19
|
class ServerCommands < Tasks
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
20
|
+
default_task :help # This is the default value for the default_task
|
|
21
|
+
|
|
22
|
+
helper(:server_port) {
|
|
23
|
+
File.exist?(SERVER_PORT_FILE) ? File.read(SERVER_PORT_FILE).strip.to_i : 3000
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
desc "Start the server"
|
|
27
|
+
option :port, aliases: "-p", type: :numeric, default: 3000, desc: "Port to listen on"
|
|
28
|
+
option :daemon, aliases: "-d", type: :boolean, default: false, desc: "Run as a background daemon"
|
|
29
|
+
option :workers, aliases: "-w", type: :numeric, default: 2, desc: "Number of worker processes"
|
|
19
30
|
option :log, type: :string, default: "log/server.log",
|
|
20
31
|
banner: "FILE", desc: "Write logs to FILE"
|
|
21
|
-
def start
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
options[:daemon] ? " (daemon)" : ""
|
|
26
|
-
]
|
|
32
|
+
def start
|
|
33
|
+
FileUtils.mkdir_p("tmp")
|
|
34
|
+
File.write(SERVER_PORT_FILE, options[:port].to_s)
|
|
35
|
+
puts "Starting server on :#{options[:port]} with #{options[:workers]} workers#{options[:daemon] ? " (daemon)" : ""}..."
|
|
27
36
|
end
|
|
28
37
|
|
|
29
38
|
desc "Stop the running server"
|
|
@@ -31,22 +40,22 @@ class ServerCommands < Tasks
|
|
|
31
40
|
option :wait, type: :numeric, default: 30, desc: "Seconds to wait for shutdown"
|
|
32
41
|
def stop
|
|
33
42
|
if options[:force]
|
|
34
|
-
puts "Force-stopping server..."
|
|
43
|
+
puts "Force-stopping server on :#{server_port}..."
|
|
35
44
|
else
|
|
36
|
-
puts "Gracefully stopping server (timeout: #{options[:wait]}s)..."
|
|
45
|
+
puts "Gracefully stopping server on :#{server_port} (timeout: #{options[:wait] || 30}s)..."
|
|
37
46
|
end
|
|
38
47
|
end
|
|
39
48
|
|
|
40
49
|
desc "Show server status and process info"
|
|
41
50
|
def status
|
|
42
|
-
puts "
|
|
51
|
+
puts "Server is listening on :#{server_port}"
|
|
43
52
|
end
|
|
44
53
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
puts "Server restarted
|
|
54
|
+
depends_on :stop
|
|
55
|
+
desc "Restart the server on the same port it was started on"
|
|
56
|
+
def restart
|
|
57
|
+
puts "Starting server on :#{server_port}..."
|
|
58
|
+
puts "Server restarted."
|
|
50
59
|
end
|
|
51
60
|
end
|
|
52
61
|
|
data/gem_tasks.loki
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# Gem lifecycle tasks — imported by .loki
|
|
3
|
+
|
|
4
|
+
class Tasks
|
|
5
|
+
desc "Open IRB console with the gem loaded"
|
|
6
|
+
def console
|
|
7
|
+
if File.exist?("bin/console")
|
|
8
|
+
sh "bin/console"
|
|
9
|
+
else
|
|
10
|
+
sh "bundle exec irb -Ilib -r #{@@project}"
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
desc "Build the gem package"
|
|
15
|
+
depends_on :quality
|
|
16
|
+
def build
|
|
17
|
+
sh "mkdir -p pkg"
|
|
18
|
+
sh "gem build asgard.gemspec"
|
|
19
|
+
sh "mv asgard-#{project_version}.gem pkg/"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
desc "Build and install gem locally"
|
|
23
|
+
depends_on :build
|
|
24
|
+
def install
|
|
25
|
+
sh "gem install pkg/asgard-#{project_version}.gem"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
desc "release", "Release gem to RubyGems (runs quality gate first)"
|
|
29
|
+
option :yes, aliases: "-y", type: :boolean, default: false, desc: "Skip confirmation prompt"
|
|
30
|
+
depends_on :quality
|
|
31
|
+
def release
|
|
32
|
+
tag = "v#{project_version}"
|
|
33
|
+
gem_file = "pkg/asgard-#{project_version}.gem"
|
|
34
|
+
|
|
35
|
+
unless options[:yes]
|
|
36
|
+
print "Release #{@@project} v#{project_version} to RubyGems? [y/N] "
|
|
37
|
+
$stdout.flush
|
|
38
|
+
return puts "Aborted." unless $stdin.gets.strip.downcase == "y"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
abort "Working directory is not clean — commit or stash changes first." unless `git status --porcelain`.strip.empty?
|
|
42
|
+
abort "Tag #{tag} already exists." unless `git tag -l #{tag}`.strip.empty?
|
|
43
|
+
|
|
44
|
+
sh "mkdir -p pkg"
|
|
45
|
+
sh "gem build asgard.gemspec"
|
|
46
|
+
sh "mv asgard-#{project_version}.gem pkg/"
|
|
47
|
+
sh "git tag #{tag}"
|
|
48
|
+
sh "git push origin #{tag}"
|
|
49
|
+
sh "gem push #{gem_file}"
|
|
50
|
+
end
|
|
51
|
+
end
|
data/git.loki
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# Per-repo git tasks — imported by .loki
|
|
3
|
+
|
|
4
|
+
class Tasks
|
|
5
|
+
desc "Push the current branch to its remote"
|
|
6
|
+
def push = sh "git push"
|
|
7
|
+
|
|
8
|
+
desc "Pull (fast-forward only) from the remote"
|
|
9
|
+
def pull = sh "git pull --ff-only"
|
|
10
|
+
|
|
11
|
+
desc "Fetch from the remote"
|
|
12
|
+
def fetch = sh "git fetch"
|
|
13
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Asgard
|
|
4
|
+
class Base < Thor
|
|
5
|
+
# Dependency declaration (`depends_on`) and full-graph validation
|
|
6
|
+
# (`validate_deps!`), backed by stdlib TSort for cycle detection.
|
|
7
|
+
module DependencyGraph
|
|
8
|
+
def _deps
|
|
9
|
+
@_deps ||= {}
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Declare dependencies for the next task.
|
|
13
|
+
# Bare symbols run sequentially; arrays within the splat run in parallel.
|
|
14
|
+
#
|
|
15
|
+
# depends_on :build # sequential
|
|
16
|
+
# depends_on :build, :lint # both sequential
|
|
17
|
+
# depends_on [:build, :lint] # build and lint in parallel
|
|
18
|
+
# depends_on :setup, [:build, :lint], :test # setup, then build+lint, then test
|
|
19
|
+
#
|
|
20
|
+
# A sole Proc/lambda defers resolution to validate_deps! (after every
|
|
21
|
+
# .loki file has loaded), instead of now. It must return the same shape
|
|
22
|
+
# the splat form above would: an array of stages, each a Symbol
|
|
23
|
+
# (sequential) or Array (parallel group).
|
|
24
|
+
#
|
|
25
|
+
# depends_on -> { [all_commands.keys.grep(/_check\z/).map(&:to_sym)] }
|
|
26
|
+
def depends_on(*tasks)
|
|
27
|
+
@_pending_deps = tasks
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Validate the full dep graph for cycles using stdlib TSort.
|
|
31
|
+
def validate_deps!
|
|
32
|
+
_check_orphaned_deps!
|
|
33
|
+
return if _deps.empty?
|
|
34
|
+
|
|
35
|
+
_resolve_lazy_deps!
|
|
36
|
+
all_task_names = all_commands.keys.map(&:to_sym)
|
|
37
|
+
_check_undefined_deps!(all_task_names)
|
|
38
|
+
_check_dep_arities!
|
|
39
|
+
_build_and_sort_graph(all_task_names)
|
|
40
|
+
rescue TSort::Cyclic => e
|
|
41
|
+
raise Asgard::CircularDependencyError, e.message
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
# Normalizes depends_on's raw splat args into a _deps-ready value: the
|
|
47
|
+
# sole Proc/lambda unresolved (see #depends_on), or a concrete stage
|
|
48
|
+
# array.
|
|
49
|
+
def _normalize_pending_deps(pending)
|
|
50
|
+
sole = pending.first
|
|
51
|
+
return sole if pending.size == 1 && sole.respond_to?(:call)
|
|
52
|
+
|
|
53
|
+
_stages_from(pending)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Each element is a Symbol (sequential) or Array (parallel group).
|
|
57
|
+
def _stages_from(list)
|
|
58
|
+
list.map { |d| Array(d).map(&:to_sym) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Replaces any Proc-valued _deps entry (see #depends_on) with its
|
|
62
|
+
# resolved stage array. Runs once, after the full .loki chain has
|
|
63
|
+
# loaded, so the Proc can safely reference tasks defined in any file.
|
|
64
|
+
def _resolve_lazy_deps!
|
|
65
|
+
_deps.each do |task, stages_or_proc|
|
|
66
|
+
next unless stages_or_proc.respond_to?(:call)
|
|
67
|
+
|
|
68
|
+
_deps[task] = _stages_from(Array(_call_dep_proc(task, stages_or_proc)))
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def _call_dep_proc(task, dep_proc)
|
|
73
|
+
dep_proc.call
|
|
74
|
+
rescue StandardError => e
|
|
75
|
+
raise Asgard::Error, "depends_on proc for '#{task}' raised #{e.class}: #{e.message}"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def _check_orphaned_deps!
|
|
79
|
+
pending = Array(@_pending_deps)
|
|
80
|
+
return unless pending.any?
|
|
81
|
+
|
|
82
|
+
raise Asgard::Error,
|
|
83
|
+
"depends_on(#{pending.join(', ')}) declared without a following task definition"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def _check_undefined_deps!(all_task_names)
|
|
87
|
+
undefined = _deps.values.flatten.uniq - all_task_names
|
|
88
|
+
return unless undefined.any?
|
|
89
|
+
|
|
90
|
+
raise Asgard::Error, "undefined task(s) in depends_on: #{undefined.sort.join(', ')}"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def _check_dep_arities!
|
|
94
|
+
_deps.each_value do |stages|
|
|
95
|
+
stages.flatten.each do |dep|
|
|
96
|
+
meth = instance_method(dep.to_s)
|
|
97
|
+
required = meth.parameters.count { |type, _| type == :req }
|
|
98
|
+
next unless required.positive?
|
|
99
|
+
|
|
100
|
+
raise Asgard::Error,
|
|
101
|
+
"task '#{dep}' has #{required} required argument(s) and cannot be used as a dependency"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Runs a full topological sort purely to raise TSort::Cyclic on a cycle;
|
|
107
|
+
# the order itself isn't otherwise used (execution order comes from the
|
|
108
|
+
# stage groups each task's own depends_on declared).
|
|
109
|
+
def _build_and_sort_graph(all_task_names)
|
|
110
|
+
full_graph = all_task_names.to_h { |task| [task, _deps.fetch(task, []).flatten] }
|
|
111
|
+
Graph.new(full_graph).tsort
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Minimal TSort-able wrapper around a task => dependency-list Hash.
|
|
115
|
+
Graph = Struct.new(:edges) do
|
|
116
|
+
include TSort
|
|
117
|
+
|
|
118
|
+
def tsort_each_node(&) = edges.each_key(&)
|
|
119
|
+
def tsort_each_child(node, &) = edges.fetch(node).each(&)
|
|
120
|
+
end
|
|
121
|
+
private_constant :Graph
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Asgard
|
|
4
|
+
class Base < Thor
|
|
5
|
+
# Task execution engine: resolves and runs declared dependencies (in
|
|
6
|
+
# parallel where declared) before the target command runs.
|
|
7
|
+
#
|
|
8
|
+
# Completion-based deduplication: a task is only marked done after its
|
|
9
|
+
# body finishes. Threads that arrive at an already-running shared dep
|
|
10
|
+
# wait on its ConditionVariable rather than proceeding immediately,
|
|
11
|
+
# preventing the race where parallel tasks start before a shared dep
|
|
12
|
+
# has actually completed.
|
|
13
|
+
module Dispatch
|
|
14
|
+
def self.included(base)
|
|
15
|
+
base.extend(ClassMethods)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
module ClassMethods
|
|
19
|
+
def _running
|
|
20
|
+
@_running ||= Set.new
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def _done
|
|
24
|
+
@_done ||= Set.new
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def _cond
|
|
28
|
+
@_cond ||= Hash.new { |h, k| h[k] = ConditionVariable.new }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Completed tasks' return values, keyed by task name — lets a task
|
|
32
|
+
# already run by one thread hand its result to another thread that
|
|
33
|
+
# shares the dependency, without either touching `self`.
|
|
34
|
+
def _results
|
|
35
|
+
@_results ||= {}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def _ran_mutex
|
|
39
|
+
@_ran_mutex ||= Mutex.new
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Reset execution tracking for a fresh asgard invocation.
|
|
43
|
+
def _reset_ran!
|
|
44
|
+
_ran_mutex.synchronize do
|
|
45
|
+
@_running = Set.new
|
|
46
|
+
@_done = Set.new
|
|
47
|
+
@_cond = Hash.new { |h, k| h[k] = ConditionVariable.new }
|
|
48
|
+
@_results = {}
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Dispatch hook: resolves and runs all deps (in parallel where declared)
|
|
54
|
+
# before executing the target command.
|
|
55
|
+
def invoke_command(command, *)
|
|
56
|
+
$DEBUG = true if options[:debug]
|
|
57
|
+
$VERBOSE = true if options[:verbose]
|
|
58
|
+
target = command.name.to_sym
|
|
59
|
+
return cached_result(target) unless acquire_run_token(target)
|
|
60
|
+
|
|
61
|
+
result = nil
|
|
62
|
+
begin
|
|
63
|
+
resolved_deps = run_deps_for(target)
|
|
64
|
+
result = with_dep_results(resolved_deps) { command.run(self, *) }
|
|
65
|
+
ensure
|
|
66
|
+
signal_done(target, result)
|
|
67
|
+
end
|
|
68
|
+
result
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The results of +target+'s direct dependencies, keyed by task name —
|
|
72
|
+
# available to a task body while it runs, e.g. `dep_result(:test_check)`.
|
|
73
|
+
def dep_results
|
|
74
|
+
Thread.current[:asgard_dep_results] || {}
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def dep_result(task)
|
|
78
|
+
dep_results[task.to_sym]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def with_dep_results(results)
|
|
84
|
+
thread = Thread.current
|
|
85
|
+
previous = thread[:asgard_dep_results]
|
|
86
|
+
thread[:asgard_dep_results] = results
|
|
87
|
+
yield
|
|
88
|
+
ensure
|
|
89
|
+
thread[:asgard_dep_results] = previous
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def cached_result(target)
|
|
93
|
+
klass = self.class
|
|
94
|
+
klass._ran_mutex.synchronize { klass._results[target] }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def acquire_run_token(target)
|
|
98
|
+
klass = self.class
|
|
99
|
+
mutex = klass._ran_mutex
|
|
100
|
+
done = klass._done
|
|
101
|
+
running = klass._running
|
|
102
|
+
|
|
103
|
+
mutex.synchronize do
|
|
104
|
+
if done.include?(target)
|
|
105
|
+
false
|
|
106
|
+
elsif running.include?(target)
|
|
107
|
+
klass._cond[target].wait(mutex) until done.include?(target)
|
|
108
|
+
false
|
|
109
|
+
else
|
|
110
|
+
running.add(target)
|
|
111
|
+
true
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def run_deps_for(target)
|
|
117
|
+
stages = self.class._deps[target]
|
|
118
|
+
return {} unless stages&.any?
|
|
119
|
+
|
|
120
|
+
stages.each_with_object({}) { |group, acc| acc.merge!(run_dep_group(group)) }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def run_dep_group(group)
|
|
124
|
+
return { group.first => run_dep(group.first) } unless group.size > 1
|
|
125
|
+
|
|
126
|
+
threads = group.map { |task| [task, Thread.new { run_dep(task) }] }
|
|
127
|
+
errors = []
|
|
128
|
+
results = {}
|
|
129
|
+
threads.each do |task, t|
|
|
130
|
+
results[task] = t.value
|
|
131
|
+
rescue => e
|
|
132
|
+
errors << e
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
if errors.size == 1
|
|
136
|
+
raise errors.first
|
|
137
|
+
elsif errors.any?
|
|
138
|
+
errors.each { |e| warn "asgard: #{e.message}" }
|
|
139
|
+
raise Asgard::Error, "#{errors.size} parallel dependencies failed"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
results
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def signal_done(target, result)
|
|
146
|
+
klass = self.class
|
|
147
|
+
klass._ran_mutex.synchronize do
|
|
148
|
+
klass._done.add(target)
|
|
149
|
+
klass._results[target] = result
|
|
150
|
+
klass._cond[target].broadcast
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def run_dep(task)
|
|
155
|
+
command = self.class.all_commands[task.to_s]
|
|
156
|
+
invoke_command(command) if command
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Asgard
|
|
4
|
+
class Base < Thor
|
|
5
|
+
# Tracks every Asgard::Base subclass ever defined, and every method
|
|
6
|
+
# name's full source_location history — the data Doctor (`asgard
|
|
7
|
+
# --doctor`) uses to detect a silently-overridden `def`, same file or
|
|
8
|
+
# across files.
|
|
9
|
+
module Registry
|
|
10
|
+
def subclasses
|
|
11
|
+
@subclasses ||= []
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def inherited(subclass)
|
|
15
|
+
super
|
|
16
|
+
Asgard::Base.subclasses << subclass
|
|
17
|
+
subclass.instance_variable_set(:@_deps, {})
|
|
18
|
+
subclass.instance_variable_set(:@_method_log, Hash.new { |h, k| h[k] = [] })
|
|
19
|
+
subclass.instance_variable_set(:@_pending_deps, [])
|
|
20
|
+
subclass.instance_variable_set(:@_pending_single_desc, nil)
|
|
21
|
+
subclass.instance_variable_set(:@_pending_single_desc_opts, nil)
|
|
22
|
+
subclass.instance_variable_set(:@_running, Set.new)
|
|
23
|
+
subclass.instance_variable_set(:@_done, Set.new)
|
|
24
|
+
subclass.instance_variable_set(:@_cond, Hash.new { |h, k| h[k] = ConditionVariable.new })
|
|
25
|
+
subclass.instance_variable_set(:@_ran_mutex, Mutex.new)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Every source_location a method name has ever been defined at, in
|
|
29
|
+
# definition order — so a later `def` silently overriding an earlier
|
|
30
|
+
# one (same name, different file) is still visible after the fact.
|
|
31
|
+
# Doctor is the consumer; asgard itself doesn't otherwise care once
|
|
32
|
+
# the last definition wins.
|
|
33
|
+
def _method_log
|
|
34
|
+
@_method_log ||= Hash.new { |h, k| h[k] = [] }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Asgard
|
|
4
|
+
class Base < Thor
|
|
5
|
+
# Task-definition macros available inside .loki files: single-argument
|
|
6
|
+
# `desc`, `--no-x`/`--skip-x` suppression, `.env` loading, the `helper`
|
|
7
|
+
# DSL, and `default_task` override warnings.
|
|
8
|
+
module TaskDSL
|
|
9
|
+
# Allow single-argument desc: desc "Run the tests"
|
|
10
|
+
# The usage string defaults to the method name when the description is the only arg.
|
|
11
|
+
def desc(usage_or_desc, description = nil, options = {})
|
|
12
|
+
is_hash = description.is_a?(Hash)
|
|
13
|
+
if description.nil? || is_hash
|
|
14
|
+
options = description if is_hash
|
|
15
|
+
@_pending_single_desc = usage_or_desc
|
|
16
|
+
@_pending_single_desc_opts = options
|
|
17
|
+
else
|
|
18
|
+
@_pending_single_desc = nil
|
|
19
|
+
@_pending_single_desc_opts = nil
|
|
20
|
+
super
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Suppress [--no-name] / [--skip-name] from help for boolean class options
|
|
25
|
+
# where negation is meaningless. Call after class_option declarations.
|
|
26
|
+
def no_negate(*names)
|
|
27
|
+
names.each do |name|
|
|
28
|
+
opt = class_options[name]
|
|
29
|
+
next unless opt
|
|
30
|
+
opt.define_singleton_method(:usage) do |padding = 0|
|
|
31
|
+
aliases_for_usage.ljust(padding) + "[#{switch_name}]"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def dotenv(path = ".env")
|
|
37
|
+
require "dotenv"
|
|
38
|
+
Dotenv.load(path) if File.exist?(path)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def helper(name, &)
|
|
42
|
+
define_singleton_method(name, &)
|
|
43
|
+
no_commands { private define_method(name) { |*args, **kwargs, &blk| self.class.send(name, *args, **kwargs, &blk) } }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def default_task(meth = nil)
|
|
47
|
+
active = meth && meth != :none
|
|
48
|
+
here = caller_locations(1, 1).first if active
|
|
49
|
+
|
|
50
|
+
if active && @_default_task_location
|
|
51
|
+
# rubocop:disable Style/StderrPuts -- warn bypasses $stderr in Ruby 4.0, breaking capture_io in tests
|
|
52
|
+
$stderr.puts "asgard: default_task :#{meth} at #{here.path}:#{here.lineno} " \
|
|
53
|
+
"overrides default_task :#{@_default_task_name} set at " \
|
|
54
|
+
"#{@_default_task_location.path}:#{@_default_task_location.lineno}"
|
|
55
|
+
# rubocop:enable Style/StderrPuts
|
|
56
|
+
end
|
|
57
|
+
if active
|
|
58
|
+
@_default_task_location = here
|
|
59
|
+
@_default_task_name = meth
|
|
60
|
+
end
|
|
61
|
+
super
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|