taski 0.8.2 → 0.9.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +47 -0
  3. data/README.md +65 -50
  4. data/docs/GUIDE.md +41 -56
  5. data/examples/README.md +10 -29
  6. data/examples/clean_demo.rb +25 -65
  7. data/examples/large_tree_demo.rb +356 -0
  8. data/examples/message_demo.rb +0 -1
  9. data/examples/progress_demo.rb +13 -24
  10. data/examples/reexecution_demo.rb +8 -44
  11. data/lib/taski/execution/execution_facade.rb +150 -0
  12. data/lib/taski/execution/executor.rb +156 -357
  13. data/lib/taski/execution/registry.rb +15 -19
  14. data/lib/taski/execution/scheduler.rb +161 -140
  15. data/lib/taski/execution/task_observer.rb +41 -0
  16. data/lib/taski/execution/task_output_router.rb +41 -58
  17. data/lib/taski/execution/task_wrapper.rb +123 -219
  18. data/lib/taski/execution/worker_pool.rb +238 -64
  19. data/lib/taski/logging.rb +105 -0
  20. data/lib/taski/progress/layout/base.rb +600 -0
  21. data/lib/taski/progress/layout/filters.rb +126 -0
  22. data/lib/taski/progress/layout/log.rb +27 -0
  23. data/lib/taski/progress/layout/simple.rb +166 -0
  24. data/lib/taski/progress/layout/tags.rb +76 -0
  25. data/lib/taski/progress/layout/theme_drop.rb +84 -0
  26. data/lib/taski/progress/layout/tree.rb +300 -0
  27. data/lib/taski/progress/theme/base.rb +224 -0
  28. data/lib/taski/progress/theme/compact.rb +58 -0
  29. data/lib/taski/progress/theme/default.rb +25 -0
  30. data/lib/taski/progress/theme/detail.rb +48 -0
  31. data/lib/taski/progress/theme/plain.rb +40 -0
  32. data/lib/taski/static_analysis/analyzer.rb +5 -17
  33. data/lib/taski/static_analysis/dependency_graph.rb +19 -1
  34. data/lib/taski/static_analysis/visitor.rb +1 -39
  35. data/lib/taski/task.rb +44 -58
  36. data/lib/taski/test_helper/errors.rb +1 -1
  37. data/lib/taski/test_helper.rb +21 -35
  38. data/lib/taski/version.rb +1 -1
  39. data/lib/taski.rb +60 -61
  40. data/sig/taski.rbs +194 -203
  41. metadata +31 -8
  42. data/examples/section_demo.rb +0 -195
  43. data/lib/taski/execution/base_progress_display.rb +0 -364
  44. data/lib/taski/execution/execution_context.rb +0 -390
  45. data/lib/taski/execution/plain_progress_display.rb +0 -76
  46. data/lib/taski/execution/simple_progress_display.rb +0 -206
  47. data/lib/taski/execution/tree_progress_display.rb +0 -643
  48. data/lib/taski/section.rb +0 -74
@@ -7,41 +7,29 @@ module Taski
7
7
  module StaticAnalysis
8
8
  class Analyzer
9
9
  # Analyzes a task class and returns its static dependencies.
10
- # For Task: dependencies detected from run method and called methods (SomeTask.method calls)
11
- # For Section: impl candidates detected from impl method and called methods (constants returned)
10
+ # Dependencies are detected from the run method and called methods (SomeTask.method calls).
12
11
  #
13
12
  # Static dependencies are used for:
14
13
  # - Tree display visualization
15
14
  # - Circular dependency detection
16
- # - Task execution (for Task only; Section resolves impl at runtime)
15
+ # - Task execution ordering
17
16
  #
18
17
  # @param task_class [Class] The task class to analyze
19
18
  # @return [Set<Class>] Set of task classes that are static dependencies
20
19
  def self.analyze(task_class)
21
- target_method = target_method_for(task_class)
22
- source_location = extract_method_location(task_class, target_method)
20
+ source_location = extract_method_location(task_class, :run)
23
21
  return Set.new unless source_location
24
22
 
25
23
  file_path, _line_number = source_location
26
24
  parse_result = Prism.parse_file(file_path)
27
25
 
28
- visitor = Visitor.new(task_class, target_method)
26
+ visitor = Visitor.new(task_class, :run)
29
27
  visitor.visit(parse_result.value)
30
28
  # Follow method calls to analyze dependencies in called methods
