taski 0.4.2 → 0.7.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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +50 -0
  3. data/README.md +51 -33
  4. data/Steepfile +1 -0
  5. data/docs/GUIDE.md +340 -0
  6. data/examples/README.md +68 -20
  7. data/examples/{context_demo.rb → args_demo.rb} +27 -27
  8. data/examples/clean_demo.rb +204 -0
  9. data/examples/data_pipeline_demo.rb +3 -3
  10. data/examples/group_demo.rb +113 -0
  11. data/examples/nested_section_demo.rb +161 -0
  12. data/examples/parallel_progress_demo.rb +1 -1
  13. data/examples/reexecution_demo.rb +93 -80
  14. data/examples/system_call_demo.rb +56 -0
  15. data/examples/tree_progress_demo.rb +164 -0
  16. data/lib/taski/{context.rb → args.rb} +3 -3
  17. data/lib/taski/execution/execution_context.rb +379 -0
  18. data/lib/taski/execution/executor.rb +538 -0
  19. data/lib/taski/execution/registry.rb +26 -2
  20. data/lib/taski/execution/scheduler.rb +308 -0
  21. data/lib/taski/execution/task_output_pipe.rb +42 -0
  22. data/lib/taski/execution/task_output_router.rb +216 -0
  23. data/lib/taski/execution/task_wrapper.rb +295 -146
  24. data/lib/taski/execution/tree_progress_display.rb +793 -0
  25. data/lib/taski/execution/worker_pool.rb +104 -0
  26. data/lib/taski/section.rb +23 -0
  27. data/lib/taski/static_analysis/analyzer.rb +4 -2
  28. data/lib/taski/static_analysis/visitor.rb +86 -5
  29. data/lib/taski/task.rb +223 -120
  30. data/lib/taski/version.rb +1 -1
  31. data/lib/taski.rb +147 -28
  32. data/sig/taski.rbs +310 -67
  33. metadata +17 -8
  34. data/docs/advanced-features.md +0 -625
  35. data/docs/api-guide.md +0 -509
  36. data/docs/error-handling.md +0 -684
  37. data/lib/taski/execution/coordinator.rb +0 -63
  38. data/lib/taski/execution/parallel_progress_display.rb +0 -201
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "etc"
4
+
5
+ module Taski
6
+ module Execution
7
+ # WorkerPool manages a pool of worker threads that execute tasks.
8
+ # It provides methods to start, stop, and enqueue tasks for execution.
9
+ #
10
+ # == Responsibilities
11
+ #
12
+ # - Manage worker thread lifecycle (start, shutdown)
13
+ # - Distribute tasks to worker threads via Queue
14
+ # - Execute tasks via callback provided by Executor
15
+ #
16
+ # == API
17
+ #
18
+ # - {#start} - Start all worker threads
19
+ # - {#enqueue} - Add a task to the execution queue
20
+ # - {#shutdown} - Gracefully shutdown all worker threads
21
+ # - {#execution_queue} - Access the underlying Queue (for testing)
22
+ #
23
+ # == Thread Safety
24
+ #
25
+ # WorkerPool uses Queue for thread-safe task distribution.
26
+ # The Queue handles synchronization between the main thread
27
+ # (which enqueues tasks) and worker threads (which pop tasks).
28
+ class WorkerPool
29
+ attr_reader :execution_queue
30
+
31
+ # @param registry [Registry] The task registry for thread tracking
32
+ # @param worker_count [Integer, nil] Number of worker threads (defaults to CPU count)
33
+ # @param on_execute [Proc] Callback to execute a task, receives (task_class, wrapper)
34
+ def initialize(registry:, worker_count: nil, &on_execute)
35
+ @worker_count = worker_count || default_worker_count
36
+ @registry = registry
37
+ @on_execute = on_execute
38
+ @execution_queue = Queue.new
39
+ @workers = []
40
+ end
41
+
42
+ # Start all worker threads.
43
+ def start
44
+ @worker_count.times do
45
+ worker = Thread.new { worker_loop }
46
+ @workers << worker
47
+ @registry.register_thread(worker)
48
+ end
49
+ end
50
+
51
+ # Enqueue a task for execution.
52
+ #
53
+ # @param task_class [Class] The task class to execute
54
+ # @param wrapper [TaskWrapper] The task wrapper
55
+ def enqueue(task_class, wrapper)
56
+ @execution_queue.push({task_class: task_class, wrapper: wrapper})
57
+ debug_log("Enqueued: #{task_class}")
58
+ end
59
+
60
+ # Shutdown all worker threads gracefully.
61
+ def shutdown
62
+ enqueue_shutdown_signals
63
+ @workers.each(&:join)
64
+ end
65
+
66
+ # Enqueue shutdown signals for all workers.
67
+ def enqueue_shutdown_signals
68
+ @worker_count.times { @execution_queue.push(:shutdown) }
69
+ end
70
+
71
+ private
72
+
73
+ def default_worker_count
74
+ Etc.nprocessors.clamp(2, 8)
75
+ end
76
+
77
+ def worker_loop
78
+ loop do
79
+ work_item = @execution_queue.pop
80
+ break if work_item == :shutdown
81
+
82
+ task_class = work_item[:task_class]
83
+ wrapper = work_item[:wrapper]
84
+
85
+ debug_log("Worker executing: #{task_class}")
86
+
87
+ begin
88
+ @on_execute.call(task_class, wrapper)
89
+ rescue => e
90
+ # Log error but don't crash the worker thread.
91
+ # Task-level errors are handled in the execute callback.
92
+ # This catches unexpected errors in the callback itself.
93
+ warn "[WorkerPool] Unexpected error executing #{task_class}: #{e.message}"
94
+ end
95
+ end
96
+ end
97
+
98
+ def debug_log(message)
99
+ return unless ENV["TASKI_DEBUG"]
100
+ puts "[WorkerPool] #{message}"
101
+ end
102
+ end
103
+ end
104
+ end
data/lib/taski/section.rb CHANGED
@@ -24,6 +24,12 @@ module Taski
24
24
  raise "Section #{self.class} does not have an implementation. Override 'impl' method."
