taski 0.5.0 → 0.7.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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +50 -0
  3. data/README.md +168 -21
  4. data/docs/GUIDE.md +394 -0
  5. data/examples/README.md +65 -17
  6. data/examples/{context_demo.rb → args_demo.rb} +27 -27
  7. data/examples/clean_demo.rb +204 -0
  8. data/examples/data_pipeline_demo.rb +1 -1
  9. data/examples/group_demo.rb +113 -0
  10. data/examples/large_tree_demo.rb +519 -0
  11. data/examples/reexecution_demo.rb +93 -80
  12. data/examples/simple_progress_demo.rb +80 -0
  13. data/examples/system_call_demo.rb +56 -0
  14. data/lib/taski/{context.rb → args.rb} +3 -3
  15. data/lib/taski/execution/base_progress_display.rb +348 -0
  16. data/lib/taski/execution/execution_context.rb +383 -0
  17. data/lib/taski/execution/executor.rb +405 -134
  18. data/lib/taski/execution/plain_progress_display.rb +76 -0
  19. data/lib/taski/execution/registry.rb +17 -1
  20. data/lib/taski/execution/scheduler.rb +308 -0
  21. data/lib/taski/execution/simple_progress_display.rb +173 -0
  22. data/lib/taski/execution/task_output_pipe.rb +42 -0
  23. data/lib/taski/execution/task_output_router.rb +287 -0
  24. data/lib/taski/execution/task_wrapper.rb +215 -52
  25. data/lib/taski/execution/tree_progress_display.rb +349 -212
  26. data/lib/taski/execution/worker_pool.rb +104 -0
  27. data/lib/taski/section.rb +16 -3
  28. data/lib/taski/static_analysis/visitor.rb +3 -0
  29. data/lib/taski/task.rb +218 -37
  30. data/lib/taski/test_helper/errors.rb +13 -0
  31. data/lib/taski/test_helper/minitest.rb +38 -0
  32. data/lib/taski/test_helper/mock_registry.rb +51 -0
  33. data/lib/taski/test_helper/mock_wrapper.rb +46 -0
  34. data/lib/taski/test_helper/rspec.rb +38 -0
  35. data/lib/taski/test_helper.rb +214 -0
  36. data/lib/taski/version.rb +1 -1
  37. data/lib/taski.rb +211 -23
  38. data/sig/taski.rbs +207 -27
  39. metadata +25 -8
  40. data/docs/advanced-features.md +0 -625
  41. data/docs/api-guide.md +0 -509
  42. data/docs/error-handling.md +0 -684
  43. data/examples/section_progress_demo.rb +0 -78
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_progress_display"
4
+
5
+ module Taski
6
+ module Execution
7
+ # PlainProgressDisplay provides plain text output without terminal escape codes.
8
+ # Designed for non-TTY environments (CI, log files, piped output).
9
+ #
10
+ # Output format:
11
+ # [START] TaskName
12
+ # [DONE] TaskName (123.4ms)
13
+ # [FAIL] TaskName: Error message
14
+ #
15
+ # Enable with: TASKI_PROGRESS_MODE=plain
16
+ class PlainProgressDisplay < BaseProgressDisplay
17
+ def initialize(output: $stderr)
18
+ super
19
+ @output.sync = true if @output.respond_to?(:sync=)
20
+ end
21
+
22
+ protected
23
+
24
+ # Template method: Called when a section impl is registered
25
+ def on_section_impl_registered(_section_class, impl_class)
26
+ @tasks[impl_class] ||= TaskProgress.new
27
+ end
28
+
29
+ # Template method: Called when a task state is updated
30
+ def on_task_updated(task_class, state, duration, error)
31
+ case state
32
+ when :running
33
+ @output.puts "[START] #{short_name(task_class)}"
34
+ when :completed
35
+ duration_str = duration ? " (#{format_duration(duration)})" : ""
36
+ @output.puts "[DONE] #{short_name(task_class)}#{duration_str}"
37
+ when :failed
38
+ error_msg = error ? ": #{error.message}" : ""
39
+ @output.puts "[FAIL] #{short_name(task_class)}#{error_msg}"
40
+ when :cleaning
41
+ @output.puts "[CLEAN] #{short_name(task_class)}"
42
+ when :clean_completed
43
+ duration_str = duration ? " (#{format_duration(duration)})" : ""
44
+ @output.puts "[CLEAN DONE] #{short_name(task_class)}#{duration_str}"
45
+ when :clean_failed
46
+ error_msg = error ? ": #{error.message}" : ""
47
+ @output.puts "[CLEAN FAIL] #{short_name(task_class)}#{error_msg}"
48
+ end
49
+ @output.flush
50
+ end
51
+
52
+ # Template method: Called when display starts
53
+ def on_start
54
+ if @root_task_class
55
+ @output.puts "[TASKI] Starting #{short_name(@root_task_class)}"
56
+ @output.flush
57
+ end
58
+ end
59
+
60
+ # Template method: Called when display stops
61
+ def on_stop
62
+ total_duration = @start_time ? ((Time.now - @start_time) * 1000).to_i : 0
63
+ completed = @tasks.values.count { |t| t.run_state == :completed }
64
+ failed = @tasks.values.count { |t| t.run_state == :failed }
65
+ total = @tasks.size
66
+
67
+ if failed > 0
68
+ @output.puts "[TASKI] Failed: #{failed}/#{total} tasks (#{total_duration}ms)"
69
+ else
70
+ @output.puts "[TASKI] Completed: #{completed}/#{total} tasks (#{total_duration}ms)"
71
+ end
72
+ @output.flush
73
+ end
74
+ end
75
+ end
76
+ end
@@ -16,7 +16,9 @@ module Taski
16
16
  # @yield Block to create the task instance if it doesn't exist