31
29
  visitor.follow_method_calls
32
30
  visitor.dependencies
33
31
  end
34
32
 
35
- # @param task_class [Class] The task class
36
- # @return [Symbol] The method name to analyze (:run for Task, :impl for Section)
37
- def self.target_method_for(task_class)
38
- if defined?(Taski::Section) && task_class < Taski::Section
39
- :impl
40
- else
41
- :run
42
- end
43
- end
44
-
45
33
  # @param task_class [Class] The task class
46
34
  # @param method_name [Symbol] The method name to extract location for
47
35
  # @return [Array<String, Integer>, nil] File path and line number, or nil
@@ -51,7 +39,7 @@ module Taski
51
39
  nil
52
40
  end
53
41
 
54
- private_class_method :target_method_for, :extract_method_location
42
+ private_class_method :extract_method_location
55
43
  end
56
44
  end
57
45
  end
@@ -13,7 +13,7 @@ module Taski
13
13
  @graph = {}
14
14
  end
15
15
 
16
- # Build dependency graph starting from root task class
16
+ # Build dependency graph starting from root task class using static analysis
17
17
  # @param root_task_class [Class] The root task class to analyze
18
18
  # @return [DependencyGraph] self for method chaining
19
19
  def build_from(root_task_class)
@@ -21,6 +21,14 @@ module Taski
21
21
  self
22
22
  end
23
23
 
24
+ # Build dependency graph using cached_dependencies (runtime) instead of AST analysis
25
+ # @param root_task_class [Class] The root task class
26
+ # @return [DependencyGraph] self for method chaining
27
+ def build_from_cached(root_task_class)
28
+ collect_cached_dependencies(root_task_class)
29
+ self
30
+ end
31
+
24
32
  # Get topologically sorted task classes (dependencies first)
25
33
  # @return [Array<Class>] Sorted task classes
26
34
  # @raise [TSort::Cyclic] If circular dependency is detected
@@ -85,6 +93,16 @@ module Taski
85
93
  collect_dependencies(dep_class)
86
94
  end
87
95
  end
96
+
97
+ # Recursively collect dependencies using cached_dependencies
98
+ def collect_cached_dependencies(task_class)
99
+ return if @graph.key?(task_class)
100
+
101
+ deps = task_class.cached_dependencies
102
+ @graph[task_class] = deps.to_set
103
+
104
+ deps.each { |dep| collect_cached_dependencies(dep) }
105
+ end
88
106
  end
89
107
  end
90
108
  end
@@ -25,8 +25,6 @@ module Taski
25
25
  @method_calls_to_follow = Set.new
26
26
  # Store method definitions found in the class for later analysis
27
27
  @class_method_defs = {}
28
- # Track if we're in an impl call chain (for Section constant detection)
29
- @in_impl_chain = false
30
28
  end
31
29
 
32
30
  def visit_class_node(node)
@@ -48,8 +46,6 @@ module Taski
48
46
  @analyzed_methods.add(node.name)
49
47
  @in_target_method = true
50
48
  @current_analyzing_method = node.name
51
- # Start impl chain when entering impl method
52
- @in_impl_chain = true if node.name == :impl && @target_method == :impl
53
49
  super
54
50
  @in_target_method = false
55
51
  @current_analyzing_method = nil
@@ -66,21 +62,7 @@ module Taski
66
62
  super
67
63
  end
68
64
 
69
- def visit_constant_read_node(node)
70
- # For Section.impl, detect constants as impl candidates (static dependencies)
71
- detect_impl_candidate(node) if in_impl_method?
72
- super
73
- end
74
-
75
- def visit_constant_path_node(node)
76
- # For Section.impl, detect constants as impl candidates (static dependencies)
77
- detect_impl_candidate(node) if in_impl_method?
78
- super
79
- end
80
-
81
65
  # After visiting, follow any method calls that need analysis
82
- # @in_impl_chain is preserved because methods called from impl should
83
- # also detect constants as impl candidates
84
66
  def follow_method_calls
85
67
  new_methods = @method_calls_to_follow - @analyzed_methods
86
68
  return if new_methods.empty?
@@ -90,8 +72,6 @@ module Taski
90
72
  @method_calls_to_follow.clear
91
73
 
92
74
  # Re-analyze the class methods
93
- # Preserve impl chain context: methods called from impl should continue
94
- # detecting constants as impl candidates
95
75
  # Set namespace path from target class name for constant resolution
