asgard 0.2.0 → 0.3.1

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.
@@ -5,9 +5,20 @@
5
5
  # install, release) so this file can be loaded alongside them without conflict.
6
6
 
7
7
  class Tasks
8
- # ── Asgard: varstatic value and lazy lambda ─────────────────────────────
9
- var :app_name, "my_app"
10
- var :build_dir, -> { "builds/#{app_name}" }
8
+ # ── Ruby class variables shared across all tasks and subcommands ──────────
9
+ @@app_name ||= "my_app".freeze
10
+ @@build_dir ||= "builds/#{@@app_name}".freeze
11
+
12
+ # ── Computed values — plain private methods replace the removed var DSL ──────
13
+ # Declare as private so Thor does not try to register them as commands.
14
+ # Use @ivar ||= inside the method body to memoize expensive calls.
15
+ private
16
+
17
+ def version = `git describe --tags --always`.strip
18
+ def sha = `git rev-parse --short HEAD`.strip
19
+ def branch = @branch ||= `git rev-parse --abbrev-ref HEAD`.strip
20
+
21
+ public
11
22
 
12
23
  # ── Asgard: dotenv — load environment variables ────────────────────────────
13
24
  # Uncomment to activate:
@@ -37,9 +48,9 @@ class Tasks
37
48
  map "pl" => :pipeline
38
49
 
39
50
  # ── Basic task — no parameters ─────────────────────────────────────────────
40
- desc "greet", "Say hello (default task when no command is given)"
51
+ desc "Say hello (default task when no command is given)"
41
52
  def greet
42
- puts "Hello from #{app_name} (#{options[:env]})!"
53
+ puts "Hello from #{@@app_name} #{version} (#{branch}) in #{options[:env]} mode!"
43
54
  end
44
55
 
45
56
  # ── Positional parameter with default ──────────────────────────────────────
@@ -72,14 +83,14 @@ class Tasks
72
83
  end
73
84
 
74
85
  # ── method_option — all five option types ──────────────────────────────────
75
- desc "compile", "Compile the project"
86
+ desc "Compile the project"
76
87
  option :output, aliases: "-o", type: :string, default: "dist/", desc: "Output directory"
77
88
  option :verbose, aliases: "-v", type: :boolean, default: false, desc: "Enable verbose output"
78
89
  option :jobs, aliases: "-j", type: :numeric, default: 1, desc: "Number of parallel jobs"
79
90
  option :tags, type: :array, desc: "Build tags to apply"
80
91
  option :defines, type: :hash, desc: "Preprocessor defines (KEY:VALUE)"
81
92
  def compile
82
- puts "Compiling #{app_name} → #{options[:output]}"
93
+ puts "Compiling #{@@app_name} → #{options[:output]}"
83
94
  end
84
95
 
85
96
  # ── required option + enum + banner ────────────────────────────────────────
@@ -99,7 +110,7 @@ class Tasks
99
110
  default: "main",
100
111
  desc: "Git branch to deploy"
101
112
  def deploy(env = "staging")
102
- puts "Deploying #{app_name}@#{options[:branch]} to #{env}..."
113
+ puts "Deploying #{@@app_name}@#{options[:branch]} to #{env}..."
103
114
  end
104
115
 
105
116
  # ── long_desc — extended help shown by `asgard help report` ────────────────
@@ -115,7 +126,7 @@ class Tasks
115
126
  asgard report --format json --output report.json\x5
116
127
  asgard rp --format text
117
128
  LONGDESC
118
- desc "report", "Generate a project report"
129
+ desc "Generate a project report"
119
130
  option :format, type: :string, default: "text", enum: %w[text html json], desc: "Output format"
120
131
  option :since, type: :string, banner: "DATE", desc: "Limit to changes after DATE"
121
132
  option :output, type: :string, banner: "FILE", desc: "Write output to FILE"
@@ -124,30 +135,52 @@ class Tasks
124
135
  end
125
136
 
126
137
  # ── Asgard depends_on: sequential — analyze runs before spec ───────────────