25
25
  end
26
26
 
27
+ # Register runtime dependency for clean phase (before register_impl_selection)
28
+ register_runtime_dependency(implementation_class)
29
+
30
+ # Register selected impl for progress display
31
+ register_impl_selection(implementation_class)
32
+
27
33
  apply_interface_to_implementation(implementation_class)
28
34
 
29
35
  self.class.exported_methods.each do |method|
@@ -40,6 +46,23 @@ module Taski
40
46
 
41
47
  private
42
48
 
49
+ # Register the selected implementation as a runtime dependency.
50
+ # This allows the clean phase to include the dynamically selected impl.
51
+ # Handles nil ExecutionContext gracefully.
52
+ #
53
+ # @param impl_class [Class] The selected implementation class
54
+ def register_runtime_dependency(impl_class)
55
+ context = Execution::ExecutionContext.current
56
+ context&.register_runtime_dependency(self.class, impl_class)
57
+ end
58
+
59
+ def register_impl_selection(implementation_class)
60
+ context = Execution::ExecutionContext.current
61
+ return unless context
62
+
63
+ context.notify_section_impl_selected(self.class, implementation_class)
64
+ end
65
+
43
66
  # @param implementation_class [Class] The implementation task class
44
67
  def apply_interface_to_implementation(implementation_class)
45
68
  interface_methods = self.class.exported_methods
@@ -7,8 +7,8 @@ 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 (SomeTask.method calls)
11
- # For Section: impl candidates detected from impl method (constants returned)
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)
12
12
  #
13
13
  # Static dependencies are used for:
14
14
  # - Tree display visualization
@@ -27,6 +27,8 @@ module Taski
27
27
 
28
28
  visitor = Visitor.new(task_class, target_method)
29
29
  visitor.visit(parse_result.value)
30
+ # Follow method calls to analyze dependencies in called methods
31
+ visitor.follow_method_calls
30
32
  visitor.dependencies
31
33
  end
32
34
 
@@ -9,17 +9,34 @@ module Taski
9
9
 
10
10
  # @param target_task_class [Class] The task class to analyze
11
11
  # @param target_method [Symbol] The method name to analyze (:run or :impl)
12
- def initialize(target_task_class, target_method = :run)
12
+ # @param methods_to_analyze [Set<Symbol>] Set of method names to analyze (for following calls)
13
+ def initialize(target_task_class, target_method = :run, methods_to_analyze = nil)
13
14
  super()
14
15
  @target_task_class = target_task_class
15
16
  @target_method = target_method
16
17
  @dependencies = Set.new
17
18
  @in_target_method = false
18
19
  @current_namespace_path = []