96
76
  @current_namespace_path = @target_task_class.name.split("::")
97
77
 
@@ -140,10 +120,6 @@ module Taski
140
120
  @methods_to_analyze.include?(method_name) && !@analyzed_methods.include?(method_name)
141
121
  end
142
122
 
143
- def in_impl_method?
144
- @in_target_method && @in_impl_chain
145
- end
146
-
147
123
  # Detect method calls that should be followed (calls to methods in the same class)
148
124
  def detect_method_call_to_follow(node)
149
125
  # Only follow calls without explicit receiver (self.method or just method)
@@ -158,11 +134,6 @@ module Taski
158
134
  receiver.is_a?(Prism::SelfNode)
159
135
  end
160
136
 
161
- def detect_impl_candidate(node)
162
- constant_name = node.slice
163
- resolve_and_add_dependency(constant_name)
164
- end
165
-
166
137
  def detect_task_dependency(node)
167
138
  return unless node.receiver
168
139
 
@@ -206,16 +177,7 @@ module Taski
206
177
  end
207
178
 
208
179
  def valid_dependency?(klass)
209
- klass.is_a?(Class) &&
210
- (is_parallel_task?(klass) || is_parallel_section?(klass))
211
- end
212
-
213
- def is_parallel_task?(klass)
214
- defined?(Taski::Task) && klass < Taski::Task
215
- end
216
-
217
- def is_parallel_section?(klass)
218
- defined?(Taski::Section) && klass < Taski::Section
180
+ klass.is_a?(Class) && defined?(Taski::Task) && klass < Taski::Task
219
181
  end
220
182
  end
221
183
  end
data/lib/taski/task.rb CHANGED
@@ -1,8 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "stringio"
3
4
  require_relative "static_analysis/analyzer"
4
5
  require_relative "execution/registry"
5
6
  require_relative "execution/task_wrapper"
7
+ require_relative "progress/layout/tree"
8
+ require_relative "progress/theme/plain"
6
9
 
7
10
  module Taski
8
11
  # Base class for all tasks in the Taski framework.
@@ -49,26 +52,7 @@ module Taski
49
52
  @exported_methods ||= []
50
53
  end
51
54
 
52
- ##
53
- # Creates a task instance for manual execution control.
54
- # Use class methods (e.g., MyTask.run) for simple execution.
55
- # @param args [Hash] User-defined arguments accessible via Taski.args.
56
- # @param workers [Integer, nil] Number of worker threads for parallel execution.
57
- # @return [Execution::TaskWrapper] A new wrapper for this task.
58
- def new(args: {}, workers: nil)
59
- validate_workers!(workers)
60
- fresh_registry = Execution::Registry.new
61
- task_instance = allocate
62
- task_instance.__send__(:initialize)
63
- wrapper = Execution::TaskWrapper.new(
64
- task_instance,
65
- registry: fresh_registry,
66
- execution_context: Execution::ExecutionContext.current,
67
- args: args.merge(_workers: workers)
68
- )
69
- fresh_registry.register(self, wrapper)
70
- wrapper
71
- end
55
+ private :new
72
56
 
73
57
  ##
74
58
  # Returns cached static dependencies for this task class.
@@ -98,30 +82,21 @@ module Taski
98
82
  with_execution_setup(args: args, workers: workers) { |wrapper| wrapper.run }
99
83
  end
100
84
 
101
- ##
102
- # Executes the clean phase for the task and all its dependencies.
103
- # Clean is executed in reverse dependency order.
104
- # Creates a fresh registry each time for independent execution.
105
- # @param args [Hash] User-defined arguments accessible via Taski.args.
106
- # @param workers [Integer, nil] Number of worker threads for parallel execution.
107
- # Must be a positive integer or nil.
108
- # @raise [ArgumentError] If workers is not a positive integer or nil.
109
- def clean(args: {}, workers: nil)
110
- with_execution_setup(args: args, workers: workers) { |wrapper| wrapper.clean }
111
- end
112
-
113
85
  ##
114
86
  # Execute run followed by clean in a single operation.
115
- # If run fails, clean is still executed for resource release.
116
- # Creates a fresh registry for both operations to share.
87
+ # By default, clean is skipped when run fails.
88
+ # Use clean_on_failure: true to always execute clean for resource release.
89
+ # An optional block is executed between run and clean phases.
117
90
  #