127
- desc "analyze", "Check code style and complexity"
138
+ desc "Check code style and complexity"
128
139
  def analyze = puts "Analyzing..."
129
140
 
130
141
  depends_on :analyze
131
- desc "spec", "Run the test suite (depends on: analyze)"
142
+ desc "Run the test suite (depends on: analyze)"
132
143
  def spec = puts "Running specs..."
133
144
 
134
145
  # ── Asgard depends_on: parallel — analyze and typecheck run concurrently ───
135
- desc "typecheck", "Run the type checker"
146
+ desc "Run the type checker"
136
147
  def typecheck = puts "Type checking..."
137
148
 
138
149
  depends_on [:analyze, :typecheck]
139
- desc "check", "Run analyze and typecheck in parallel"
150
+ desc "Run analyze and typecheck in parallel"
140
151
  def check = puts "All checks passed."
141
152
 
142
153
  # ── Asgard depends_on: mixed sequential + parallel ─────────────────────────
143
- desc "pack", "Create distribution archive"
154
+ desc "Create distribution archive"
144
155
  def pack = puts "Packing..."
145
156
 
146
157
  # check → compile+spec (parallel) → pack → pipeline
147
158
  depends_on :check, [:compile, :spec], :pack
148
- desc "pipeline", "Full pipeline: check → compile+spec → pack"
159
+ desc "Full pipeline: check → compile+spec → pack"
149
160
  def pipeline = puts "Pipeline complete."
150
161
 
162
+ # ── Asgard: debug? / verbose? — Kernel predicates for conditional output ──────
163
+ # debug? returns true when --debug is passed (sets $DEBUG)
164
+ # verbose? returns true when --verbose is passed (sets $VERBOSE)
165
+ # Both are set by Asgard before invoke_command runs, so they are safe
166
+ # to read inside any task body.
167
+ #
168
+ # Run with:
169
+ # asgard status --verbose
170
+ # asgard status --debug
171
+ # asgard status --verbose --debug
172
+ desc "Show application status"
173
+ def status
174
+ puts "#{@@app_name} is running in #{options[:env]} mode."
175
+ puts " build dir : #{@@build_dir}" if verbose?
176
+ puts " sha : #{current_sha}" if verbose?
177
+ if debug?
178
+ puts " $DEBUG : #{$DEBUG.inspect}"
179
+ puts " $VERBOSE : #{$VERBOSE.inspect}"
180
+ puts " options : #{options.inspect}"
181
+ end
182
+ end
183
+
151
184
  # ── Thor: no_commands — public helper excluded from CLI and --help ──────────
152
185
  no_commands do
153
186
  def current_sha
@@ -1,52 +1,61 @@
1
1
  # frozen_string_literal: true
2
2
  # Demonstrates Thor subcommands registered on the top-level Tasks class.
3
3
  #
4
- # The subcommand class inherits from Tasks so it has access to sh, shebang,
5
- # var, depends_on, and the built-in --debug/--verbose class options.
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 # shows subcommand help
8
+ # asgard server # shows subcommand help
9
9
  # asgard server start