20
+ # Methods to analyze: starts with just the target method, grows as we find calls
21
+ @methods_to_analyze = methods_to_analyze || Set.new([@target_method])
22
+ # Track which methods we've already analyzed to prevent infinite loops
23
+ @analyzed_methods = Set.new
24
+ # Collect method calls made within analyzed methods (for following)
25
+ @method_calls_to_follow = Set.new
26
+ # Store method definitions found in the class for later analysis
27
+ @class_method_defs = {}
28
+ # Track if we're in an impl call chain (for Section constant detection)
29
+ @in_impl_chain = false
19
30
  end
20
31
 
21
32
  def visit_class_node(node)
22
- within_namespace(extract_constant_name(node.constant_path)) { super }
33
+ within_namespace(extract_constant_name(node.constant_path)) do
34
+ if in_target_class?
35
+ # First pass: collect all method definitions in the target class
36
+ collect_method_definitions(node)
37
+ end
38
+ super
39
+ end
23
40
  end
24
41
 
25
42
  def visit_module_node(node)
@@ -27,17 +44,25 @@ module Taski
27
44
  end
28
45
 
29
46
  def visit_def_node(node)
30
- if node.name == @target_method && in_target_class?
47
+ if in_target_class? && should_analyze_method?(node.name)
48
+ @analyzed_methods.add(node.name)
31
49
  @in_target_method = true
50
+ @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
32
53
  super
33
54
  @in_target_method = false
55
+ @current_analyzing_method = nil
34
56
  else
35
57
  super
36
58
  end
37
59
  end
38
60
 
39
61
  def visit_call_node(node)
40
- detect_task_dependency(node) if @in_target_method
62
+ if @in_target_method
63
+ detect_task_dependency(node)
64
+ detect_method_call_to_follow(node)
65
+ end
41
66
  super
42
67
  end
43
68
 
@@ -53,8 +78,46 @@ module Taski
53
78
  super
54
79
  end
55
80
 
81
+ # 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
+ def follow_method_calls
85
+ new_methods = @method_calls_to_follow - @analyzed_methods
86
+ return if new_methods.empty?
87
+
88
+ # Add new methods to analyze
89
+ @methods_to_analyze.merge(new_methods)
90
+ @method_calls_to_follow.clear
91
+
92
+ # Re-analyze the class methods
93
+ # Preserve impl chain context: methods called from impl should continue
94
+ # detecting constants as impl candidates
95
+ @class_method_defs.each do |method_name, method_node|
96
+ next unless new_methods.include?(method_name)
97
+
98
+ @analyzed_methods.add(method_name)
99
+ @in_target_method = true
100
+ @current_analyzing_method = method_name
101
+ visit(method_node)
102
+ @in_target_method = false
103
+ @current_analyzing_method = nil
104
+ end
105
+
106
+ # Recursively follow any new calls discovered
107
+ follow_method_calls
108
+ end
109
+
56
110
  private
57
111
 
112
+ # Collect all method definitions in the target class for later analysis
113
+ def collect_method_definitions(class_node)
114
+ class_node.body&.body&.each do |node|
115
+ if node.is_a?(Prism::DefNode)
116
+ @class_method_defs[node.name] = node
117
+ end
118
+ end
119
+ end
120
+
58
121
  def within_namespace(name)
59
122
  @current_namespace_path.push(name)
60
123
  yield
@@ -70,8 +133,26 @@ module Taski
70
133
  node.slice
71
134
  end
72
135
 
136
+ def should_analyze_method?(method_name)
137
+ @methods_to_analyze.include?(method_name) && !@analyzed_methods.include?(method_name)
138
+ end
139
+
73
140
  def in_impl_method?
74
- @in_target_method && @target_method == :impl
141
+ @in_target_method && @in_impl_chain
142
+ end
143
+
144
+ # Detect method calls that should be followed (calls to methods in the same class)
145
+ def detect_method_call_to_follow(node)
146
+ # Only follow calls without explicit receiver (self.method or just method)
147
+ return if node.receiver && !self_receiver?(node.receiver)
148
+
149
+ method_name = node.name
150
+ # Mark this method for later analysis if it's defined in the class
151
+ @method_calls_to_follow.add(method_name) if @class_method_defs.key?(method_name)
152
+ end
153
+
154
+ def self_receiver?(receiver)
155
+ receiver.is_a?(Prism::SelfNode)
75
156
  end
76
157
 
77
158
  def detect_impl_candidate(node)