118
91
  # @param args [Hash] User-defined arguments accessible via Taski.args.
119
92
  # @param workers [Integer, nil] Number of worker threads for parallel execution.
120
93
  # Must be a positive integer or nil.
94
+ # @param clean_on_failure [Boolean] When true, clean runs even if run raises (default: false).
121
95
  # @raise [ArgumentError] If workers is not a positive integer or nil.
122
96
  # @return [Object] The result of task execution
123
- def run_and_clean(args: {}, workers: nil)
124
- with_execution_setup(args: args, workers: workers) { |wrapper| wrapper.run_and_clean }
97
+ # @yield Optional block executed between run and clean phases
98
+ def run_and_clean(args: {}, workers: nil, clean_on_failure: false, &block)
99
+ with_execution_setup(args: args, workers: workers) { |wrapper| wrapper.run_and_clean(clean_on_failure: clean_on_failure, &block) }
125
100
  end
126
101
 
127
102
  ##
@@ -137,7 +112,13 @@ module Taski
137
112
  # Renders a static tree representation of the task dependencies.
138
113
  # @return [String] The rendered tree string.
139
114
  def tree
140
- Execution::TreeProgressDisplay.render_static_tree(self)
115
+ output = StringIO.new
116
+ theme = Progress::Theme::Plain.new
117
+ layout = Progress::Layout::Tree.new(output: output, theme: theme)
118
+ context = Execution::ExecutionFacade.new(root_task_class: self)
119
+ layout.context = context
120
+ layout.on_ready
121
+ layout.render_tree
141
122
  end
142
123
 
143
124
  private
@@ -170,7 +151,7 @@ module Taski
170
151
  wrapper = Execution::TaskWrapper.new(
171
152
  task_instance,
172
153
  registry: fresh_registry,
173
- execution_context: Execution::ExecutionContext.current
154
+ execution_facade: Execution::ExecutionFacade.current
174
155
  )
175
156
  fresh_registry.register(self, wrapper)
176
157
  wrapper
@@ -197,21 +178,28 @@ module Taski
197
178
  singleton_class.undef_method(method) if singleton_class.method_defined?(method)
198
179
 
199
180
  define_singleton_method(method) do
200
- # Check if running inside an execution with a registry
201
181
  registry = Taski.current_registry
202
182
  if registry
203
- # Inside execution - get or create wrapper in current registry
204
- # This handles both pre-registered dependencies and dynamic ones (like Section impl)
205
- wrapper = registry.get_or_create(self) do
206
- task_instance = allocate
207
- task_instance.__send__(:initialize)
208
- Execution::TaskWrapper.new(
209
- task_instance,
210
- registry: registry,
211
- execution_context: Execution::ExecutionContext.current
212
- )
183
+ if Thread.current[:taski_fiber_context]
184
+ # Fiber-based lazy resolution - yield to the worker loop
185
+ result = Fiber.yield([:need_dep, self, method])
186
+ if result.is_a?(Array) && result[0] == :_taski_error
187
+ raise result[1]
188
+ end
189
+ result
190
+ else
191
+ # Synchronous resolution (clean phase, outside Fiber)
192
+ wrapper = registry.get_or_create(self) do
193
+ task_instance = allocate
194
+ task_instance.__send__(:initialize)
195
+ Execution::TaskWrapper.new(
196
+ task_instance,
197
+ registry: registry,
198
+ execution_facade: Execution::ExecutionFacade.current
199
+ )
200
+ end
201
+ wrapper.get_exported_value(method)
213
202
  end
214
- wrapper.get_exported_value(method)
215
203
  else
216
204
  # Outside execution - fresh execution (top-level call)
217
205
  Taski.send(:with_env, root_task: self) do
@@ -305,18 +293,16 @@ module Taski
305
293
  # end
306
294
  # end
307
295
  def group(name)
308
- context = Execution::ExecutionContext.current
309
- context&.notify_group_started(self.class, name)
310
- start_time = Time.now
296
+ context = Execution::ExecutionFacade.current
297
+ phase = Thread.current[:taski_current_phase] || :run
298
+ context&.notify_group_started(self.class, name, phase: phase, timestamp: Time.now)
311
299
 
312
300
  begin
313
301
  result = yield
314
- duration_ms = ((Time.now - start_time) * 1000).round(0)
315
- context&.notify_group_completed(self.class, name, duration: duration_ms)
302
+ context&.notify_group_completed(self.class, name, phase: phase, timestamp: Time.now)
316
303
  result
