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.
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
- # The subcommand class inherits from Tasks so it has access to sh, shebang,
5
- # 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
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 "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
 
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