17
17
  # @return [Object] The task instance
18
18
  def get_or_create(task_class)
19
- @tasks[task_class] ||= yield
19
+ @monitor.synchronize do
20
+ @tasks[task_class] ||= yield
21
+ end
20
22
  end
21
23
 
22
24
  # @param task_class [Class] The task class
@@ -61,6 +63,20 @@ module Taski
61
63
  @monitor.synchronize { @abort_requested }
62
64
  end
63
65
 
66
+ # @return [Array<TaskWrapper>] All wrappers that have errors
67
+ def failed_wrappers
68
+ @monitor.synchronize do
69
+ @tasks.values.select { |w| w.error }
70
+ end
71
+ end
72
+
73
+ # @return [Array<TaskWrapper>] All wrappers that have clean errors
74
+ def failed_clean_wrappers
75
+ @monitor.synchronize do
76
+ @tasks.values.select { |w| w.clean_error }
77
+ end
78
+ end
79
+
64
80
  # @param task_class [Class] The task class to run
65
81
  # @param exported_methods [Array<Symbol>] Methods to call to trigger execution
66
82
  # @return [Object] The result of the task execution
@@ -0,0 +1,308 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Taski
4
+ module Execution
5
+ # Scheduler manages task dependency state and determines execution order.
6
+ # It tracks which tasks are pending, enqueued, or completed, and provides
7
+ # methods to determine which tasks are ready to execute.
8
+ #
9
+ # == Responsibilities
10
+ #
11
+ # - Build dependency graph from root task via static analysis
12
+ # - Track task states: pending, enqueued, completed
13
+ # - Determine which tasks are ready to execute (all dependencies completed)
14
+ # - Provide next_ready_tasks for the Executor's event loop
15
+ # - Build reverse dependency graph for clean operations
16
+ # - Track clean states independently from run states
17
+ # - Provide next_ready_clean_tasks for reverse dependency order execution
18
+ #
19
+ # == API
20
+ #
21
+ # Run operations:
22
+ # - {#build_dependency_graph} - Initialize dependency graph from root task
23
+ # - {#next_ready_tasks} - Get tasks ready for execution
24
+ # - {#mark_enqueued} - Mark task as sent to worker pool
25
+ # - {#mark_completed} - Mark task as finished
26
+ # - {#completed?} - Check if task is completed
27
+ # - {#running_tasks?} - Check if any tasks are currently executing
28
+ #
29
+ # Clean operations:
30
+ # - {#build_reverse_dependency_graph} - Build reverse graph for clean order
31
+ # - {#next_ready_clean_tasks} - Get tasks ready for clean (reverse order)
32
+ # - {#mark_clean_enqueued} - Mark task as sent for clean
33
+ # - {#mark_clean_completed} - Mark task as clean finished
34
+ # - {#clean_completed?} - Check if task clean is completed
35
+ # - {#running_clean_tasks?} - Check if any clean tasks are currently executing
36
+ #
37
+ # == Thread Safety
38
+ #
39
+ # Scheduler is only accessed from the main thread in Executor,
40
+ # so no synchronization is needed. The Executor serializes all
41
+ # access to the Scheduler through its event loop.
42
+ class Scheduler
43
+ # Task execution states
44
+ STATE_PENDING = :pending
45
+ STATE_ENQUEUED = :enqueued
46
+ STATE_COMPLETED = :completed
47
+
48
+ # Clean execution states (independent from run states)
49
+ CLEAN_STATE_PENDING = :clean_pending
50
+ CLEAN_STATE_ENQUEUED = :clean_enqueued
51
+ CLEAN_STATE_COMPLETED = :clean_completed
52
+
53
+ ##
54
+ # Initializes internal data structures used to track normal and clean task execution.
55
+ #
56
+ # Sets up:
57
+ # - @dependencies: map from task class to its dependency task classes.
58
+ # - @task_states: map from task class to its normal execution state.
59
+ # - @completed_tasks: set of task classes that have completed normal execution.
60
+ # - @reverse_dependencies: map from task class to dependent task classes (used for clean ordering).
61
+ # - @clean_task_states: map from task class to its clean execution state.
62
+ # - @clean_completed_tasks: set of task classes that have completed clean execution.
63
+ def initialize
64
+ # Run execution state
65
+ @dependencies = {}
66
+ @task_states = {}
67
+ @completed_tasks = Set.new
68
+
69
+ # Clean execution state (independent tracking)
70
+ @reverse_dependencies = {}
71
+ @clean_task_states = {}
72
+ @clean_completed_tasks = Set.new
73
+ end
74
+
75
+ # Build dependency graph by traversing from root task.
76
+ # Populates internal state with all tasks and their dependencies.
77
+ #
78
+ # @param root_task_class [Class] The root task class to start from
79
+ def build_dependency_graph(root_task_class)
80
+ # @type var queue: Array[singleton(Taski::Task)]
81
+ queue = [root_task_class]
82
+
83
+ while (task_class = queue.shift)
84
+ next if @task_states.key?(task_class)
85
+
86
+ deps = task_class.cached_dependencies
87
+ @dependencies[task_class] = deps.dup
88
+ @task_states[task_class] = STATE_PENDING
89
+
90
+ deps.each { |dep| queue << dep }
91
+ end
92
+ end
93
+
94
+ # Get all tasks that are ready to execute.
95
+ # A task is ready when all its dependencies are completed.
96
+ #
97
+ # @return [Array<Class>] Array of task classes ready for execution
98
+ def next_ready_tasks
99
+ ready = []
100
+ @task_states.each_key do |task_class|
101
+ next unless @task_states[task_class] == STATE_PENDING
102
+ next unless ready_to_execute?(task_class)
103
+ ready << task_class
104
+ end
105
+ ready
106
+ end
107
+
108
+ # Mark a task as enqueued for execution.
109
+ #
110
+ # @param task_class [Class] The task class to mark
111
+ def mark_enqueued(task_class)
112
+ @task_states[task_class] = STATE_ENQUEUED
113
+ end
114
+
115
+ # Mark a task as completed.
116
+ #
117
+ # @param task_class [Class] The task class to mark
118
+ def mark_completed(task_class)
119
+ @task_states[task_class] = STATE_COMPLETED
120
+ @completed_tasks.add(task_class)
121
+ end
122
+
123
+ # Check if a task is completed.
124
+ #
125
+ # @param task_class [Class] The task class to check
126
+ # @return [Boolean] true if the task is completed
127
+ def completed?(task_class)
128
+ @completed_tasks.include?(task_class)
129
+ end
130
+
131
+ # Check if there are any running (enqueued) tasks.
132
+ #
133
+ ##
134
+ # Indicates whether any tasks are currently enqueued for execution.
135
+ # @return [Boolean] `true` if any task is enqueued, `false` otherwise.
136
+ def running_tasks?
137
+ @task_states.values.any? { |state| state == STATE_ENQUEUED }
138
+ end
139
+
140
+ # ========================================
141
+ # Runtime Dependency Merging
142
+ # ========================================
143
+
144
+ # Merge runtime dependencies into the dependency graph.
145
+ # Used to incorporate dynamically selected dependencies (e.g., Section implementations)
146
+ # that were determined during the run phase.
147
+ #
148
+ # This method also updates reverse dependencies if they exist (for clean operations).
149
+ # This method is idempotent - calling it multiple times with the same data is safe.
150
+ #
151
+ # @param runtime_deps [Hash{Class => Set<Class>}] Runtime dependencies from ExecutionContext
152
+ def merge_runtime_dependencies(runtime_deps)
153
+ runtime_deps.each do |from_class, to_classes|
154
+ # Ensure the from_class exists in the graph
155
+ @dependencies[from_class] ||= Set.new
156
+ @task_states[from_class] ||= STATE_PENDING
157
+
158
+ to_classes.each do |to_class|
159
+ # Add the dependency relationship
160
+ @dependencies[from_class].add(to_class)
161
+
162
+ # Add the to_class to the graph if not present
163
+ unless @dependencies.key?(to_class)
164
+ @dependencies[to_class] = to_class.cached_dependencies
165
+ @task_states[to_class] = STATE_PENDING
166
+ end
167
+
168
+ # Update reverse dependencies if they exist (for clean operations)
169
+ # If A depends on B (from_class→to_class), then B→[A] in reverse graph
170
+ if @reverse_dependencies.any?
171
+ @reverse_dependencies[to_class] ||= Set.new
172
+ @reverse_dependencies[to_class].add(from_class)
173
+
174
+ # Ensure to_class has clean state initialized
175
+ @clean_task_states[to_class] ||= CLEAN_STATE_PENDING
176
+ end
177
+ end
178
+ end
179
+ end
180
+
181
+ # ========================================
182
+ # Clean Operations (Reverse Dependency Order)
183
+ # ========================================
184
+
185
+ # Build reverse dependency graph for clean operations.
186
+ # Clean operations run in reverse order: if A depends on B, then B must
187
+ # be cleaned after A (so A→[B] in reverse graph means B depends on A's clean).
188
+ #
189
+ # Also initializes clean states for all tasks to CLEAN_STATE_PENDING.
190
+ #
191
+ ##
192
+ # Builds the reverse dependency graph and initializes per-task clean execution state starting from the given root task.
193
+ #
194
+ # Ensures the forward dependency graph is present, clears prior clean-state data, initializes each discovered task with
195
+ # an empty reverse-dependency set and `CLEAN_STATE_PENDING`, and populates reverse mappings so a task's clean run
196
+ # depends on the clean completion of tasks that depend on it.
197
+ # @param [Class] root_task_class The root task class from which to discover tasks and their dependencies.
198
+ def build_reverse_dependency_graph(root_task_class)
199
+ # First, ensure we have the forward dependency graph
200
+ build_dependency_graph(root_task_class) if @dependencies.empty?
201
+
202
+ # Clear previous clean state
203
+ @reverse_dependencies.clear
204
+ @clean_task_states.clear
205
+ @clean_completed_tasks.clear
206
+
207
+ # Initialize all tasks with empty reverse dependency sets
208
+ @dependencies.each_key do |task_class|
209
+ @reverse_dependencies[task_class] = Set.new
210
+ @clean_task_states[task_class] = CLEAN_STATE_PENDING
211
+ end
212
+
213
+ # Build reverse mappings: if A depends on B, then B→[A] in reverse graph
214
+ # This means B's clean depends on A's clean completing first
215
+ @dependencies.each do |task_class, deps|
216
+ deps.each do |dep_class|
217
+ @reverse_dependencies[dep_class].add(task_class)
218
+ end
219
+ end
220
+ end
221
+
222
+ # Get all tasks that are ready to clean.
223
+ # A task is ready to clean when all its reverse dependencies (dependents)
224
+ # have completed their clean operation.
225
+ #
226
+ ##
227
+ # Lists task classes that are ready for clean execution.
228
+ # A task is considered ready when its clean state is `CLEAN_STATE_PENDING` and all of its reverse dependencies have completed their clean execution.
229
+ # @return [Array<Class>] Array of task classes ready for clean execution.
230
+ def next_ready_clean_tasks
231
+ ready = []
232
+ @clean_task_states.each_key do |task_class|
233
+ next unless @clean_task_states[task_class] == CLEAN_STATE_PENDING
234
+ next unless ready_to_clean?(task_class)
235
+ ready << task_class
236
+ end
237
+ ready
238
+ end
239
+
240
+ # Mark a task as enqueued for clean execution.
241
+ #
242
+ ##
243
+ # Marks the given task class as enqueued for clean execution.
244
+ # @param [Class] task_class The task class to mark as enqueued for clean execution.
245
+ def mark_clean_enqueued(task_class)
246
+ @clean_task_states[task_class] = CLEAN_STATE_ENQUEUED
247
+ end
248
+
249
+ # Mark a task as clean completed.
250
+ #
251
+ ##
252
+ # Marks the clean execution of the given task class as completed and records it.
253
+ # @param [Class] task_class - The task class to mark as clean completed.
254
+ def mark_clean_completed(task_class)
255
+ @clean_task_states[task_class] = CLEAN_STATE_COMPLETED
256
+ @clean_completed_tasks.add(task_class)
257
+ end
258
+
259
+ # Check if a task's clean is completed.
260
+ #
261
+ # @param task_class [Class] The task class to check
262
+ ##
263
+ # Checks whether a task class has completed its clean execution.
264
+ # @param [Class] task_class - The task class to check.
265
+ # @return [Boolean] `true` if the task's clean is completed, `false` otherwise.
266
+ def clean_completed?(task_class)
267
+ @clean_completed_tasks.include?(task_class)
268
+ end
269
+
270
+ # Check if there are any running (enqueued) clean tasks.
271
+ #
272
+ ##
273
+ # Indicates whether any clean tasks are currently enqueued for execution.
274
+ # @return [Boolean] `true` if at least one clean task is enqueued, `false` otherwise.
275
+ def running_clean_tasks?
276
+ @clean_task_states.values.any? { |state| state == CLEAN_STATE_ENQUEUED }
277
+ end
278
+
279
+ private
280
+
281
+ # Check if a task is ready to execute (all dependencies completed).
282
+ #
283
+ # @param task_class [Class] The task class to check
284
+ ##
285
+ # Determines whether a task's dependencies have all completed.
286
+ # @param [Class] task_class - The task class to check.
287
+ # @return [Boolean] `true` if every dependency of `task_class` is in the set of completed tasks, `false` otherwise.
288
+ def ready_to_execute?(task_class)
289
+ task_deps = @dependencies[task_class] || Set.new
290
+ task_deps.subset?(@completed_tasks)
291
+ end
292
+
293
+ # Check if a task is ready to clean (all reverse dependencies completed).
294
+ # Reverse dependencies are tasks that depend on this task, which must
295
+ # be cleaned before this task can be cleaned.
296
+ #
297
+ # @param task_class [Class] The task class to check
298
+ ##
299
+ # Determines whether a task is ready for clean execution.
300
+ # @param [Class] task_class - The task class to check.
301
+ # @return [Boolean] `true` if all tasks that depend on `task_class` have completed their clean execution, `false` otherwise.
302
+ def ready_to_clean?(task_class)
303
+ reverse_deps = @reverse_dependencies[task_class] || Set.new
304
+ reverse_deps.subset?(@clean_completed_tasks)
305
+ end
306
+ end
307
+ end
308
+ end
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_progress_display"
4
+
5
+ module Taski
6
+ module Execution
7
+ # SimpleProgressDisplay provides a minimalist single-line progress display
8
+ # that shows task execution status in a compact format:
9
+ #
10
+ # ⠹ [3/5] DeployTask | Uploading files...
11
+ #
12
+ # This is an alternative to TreeProgressDisplay for users who prefer
13
+ # less verbose output.
14
+ class SimpleProgressDisplay < BaseProgressDisplay
15
+ SPINNER_FRAMES = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
16
+ RENDER_INTERVAL = 0.1
17
+
18
+ ICONS = {
19
+ success: "✓",
20
+ failure: "✗",
21
+ pending: "○"
22
+ }.freeze
23
+
24
+ COLORS = {
25
+ green: "\e[32m",
26
+ red: "\e[31m",
27
+ yellow: "\e[33m",
28
+ dim: "\e[2m",
29
+ reset: "\e[0m"
30
+ }.freeze
31
+
32
+ def initialize(output: $stdout)
33
+ super
34
+ @spinner_index = 0
35
+ @renderer_thread = nil
36
+ @running = false
37
+ end
38
+
39
+ protected
40
+
41
+ # Template method: Called when root task is set
42
+ def on_root_task_set
43
+ build_tree_structure
44
+ end
45
+
46
+ # Template method: Called when a section impl is registered
47
+ def on_section_impl_registered(_section_class, impl_class)
48
+ @tasks[impl_class] ||= TaskProgress.new
49
+ @tasks[impl_class].is_impl_candidate = false
50
+ end
51
+
52
+ # Template method: Determine if display should activate
53
+ def should_activate?
54
+ tty?
55
+ end
56
+
57
+ # Template method: Called when display starts
58
+ def on_start
59
+ @running = true
60
+ @output.print "\e[?25l" # Hide cursor
61
+ @renderer_thread = Thread.new do
62
+ loop do
63
+ break unless @running
64
+ render_live
65
+ sleep RENDER_INTERVAL
66
+ end
67
+ end
68
+ end
69
+
70
+ # Template method: Called when display stops
71
+ def on_stop
72
+ @running = false
73
+ @renderer_thread&.join
74
+ @output.print "\e[?25h" # Show cursor
75
+ render_final
76
+ end
77
+
78
+ private
79
+
80
+ def build_tree_structure
81
+ return unless @root_task_class
82
+
83
+ # Use TreeProgressDisplay's static method for tree building
84
+ tree = TreeProgressDisplay.build_tree_node(@root_task_class)
85
+ register_tasks_from_tree(tree)
86
+ end
87
+
88
+ def render_live
89
+ @monitor.synchronize do
90
+ @spinner_index = (@spinner_index + 1) % SPINNER_FRAMES.size
91
+ line = build_status_line
92
+ # Clear line and write new content
93
+ @output.print "\r\e[K#{line}"
94
+ @output.flush
95
+ end
96
+ end
97
+
98
+ def render_final
99
+ @monitor.synchronize do
100
+ total_duration = @start_time ? ((Time.now - @start_time) * 1000).to_i : 0
101
+ completed = @tasks.values.count { |p| p.run_state == :completed }
102
+ failed = @tasks.values.count { |p| p.run_state == :failed }
103
+ total = @tasks.size
104
+
105
+ line = if failed > 0
106
+ failed_tasks = @tasks.select { |_, p| p.run_state == :failed }
107
+ first_error = failed_tasks.values.first&.run_error
108
+ error_msg = first_error ? ": #{first_error.message}" : ""
109
+ "#{COLORS[:red]}#{ICONS[:failure]}#{COLORS[:reset]} [#{completed}/#{total}] " \
110
+ "#{failed_tasks.keys.first} failed#{error_msg}"
111
+ else
112
+ "#{COLORS[:green]}#{ICONS[:success]}#{COLORS[:reset]} [#{completed}/#{total}] " \
113
+ "All tasks completed (#{total_duration}ms)"
114
+ end
115
+
116
+ @output.print "\r\e[K#{line}\n"
117
+ @output.flush
118
+ end
119
+ end
120
+
121
+ def build_status_line
122
+ running_tasks = @tasks.select { |_, p| p.run_state == :running }
123
+ cleaning_tasks = @tasks.select { |_, p| p.clean_state == :cleaning }
124
+ completed = @tasks.values.count { |p| p.run_state == :completed }
125
+ failed = @tasks.values.count { |p| p.run_state == :failed }
126
+ total = @tasks.size
127
+
128
+ spinner = SPINNER_FRAMES[@spinner_index]
129
+ status_icon = if failed > 0
130
+ "#{COLORS[:red]}#{ICONS[:failure]}#{COLORS[:reset]}"
131
+ elsif running_tasks.any? || cleaning_tasks.any?
132
+ "#{COLORS[:yellow]}#{spinner}#{COLORS[:reset]}"
133
+ else
134
+ "#{COLORS[:green]}#{ICONS[:success]}#{COLORS[:reset]}"
135
+ end
136
+
137
+ # Get current task names
138
+ current_tasks = if cleaning_tasks.any?
139
+ cleaning_tasks.keys.map { |t| short_name(t) }
140
+ else
141
+ running_tasks.keys.map { |t| short_name(t) }
142
+ end
143
+
144
+ task_names = current_tasks.first(3).join(", ")
145
+ task_names += "..." if current_tasks.size > 3
146
+
147
+ # Get last output message if available
148
+ output_suffix = build_output_suffix(running_tasks.keys.first || cleaning_tasks.keys.first)
149
+
150
+ parts = ["#{status_icon} [#{completed}/#{total}]"]
151
+ parts << task_names if task_names && !task_names.empty?
152
+ parts << "|" << output_suffix if output_suffix
153
+
154
+ parts.join(" ")
155
+ end
156
+
157
+ def build_output_suffix(task_class)
158
+ return nil unless @output_capture && task_class
159
+
160
+ last_line = @output_capture.last_line_for(task_class)
161
+ return nil unless last_line && !last_line.strip.empty?
162
+
163
+ # Truncate if too long
164
+ max_length = 40
165
+ if last_line.length > max_length
166
+ last_line[0, max_length - 3] + "..."
167
+ else
168
+ last_line
169
+ end
170
+ end
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Taski
4
+ module Execution
5
+ # Manages a single IO pipe for capturing task output
6
+ # Each task gets its own dedicated pipe for stdout capture
7
+ class TaskOutputPipe
8
+ attr_reader :read_io, :write_io, :task_class
9
+
10
+ def initialize(task_class)
11
+ @task_class = task_class
12
+ @read_io, @write_io = IO.pipe
13
+ @write_io.sync = true
14
+ end
15
+
16
+ def close_write
17
+ @write_io.close unless @write_io.closed?
18
+ end
19
+
20
+ def close_read
21
+ @read_io.close unless @read_io.closed?
22
+ end
23
+
24
+ def close
25
+ close_write
26
+ close_read
27
+ end
28
+
29
+ def write_closed?
30
+ @write_io.closed?
31
+ end
32
+
33
+ def read_closed?
34
+ @read_io.closed?
35
+ end
36
+
37
+ def closed?
38
+ write_closed? && read_closed?
39
+ end
40
+ end
41
+ end
42
+ end