317
- rescue => e
318
- duration_ms = ((Time.now - start_time) * 1000).round(0)
319
- context&.notify_group_completed(self.class, name, duration: duration_ms, error: e)
304
+ rescue
305
+ context&.notify_group_completed(self.class, name, phase: phase, timestamp: Time.now)
320
306
  raise
321
307
  end
322
308
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Taski
4
4
  module TestHelper
5
- # Raised when attempting to mock a class that is not a Taski::Task or Taski::Section subclass.
5
+ # Raised when attempting to mock a class that is not a Taski::Task subclass.
6
6
  class InvalidTaskError < ArgumentError
7
7
  end
8
8
 
@@ -19,37 +19,18 @@ module Taski
19
19
  # end
20
20
  module TestHelper
21
21
  # Module prepended to Task's singleton class to intercept define_class_accessor.
22
+ # Wraps the original accessor with a mock check.
22
23
  # @api private
23
24
  module TaskExtension
24
25
  def define_class_accessor(method)
25
- singleton_class.undef_method(method) if singleton_class.method_defined?(method)
26
+ super
27
+ original_method = self.method(method)
26
28
 
27
29
  define_singleton_method(method) do
28
- # Check for mock first
29
30
  mock = MockRegistry.mock_for(self)
30
31
  return mock.get_exported_value(method) if mock
31
32
 
32
- # No mock - call original implementation via registry lookup
33
- registry = Taski.current_registry
34
- if registry
35
- wrapper = registry.get_or_create(self) do
36
- task_instance = allocate
37
- task_instance.__send__(:initialize)
38
- Execution::TaskWrapper.new(
39
- task_instance,
40
- registry: registry,
41
- execution_context: Execution::ExecutionContext.current
42
- )
43
- end
44
- wrapper.get_exported_value(method)
45
- else
46
- Taski.send(:with_env, root_task: self) do
47
- Taski.send(:with_args, options: {}) do
48
- validate_no_circular_dependencies!
49
- fresh_wrapper.get_exported_value(method)
50
- end
51
- end
52
- end
33
+ original_method.call
53
34
  end
54
35
  end
55
36
  end
@@ -57,7 +38,7 @@ module Taski
57
38
  # Module prepended to Scheduler to skip dependencies of mocked tasks.
58
39
  # @api private
59
40
  module SchedulerExtension
60
- def build_dependency_graph(root_task_class)
41
+ def load_graph(dependency_graph, root_task_class)
61
42
  queue = [root_task_class]
62
43
 
63
44
  while (task_class = queue.shift)
@@ -65,24 +46,29 @@ module Taski
65
46
 
66
47
  # Mocked tasks have no dependencies (isolates indirect dependencies)
67
48
  mock = MockRegistry.mock_for(task_class)
68
- deps = mock ? Set.new : task_class.cached_dependencies
49
+ deps = mock ? Set.new : dependency_graph.dependencies_for(task_class)
69
50
  @dependencies[task_class] = deps.dup
70
51
  @task_states[task_class] = Taski::Execution::Scheduler::STATE_PENDING
52
+ @run_reverse_deps[task_class] ||= Set.new
71
53
 
72
- deps.each { |dep| queue << dep }
54
+ deps.each do |dep|
55
+ @run_reverse_deps[dep] ||= Set.new
56
+ @run_reverse_deps[dep].add(task_class)
57
+ log_dependency_resolved(task_class, dep)
58
+ queue << dep
59
+ end
73
60
  end
74
61
  end
75
62
  end
76
63
 
77
- # Module prepended to Executor to skip execution of mocked tasks.
64
+ # Module prepended to WorkerPool to skip execution of mocked tasks.
78
65
  # @api private
79
- module ExecutorExtension
80
- def execute_task(task_class, wrapper)
66
+ module WorkerPoolExtension
67
+ def drive_fiber(task_class, wrapper, queue)
81
68
  return if @registry.abort_requested?
82
69
 
83
- # Skip execution if task is mocked
84
70
  if MockRegistry.mock_for(task_class)
85
- wrapper.mark_completed(nil)
71
+ wrapper.mark_completed(nil) unless wrapper.completed?
86
72
  @completion_queue.push({task_class: task_class, wrapper: wrapper})
87
73
  return
88
74
  end
@@ -143,10 +129,10 @@ module Taski
143
129
  end