10
- # asgard server start 4000 --workers 4 --daemon
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 4000
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
- desc "start [PORT]", "Start the server on PORT (default: 3000)"
17
- option :daemon, aliases: "-d", type: :boolean, default: false, desc: "Run as a background daemon"
18
- option :workers, aliases: "-w", type: :numeric, default: 2, desc: "Number of worker processes"
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(port = "3000")
22
- puts "Starting server on :%s with %d workers%s" % [
23
- port,
24
- options[:workers],
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
- desc "stop", "Stop the running server"
38
+ desc "Stop the running server"
30
39
  option :force, aliases: "-f", type: :boolean, default: false, desc: "Force-kill without draining"
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
- desc "status", "Show server status and process info"
49
+ desc "Show server status and process info"
41
50
  def status
42
- puts "Checking server status..."
51
+ puts "Server is listening on :#{server_port}"
43
52
  end
44
53
 
45
- # depends_on works inside subcommand groups — stop runs before start
46
- depends_on :stop, :start
47
- desc "restart [PORT]", "Restart the server on PORT (stop, then start)"
48
- def restart(port = "3000")
49
- puts "Server restarted on port #{port}."
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
 
@@ -0,0 +1,12 @@
1
+ # examples/subdir/.loki — root task file for this subdirectory.
2
+ #
3
+ # When asgard is run from examples/subdir/, it loads this file.
4
+ # From here, import pulls in sibling task files explicitly.
5
+ #
6
+ # import accepts a direct path or a glob:
7
+ # import "import_up_demo.loki" # a single file
8
+ # import "*.loki" # all loki files in this directory
9
+ # import "tasks/**/*.loki" # all loki files under a tasks/ tree
10
+ # import "../shared/*.loki" # files in a sibling directory
11
+
12
+ import "import_up_demo.loki"
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+ # This file lives in a subdirectory and is explicitly imported by the
3
+ # parent examples/.loki via import "subdir/import_demo.loki".
4
+ #
5
+ # In a real project a subdirectory might hold task files scoped to a
6
+ # specific concern (deploy, database, CI) while the root .loki wires
7
+ # them all together with import.
8
+
9
+ class Tasks
10
+ desc "Confirm this task was loaded from a subdirectory via import"
11
+ def subdir_task
12
+ puts "Loaded from examples/subdir/import_demo.loki"
13
+ end
14
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+ # Demonstrates import_up — find a file by walking up the directory tree
3
+ # and load it, without needing to know how deep the current file is.
4
+ #
5
+ # import_up(name) combines loki_up(name) + import(path) in one call.
6
+ # It searches CWD, then each ancestor in turn, and loads the first match.
7
+ #
8
+ # This is useful when a nested task file needs to pull in something from
9
+ # the project root — a shared config, a common helpers file — without
10
+ # hardcoding a relative path that would break if the file moves.
11
+ #
12
+ # import_up also accepts a glob:
13
+ # import_up "*.loki" # first directory (walking up) that contains any .loki file
14
+ # import_up "config/settings.loki" # first ancestor with config/settings.loki
15
+ # import_up ".env" # locate a .env file anywhere up the tree
16
+
17
+ # Load env_usage.loki from the nearest ancestor directory that contains it.
18
+ import_up "env_usage.loki"
data/gem_tasks.loki ADDED
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+ # Gem lifecycle tasks — imported by .loki
3
+
4
+ class Tasks
5
+ desc "Build the gem package"
6
+ depends_on :quality
7
+ def build
8
+ sh "mkdir -p pkg"
9
+ sh "gem build asgard.gemspec"
10
+ sh "mv asgard-#{project_version}.gem pkg/"
11
+ end
12
+
13
+ desc "Build and install gem locally"
14
+ depends_on :build
15
+ def install
16
+ sh "gem install pkg/asgard-#{project_version}.gem"
17
+ end
18
+
19
+ desc "Release to RubyGems"
20
+ depends_on :quality
21
+ def release
22
+ tag = "v#{project_version}"
23
+ gem_file = "pkg/asgard-#{project_version}.gem"
24
+
25
+ abort "Working directory is not clean — commit or stash changes first." unless `git status --porcelain`.strip.empty?
26
+ abort "Tag #{tag} already exists." unless `git tag -l #{tag}`.strip.empty?
27
+
28
+ sh "mkdir -p pkg"
29
+ sh "gem build asgard.gemspec"
30
+ sh "mv asgard-#{project_version}.gem pkg/"
31
+ sh "git tag #{tag}"
32
+ sh "git push origin #{tag}"
33
+ sh "gem push #{gem_file}"
34
+ end
35
+ end
data/lib/asgard/base.rb CHANGED
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "thor"
4
- require "set"
5
4
  require "dagwood"
6
5
 
7
6
  module Asgard
@@ -16,9 +15,10 @@ module Asgard
16
15
  def inherited(subclass)
17
16
  super
18
17
  Asgard::Base.subclasses << subclass
19
- subclass.instance_variable_set(:@_deps, {})
20
- subclass.instance_variable_set(:@_vars, {})
21
- subclass.instance_variable_set(:@_pending_deps, [])
18
+ subclass.instance_variable_set(:@_deps, {})
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
22
  subclass.instance_variable_set(:@_running, Set.new)
23
23
  subclass.instance_variable_set(:@_done, Set.new)
24
24
  subclass.instance_variable_set(:@_cond, Hash.new { |h, k| h[k] = ConditionVariable.new })
@@ -29,10 +29,6 @@ module Asgard
29
29
  @_deps ||= {}
30
30
  end
31
31
 
32
- def _vars
33
- @_vars ||= {}
34
- end
35
-
36
32
  def _running
37
33
  @_running ||= Set.new
38
34
  end
@@ -65,7 +61,7 @@ module Asgard
65
61
  def _build_dep_graph(stages)
66
62
  graph = {}
67
63
  stages.each_with_index do |stage, i|
68
- prev_stage = i > 0 ? stages[i - 1] : []
64
+ prev_stage = i.positive? ? stages[i - 1] : []
69
65
  stage.each { |task| graph[task] = prev_stage.dup }
70
66
  end
71
67
  graph
@@ -82,23 +78,40 @@ module Asgard
82
78
  @_pending_deps = tasks
83
79
  end
84
80
 
85
- def var(name, value = nil, &block)
86
- value = block if block_given?
87
- _vars[name.to_sym] = value
88
- no_commands do
89
- define_method(name) do
90
- ivar = :"@__var_#{name}"
91
- unless instance_variable_defined?(ivar)
92
- v = self.class._vars[name.to_sym]
93
- instance_variable_set(ivar, v.respond_to?(:call) ? v.call : v)
94
- end
95
- instance_variable_get(ivar)
81
+ # Allow single-argument desc: desc "Run the tests"
82
+ # The usage string defaults to the method name when the description is the only arg.
83
+ def desc(usage_or_desc, description = nil, options = {})
84
+ if description.nil? || description.is_a?(Hash)
85
+ options = description if description.is_a?(Hash)
86
+ @_pending_single_desc = usage_or_desc
87
+ @_pending_single_desc_opts = options
88
+ else
89
+ @_pending_single_desc = nil
90
+ @_pending_single_desc_opts = nil
91
+ super
92
+ end
93
+ end
94
+
95
+ # Suppress [--no-name] / [--skip-name] from help for boolean class options
96
+ # where negation is meaningless. Call after class_option declarations.
97
+ def no_negate(*names)
98
+ names.each do |name|
99
+ opt = class_options[name]
100
+ next unless opt
101
+ opt.define_singleton_method(:usage) do |padding = 0|
102
+ aliases_for_usage.ljust(padding) + "[#{switch_name}]"
96
103
  end
97
104
  end
98
105
  end
99
106
 
100
- def import(mod)
101
- include mod
107
+ def header(text = nil)
108
+ return @_header if text.nil?
109
+ (@_header ||= []) << text
110
+ end
111
+
112
+ def footer(text = nil)
113
+ return @_footer if text.nil?
114
+ (@_footer ||= []).unshift(text)
102
115
  end
103
116
 
104
117
  def dotenv(path = ".env")
@@ -106,44 +119,86 @@ module Asgard
106
119
  Dotenv.load(path) if File.exist?(path)
107
120
  end
108
121
 
109
- # Validate the full dep graph for cycles using Dagwood::DependencyGraph.
110
- def validate_deps!
111
- pending = Array(@_pending_deps)
112
- if pending.any?
113
- raise Asgard::Error,
114
- "depends_on(#{pending.join(', ')}) declared without a following task definition"
122
+ def helper(name, &)
123
+ define_singleton_method(name, &)
124
+ no_commands { private define_method(name) { |*args, **kwargs, &blk| self.class.send(name, *args, **kwargs, &blk) } }
125
+ end
126
+
127
+ def default_task(meth = nil)
128
+ if meth && meth != :none && @_default_task_location
129
+ here = caller_locations(1, 1).first
130
+ # rubocop:disable Style/StderrPuts -- warn bypasses $stderr in Ruby 4.0, breaking capture_io in tests
131
+ $stderr.puts "asgard: default_task :#{meth} at #{here.path}:#{here.lineno} " \
132
+ "overrides default_task :#{@_default_task_name} set at " \
133
+ "#{@_default_task_location.path}:#{@_default_task_location.lineno}"
134
+ # rubocop:enable Style/StderrPuts
115
135
  end
136
+ if meth && meth != :none
137
+ @_default_task_location = caller_locations(1, 1).first
138
+ @_default_task_name = meth
139
+ end
140
+ super
141
+ end
116
142
 
143
+ # Validate the full dep graph for cycles using Dagwood::DependencyGraph.
144
+ def validate_deps!
145
+ _check_orphaned_deps!
117
146
  return if _deps.empty?
118
147
 
119
148
  all_task_names = all_commands.keys.map(&:to_sym)
120
- full_graph = all_task_names.each_with_object({}) do |task, hash|
121
- hash[task] = _deps.fetch(task, []).flatten
122
- end
149
+ _check_undefined_deps!(all_task_names)
150
+ _check_dep_arities!
151
+ _build_and_sort_graph(all_task_names)
152
+ rescue TSort::Cyclic => e
153
+ raise Asgard::CircularDependencyError, e.message
154
+ end
155
+
156
+ private
123
157
 
158
+ def _check_orphaned_deps!
159
+ pending = Array(@_pending_deps)
160
+ return unless pending.any?
161
+
162
+ raise Asgard::Error,
163
+ "depends_on(#{pending.join(', ')}) declared without a following task definition"
164
+ end
165
+
166
+ def _check_undefined_deps!(all_task_names)
124
167
  undefined = _deps.values.flatten.uniq - all_task_names
125
- if undefined.any?
126
- raise Asgard::Error, "undefined task(s) in depends_on: #{undefined.sort.join(', ')}"
127
- end
168
+ return unless undefined.any?
169
+
170
+ raise Asgard::Error, "undefined task(s) in depends_on: #{undefined.sort.join(', ')}"
171
+ end
128
172
 
129
- _deps.each do |_task, stages|
173
+ def _check_dep_arities!
174
+ _deps.each_value do |stages|
130
175
  stages.flatten.each do |dep|
131
- meth = instance_method(dep.to_s) rescue nil
132
- next unless meth
176
+ meth = instance_method(dep.to_s)
133
177
  required = meth.parameters.count { |type, _| type == :req }
134
- if required > 0
135
- raise Asgard::Error,
136
- "task '#{dep}' has #{required} required argument(s) and cannot be used as a dependency"
137
- end
178
+ next unless required.positive?
179
+
180
+ raise Asgard::Error,
181
+ "task '#{dep}' has #{required} required argument(s) and cannot be used as a dependency"
138
182
  end
139
183
  end
184
+ end
140
185
 
186
+ def _build_and_sort_graph(all_task_names)
187
+ full_graph = all_task_names.to_h { |task| [task, _deps.fetch(task, []).flatten] }
141
188
  Dagwood::DependencyGraph.new(full_graph).order
142
- rescue TSort::Cyclic => e
143
- raise Asgard::CircularDependencyError, e.message
144
189
  end
145
190
 
191
+ public
192
+
146
193
  def method_added(method_name)
194
+ if @_pending_single_desc && !no_commands?
195
+ pending_desc = @_pending_single_desc
196
+ pending_opts = @_pending_single_desc_opts || {}
197
+ @_pending_single_desc = nil
198
+ @_pending_single_desc_opts = nil
199
+ desc(method_name.to_s, pending_desc, pending_opts)
200
+ end
201
+
147
202
  return super unless @usage
148
203
 
149
204
  pending = Array(@_pending_deps).dup
@@ -158,6 +213,13 @@ module Asgard
158
213
  end
159
214
  end
160
215
 
216
+ def help(command = nil, subcommand = false) # rubocop:disable Style/OptionalBooleanParameter
217
+ say self.class.header.join("\n\n") if self.class.header && command.nil?
218
+ say "\n"
219
+ super
220
+ say self.class.footer.join("\n\n") if self.class.footer && command.nil?
221
+ end
222
+
161
223
  no_commands do
162
224
  # Dispatch hook: resolves and runs all deps (in parallel where declared)
163
225
  # before executing the target command.
@@ -171,51 +233,67 @@ module Asgard
171
233
  $DEBUG = true if options[:debug]
172
234
  $VERBOSE = true if options[:verbose]
173
235
  target = command.name.to_sym
174
-
175
- should_run = self.class._ran_mutex.synchronize do
176
- if self.class._done.include?(target)
177
- false
178
- elsif self.class._running.include?(target)
179
- self.class._cond[target].wait(self.class._ran_mutex) until self.class._done.include?(target)
180
- false
181
- else
182
- self.class._running.add(target)
183
- true
184
- end
185
- end
186
- return unless should_run
236
+ return unless acquire_run_token(target)
187
237
 
188
238
  begin
189
- stages = self.class._deps[target]
190
- if stages&.any?
191
- graph = self.class._build_dep_graph(stages)
192
- groups = Dagwood::DependencyGraph.new(graph).parallel_order
193
-
194
- groups.each do |group|
195
- if group.size > 1
196
- threads = group.map { |task| Thread.new { _run_dep(task) } }
197
- errors = []
198
- threads.each { |t| begin; t.join; rescue => e; errors << e; end }
199
- raise errors.first if errors.any?
200
- else
201
- _run_dep(group.first)
202
- end
203
- end
204
- end
205
-
239
+ run_deps_for(target)
206
240
  command.run(self, *args)
207
241
  ensure
208
- self.class._ran_mutex.synchronize do
209
- self.class._done.add(target)
210
- self.class._cond[target].broadcast
211
- end
242
+ signal_done(target)
212
243
  end
213
244
  end
245
+ end
246
+
247
+ private
248
+
249
+ def acquire_run_token(target)
250
+ self.class._ran_mutex.synchronize do
251
+ if self.class._done.include?(target)
252
+ false
253
+ elsif self.class._running.include?(target)
254
+ self.class._cond[target].wait(self.class._ran_mutex) until self.class._done.include?(target)
255
+ false
256
+ else
257
+ self.class._running.add(target)
258
+ true
259
+ end
260
+ end
261
+ end
262
+
263
+ def run_deps_for(target)
264
+ stages = self.class._deps[target]
265
+ return unless stages&.any?
266
+
267
+ groups = Dagwood::DependencyGraph.new(self.class._build_dep_graph(stages)).parallel_order
268
+ groups.each { |group| run_dep_group(group) }
269
+ end
214
270
 
215
- def _run_dep(task)
216
- command = self.class.all_commands[task.to_s]
217
- invoke_command(command) if command
271
+ def run_dep_group(group)
272
+ if group.size > 1
273
+ threads = group.map { |task| Thread.new { run_dep(task) } }
274
+ errors = []
275
+ threads.each { |t| begin; t.join; rescue => e; errors << e; end }
276
+ if errors.size == 1
277
+ raise errors.first
278
+ elsif errors.any?
279
+ errors.each { |e| warn "asgard: #{e.message}" }
280
+ raise Asgard::Error, "#{errors.size} parallel dependencies failed"
281
+ end
282
+ else
283
+ run_dep(group.first)
218
284
  end
219
285
  end
286
+
287
+ def signal_done(target)
288
+ self.class._ran_mutex.synchronize do
289
+ self.class._done.add(target)
290
+ self.class._cond[target].broadcast
291
+ end
292
+ end
293
+
294
+ def run_dep(task)
295
+ command = self.class.all_commands[task.to_s]
296
+ invoke_command(command) if command
297
+ end
220
298
  end
221
299
  end