144
130
 
145
131
  # Registers a mock for a task class with specified return values.
146
- # @param task_class [Class] A Taski::Task or Taski::Section subclass
132
+ # @param task_class [Class] A Taski::Task subclass
147
133
  # @param values [Hash{Symbol => Object}] Method names mapped to return values
148
134
  # @return [MockWrapper] The created mock wrapper
149
- # @raise [InvalidTaskError] If task_class is not a Taski::Task/Section subclass
135
+ # @raise [InvalidTaskError] If task_class is not a Taski::Task subclass
150
136
  # @raise [InvalidMethodError] If any method name is not an exported method
151
137
  #
152
138
  # @example
@@ -213,7 +199,7 @@ module Taski
213
199
  return if valid
214
200
 
215
201
  raise InvalidTaskError,
216
- "Cannot mock #{task_class}: not a Taski::Task or Taski::Section subclass"
202
+ "Cannot mock #{task_class}: not a Taski::Task subclass"
217
203
  end
218
204
 
219
205
  def validate_exported_methods!(task_class, method_names)
@@ -243,4 +229,4 @@ end
243
229
  # Prepend extensions when test helper is loaded
244
230
  Taski::Task.singleton_class.prepend(Taski::TestHelper::TaskExtension)
245
231
  Taski::Execution::Scheduler.prepend(Taski::TestHelper::SchedulerExtension)
246
- Taski::Execution::Executor.prepend(Taski::TestHelper::ExecutorExtension)
232
+ Taski::Execution::WorkerPool.prepend(Taski::TestHelper::WorkerPoolExtension)
data/lib/taski/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Taski
4
- VERSION = "0.8.2"
4
+ VERSION = "0.9.0"
5
5
  end
data/lib/taski.rb CHANGED
@@ -5,16 +5,18 @@ require_relative "taski/static_analysis/analyzer"
5
5
  require_relative "taski/static_analysis/visitor"
6
6
  require_relative "taski/static_analysis/dependency_graph"
7
7
  require_relative "taski/execution/registry"
8
- require_relative "taski/execution/execution_context"
8
+ require_relative "taski/execution/task_observer"
9
+ require_relative "taski/execution/execution_facade"
9
10
  require_relative "taski/execution/task_wrapper"
10
11
  require_relative "taski/execution/scheduler"
11
12
  require_relative "taski/execution/worker_pool"
12
13
  require_relative "taski/execution/executor"
13
- require_relative "taski/execution/tree_progress_display"
14
- require_relative "taski/execution/simple_progress_display"
15
- require_relative "taski/execution/plain_progress_display"
14
+ require_relative "taski/progress/layout/log"
15
+ require_relative "taski/progress/layout/simple"
16
+ require_relative "taski/progress/layout/tree"
16
17
  require_relative "taski/args"
17
18
  require_relative "taski/env"
19
+ require_relative "taski/logging"
18
20
 
19
21
  module Taski
20
22
  class TaskAbortException < StandardError
@@ -47,8 +49,25 @@ module Taski
47
49
  end
48
50
 
49
51
  # Mixin for exception classes to enable transparent rescue matching with AggregateError.
50
- # When extended by an exception class, `rescue ThatError` will also match
51
- # an AggregateError that contains ThatError.
52
+ #
53
+ # == How it works
54
+ #
55
+ # Ruby's +rescue+ clause uses the +===+ operator to match exceptions.
56
+ # This module overrides +===+ so that when the rescued class is compared
57
+ # against an AggregateError, it checks whether the AggregateError *contains*
58
+ # an error of that type. This means +rescue TaskError+ will match an
59
+ # AggregateError that wraps one or more TaskError instances, even though
60
+ # AggregateError does not inherit from TaskError.
61
+ #
62
+ # == Why this exists
63
+ #
64
+ # Taski's Executor always raises AggregateError (even for a single failure)
65
+ # to provide a uniform error interface. Without AggregateAware, callers would
66
+ # always need to rescue AggregateError and inspect its contents. With this
67
+ # module, callers can rescue the specific error class they care about:
68
+ #
69
+ # rescue MyTask::Error => e # matches AggregateError containing MyTask::Error
70
+ # rescue Taski::TaskError # matches AggregateError containing any TaskError
52
71
  #
53
72
  # @note TaskError and all TaskClass::Error classes already extend this module.
54
73
  #
@@ -141,6 +160,19 @@ module Taski
141
160
  @args_monitor = Monitor.new
142
161
  @env_monitor = Monitor.new
143
162
  @message_monitor = Monitor.new
163
+ @logger_monitor = Monitor.new
164
+
165
+ # Get the current logger for structured logging
166
+ # @return [Logger, nil] The configured logger or nil (disabled by default)
167
+ def self.logger
168
+ @logger_monitor.synchronize { @logger }
169
+ end
170
+
171
+ # Set the logger for structured logging
172
+ # @param logger [Logger, nil] A Ruby Logger instance or nil to disable logging
173
+ def self.logger=(logger)
174
+ @logger_monitor.synchronize { @logger = logger }
175
+ end
144
176
 
145
177
  # Get the current runtime arguments
146
178
  # @return [Args, nil] The current args or nil if no task is running
@@ -233,66 +265,34 @@ module Taski
233
265
  reset_args! if created_args
234
266
  end
235
267
 
236
- # Progress display is enabled by default (tree-style).
237
- # Environment variables:
238
- # - TASKI_PROGRESS_DISABLE=1: Disable progress display entirely
239
- # - TASKI_PROGRESS_MODE=simple|tree: Set display mode (default: tree)
240
- def self.progress_display
241
- return nil if progress_disabled?
242
- @progress_display ||= create_progress_display
243
- end
244
-
245
- def self.progress_disabled?
246
- ENV["TASKI_PROGRESS_DISABLE"] == "1"
247
- end
268
+ NOT_CONFIGURED = Object.new.freeze
269
+ PROGRESS_MONITOR = Monitor.new
270
+ @progress_display = NOT_CONFIGURED
248
271
 
249
- # Get the current progress mode (:tree or :simple)
250
- # Environment variable TASKI_PROGRESS_MODE takes precedence over code settings.
251
- # @return [Symbol] The current progress mode
252
- def self.progress_mode
253
- if ENV["TASKI_PROGRESS_MODE"]
254
- progress_mode_from_env
255
- else
256
- @progress_mode || :tree
272
+ def self.progress_display
273
+ PROGRESS_MONITOR.synchronize do
274
+ if @progress_display.equal?(NOT_CONFIGURED)
275
+ @progress_display = Progress::Layout::Simple.new
276
+ end
277
+ @progress_display
257
278
  end
258
279
  end
259
280
 
260
- # Set the progress mode (:tree or :simple)
261
- # @param mode [Symbol] The mode to use (:tree or :simple)
262
- def self.progress_mode=(mode)
263
- @progress_mode = mode.to_sym
264
- # Reset display so it will be recreated with new mode
265
- @progress_display&.stop
266
- @progress_display = nil
267
- end
268
-
269
- def self.reset_progress_display!
270
- @progress_display&.stop
271
- @progress_display = nil
272
- @progress_mode = nil
273
- end
274
-
275
- # @api private
276
- def self.create_progress_display
277
- case progress_mode
278
- when :simple
279
- Execution::SimpleProgressDisplay.new
280
- when :plain
281
- Execution::PlainProgressDisplay.new
282
- else
283
- Execution::TreeProgressDisplay.new
281
+ def self.progress_display=(display)
282
+ PROGRESS_MONITOR.synchronize do
283
+ unless @progress_display.equal?(NOT_CONFIGURED)
284
+ @progress_display.stop if @progress_display.respond_to?(:stop)
285
+ end
286
+ @progress_display = display
284
287
  end
285
288
  end
286
289
 
287
- # @api private
288
- def self.progress_mode_from_env
289
- case ENV["TASKI_PROGRESS_MODE"]
290
- when "simple"
291
- :simple
292
- when "plain"
293
- :plain
294
- else
295
- :tree
290
+ def self.reset_progress_display!
291
+ PROGRESS_MONITOR.synchronize do
292
+ unless @progress_display.equal?(NOT_CONFIGURED)
293
+ @progress_display.stop if @progress_display.respond_to?(:stop)
294
+ end
295
+ @progress_display = NOT_CONFIGURED
296
296
  end
297
297
  end
298
298
 
@@ -323,6 +323,5 @@ module Taski
323
323
  end
324
324
  end
325
325
 
326
- # Load Task and Section after Taski module is defined (they depend on TaskError)
326
+ # Load Task after Taski module is defined (it depends on TaskError)
327
327
  require_relative "taski/task"
328
- require_relative "taski/section"