taski 0.3.1 → 0.4.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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +101 -271
  3. data/Steepfile +19 -0
  4. data/docs/advanced-features.md +625 -0
  5. data/docs/api-guide.md +509 -0
  6. data/docs/error-handling.md +684 -0
  7. data/examples/README.md +98 -42
  8. data/examples/context_demo.rb +118 -0
  9. data/examples/data_pipeline_demo.rb +231 -0
  10. data/examples/parallel_progress_demo.rb +72 -0
  11. data/examples/quick_start.rb +4 -4
  12. data/examples/reexecution_demo.rb +127 -0
  13. data/examples/{section_configuration.rb → section_demo.rb} +49 -60
  14. data/lib/taski/context.rb +50 -0
  15. data/lib/taski/execution/coordinator.rb +63 -0
  16. data/lib/taski/execution/parallel_progress_display.rb +201 -0
  17. data/lib/taski/execution/registry.rb +72 -0
  18. data/lib/taski/execution/task_wrapper.rb +255 -0
  19. data/lib/taski/section.rb +26 -254
  20. data/lib/taski/static_analysis/analyzer.rb +46 -0
  21. data/lib/taski/static_analysis/dependency_graph.rb +90 -0
  22. data/lib/taski/static_analysis/visitor.rb +130 -0
  23. data/lib/taski/task.rb +199 -0
  24. data/lib/taski/version.rb +1 -1
  25. data/lib/taski.rb +68 -39
  26. data/rbs_collection.lock.yaml +116 -0
  27. data/rbs_collection.yaml +19 -0
  28. data/sig/taski.rbs +269 -62
  29. metadata +36 -32
  30. data/examples/advanced_patterns.rb +0 -119
  31. data/examples/progress_demo.rb +0 -166
  32. data/examples/tree_demo.rb +0 -205
  33. data/lib/taski/dependency_analyzer.rb +0 -232
  34. data/lib/taski/exceptions.rb +0 -17
  35. data/lib/taski/logger.rb +0 -158
  36. data/lib/taski/logging/formatter_factory.rb +0 -34
  37. data/lib/taski/logging/formatter_interface.rb +0 -19
  38. data/lib/taski/logging/json_formatter.rb +0 -26
  39. data/lib/taski/logging/simple_formatter.rb +0 -16
  40. data/lib/taski/logging/structured_formatter.rb +0 -44
  41. data/lib/taski/progress/display_colors.rb +0 -17
  42. data/lib/taski/progress/display_manager.rb +0 -117
  43. data/lib/taski/progress/output_capture.rb +0 -105
  44. data/lib/taski/progress/spinner_animation.rb +0 -49
  45. data/lib/taski/progress/task_formatter.rb +0 -25
  46. data/lib/taski/progress/task_status.rb +0 -38
  47. data/lib/taski/progress/terminal_controller.rb +0 -35
  48. data/lib/taski/progress_display.rb +0 -57
  49. data/lib/taski/reference.rb +0 -40
  50. data/lib/taski/task/base.rb +0 -91
  51. data/lib/taski/task/define_api.rb +0 -156
  52. data/lib/taski/task/dependency_resolver.rb +0 -73
  53. data/lib/taski/task/exports_api.rb +0 -29
  54. data/lib/taski/task/instance_management.rb +0 -201
  55. data/lib/taski/tree_colors.rb +0 -91
  56. data/lib/taski/utils/dependency_resolver_helper.rb +0 -85
  57. data/lib/taski/utils/tree_display_helper.rb +0 -68
  58. data/lib/taski/utils.rb +0 -107
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Taski Re-execution Demo
5
+ #
6
+ # This example demonstrates cache control and re-execution:
7
+ # - Default caching behavior
8
+ # - Task.new for fresh instances
9
+ # - Task.reset! for clearing all caches
10
+ #
11
+ # Run: ruby examples/reexecution_demo.rb
12
+
13
+ require_relative "../lib/taski"
14
+
15
+ puts "Taski Re-execution Demo"
16
+ puts "=" * 40
17
+
18
+ # Task that generates random values (to demonstrate caching)
19
+ class RandomGenerator < Taski::Task
20
+ exports :value, :timestamp
21
+
22
+ def run
23
+ @value = rand(1000)
24
+ @timestamp = Time.now.strftime("%H:%M:%S.%L")
25
+ puts " RandomGenerator.run called: value=#{@value}, time=#{@timestamp}"
26
+ end
27
+ end
28
+
29
+ # Task that depends on RandomGenerator
30
+ class Consumer < Taski::Task
31
+ exports :result
32
+
33
+ def run
34
+ random_value = RandomGenerator.value
35
+ @result = "Consumed value: #{random_value}"
36
+ puts " Consumer.run called: #{@result}"
37
+ end
38
+ end
39
+
40
+ puts "\n1. Default Caching Behavior"
41
+ puts "-" * 40
42
+ puts "First call to RandomGenerator.value:"
43
+ value1 = RandomGenerator.value
44
+ puts " => #{value1}"
45
+
46
+ puts "\nSecond call to RandomGenerator.value (cached, no run):"
47
+ value2 = RandomGenerator.value
48
+ puts " => #{value2}"
49
+
50
+ puts "\nValues are identical: #{value1 == value2}"
51
+
52
+ puts "\n" + "=" * 40
53
+ puts "\n2. Using Task.new for Fresh Instance"
54
+ puts "-" * 40
55
+ puts "Creating new instance with RandomGenerator.new:"
56
+
57
+ instance1 = RandomGenerator.new
58
+ instance1.run
59
+ puts " instance1.value = #{instance1.value}"
60
+
61
+ instance2 = RandomGenerator.new
62
+ instance2.run
63
+ puts " instance2.value = #{instance2.value}"
64
+
65
+ puts "\nNote: Each .new creates independent instance"
66
+ puts "Class-level cache unchanged: RandomGenerator.value = #{RandomGenerator.value}"
67
+
68
+ puts "\n" + "=" * 40
69
+ puts "\n3. Using reset! to Clear Cache"
70
+ puts "-" * 40
71
+ puts "Before reset!:"
72
+ puts " RandomGenerator.value = #{RandomGenerator.value}"
73
+
74
+ puts "\nCalling RandomGenerator.reset!..."
75
+ RandomGenerator.reset!
76
+
77
+ puts "\nAfter reset! (fresh execution):"
78
+ new_value = RandomGenerator.value
79
+ puts " RandomGenerator.value = #{new_value}"
80
+
81
+ puts "\n" + "=" * 40
82
+ puts "\n4. Dependency Chain with Re-execution"
83
+ puts "-" * 40
84
+
85
+ # Reset both tasks
86
+ RandomGenerator.reset!
87
+ Consumer.reset!
88
+
89
+ puts "First Consumer execution:"
90
+ result1 = Consumer.result
91
+ puts " => #{result1}"
92
+
93
+ puts "\nSecond Consumer execution (cached):"
94
+ result2 = Consumer.result
95
+ puts " => #{result2}"
96
+
97
+ puts "\nReset Consumer and re-execute:"
98
+ Consumer.reset!
99
+ result3 = Consumer.result
100
+ puts " => #{result3}"
101
+ puts " (Dependencies are re-resolved when task is reset)"
102
+
103
+ puts "\nReset both tasks:"
104
+ RandomGenerator.reset!
105
+ Consumer.reset!
106
+ result4 = Consumer.result
107
+ puts " => #{result4}"
108
+ puts " (New random value because both were reset)"
109
+
110
+ puts "\n" + "=" * 40
111
+ puts "\n5. Use Cases Summary"
112
+ puts "-" * 40
113
+ puts <<~SUMMARY
114
+ TaskClass.run / TaskClass.value
115
+ => Normal execution with caching (recommended for dependency graphs)
116
+
117
+ TaskClass.new.run
118
+ => Re-execute only this task (dependencies still use cache)
119
+ => Useful for: testing, one-off executions
120
+
121
+ TaskClass.reset!
122
+ => Clear this task's cache, next call will re-execute
123
+ => Useful for: environment changes, refreshing data
124
+ SUMMARY
125
+
126
+ puts "\n" + "=" * 40
127
+ puts "Re-execution demonstration complete!"
@@ -1,17 +1,12 @@
1
1
  #!/usr/bin/env ruby
2
- # frozen_string_literal: true
3
-
4
- # Section Configuration Example
5
- # This example demonstrates how to use Taski::Section for dynamic implementation
6
- # selection and dependency resolution with configuration management.
7
- #
8
- # Key Features Demonstrated:
9
- # 1. DRY Principle: No need to duplicate 'exports' declarations in nested Task classes
10
- # - interface declaration automatically adds exports to nested Task classes
11
- # 2. Consistent API: impl must return Task classes - .build is called automatically
12
- # 3. Dynamic Implementation Selection: Different implementations based on environment
13
- # 4. Dependency Resolution: Sections are properly detected in dependency analysis
14
- # 5. Tree Visualization: Sections appear in dependency trees
2
+
3
+ # Section API Basics Example
4
+ # This example demonstrates runtime implementation selection with the Section API
5
+
6
+ # Section API is perfect for:
7
+ # - Environment-specific implementations (dev/staging/prod)
8
+ # - Different service adapters (AWS/GCP/local)
9
+ # - Clean abstraction with guaranteed interfaces
15
10
 
16
11
  require_relative "../lib/taski"
17
12
 
@@ -20,10 +15,10 @@ require_relative "../lib/taski"
20
15
  # for development and production environments
21
16
  class DatabaseSection < Taski::Section
22
17
  # Define the interface that implementations must provide
23
- interface :host, :port, :username, :password, :database_name, :pool_size
18
+ interfaces :host, :port, :username, :password, :database_name, :pool_size
24
19
 
25
20
  # Select implementation based on environment
26
- # Note: Must return a Task class - .build is automatically called
21
+ # Note: Must return a Task class - .run is automatically called
27
22
  # No 'self' needed - just define as instance method!
28
23
  def impl
29
24
  if ENV["RAILS_ENV"] == "production"
@@ -34,9 +29,9 @@ class DatabaseSection < Taski::Section
34
29
  end
35
30
 
36
31
  # Production implementation with secure settings
37
- # Note: exports are automatically inherited from interface declaration
32
+ # No exports needed - automatically inherited from interfaces
38
33
  class Production < Taski::Task
39
- def build
34
+ def run
40
35
  @host = "prod-db.example.com"
41
36
  @port = 5432
42
37
  @username = "app_user"
@@ -47,9 +42,8 @@ class DatabaseSection < Taski::Section
47
42
  end
48
43
 
49
44
  # Development implementation with local settings
50
- # Note: exports are automatically inherited from interface declaration
51
45
  class Development < Taski::Task
52
- def build
46
+ def run
53
47
  @host = "localhost"
54
48
  @port = 5432
55
49
  @username = "dev_user"
@@ -63,12 +57,12 @@ end
63
57
  # Example 2: API Configuration Section
64
58
  # This section provides API endpoints and credentials
65
59
  class ApiSection < Taski::Section
66
- interface :base_url, :api_key, :timeout, :retry_count
60
+ interfaces :base_url, :api_key, :timeout, :retry_count
67
61
 
68
62
  # No 'self' needed - just define as instance method!
69
63
  def impl
70
64
  # Select based on feature flag
71
- # Note: Must return a Task class - .build is automatically called
65
+ # Note: Must return a Task class - .run is automatically called
72
66
  if ENV["USE_STAGING_API"] == "true"
73
67
  Staging
74
68
  else
@@ -76,9 +70,8 @@ class ApiSection < Taski::Section
76
70
  end
77
71
  end
78
72
 
79
- # Note: exports are automatically inherited from interface declaration - DRY principle!
80
73
  class Production < Taski::Task
81
- def build
74
+ def run
82
75
  @base_url = "https://api.example.com/v1"
83
76
  @api_key = ENV["PROD_API_KEY"] || "prod-key-123"
84
77
  @timeout = 30
@@ -86,9 +79,8 @@ class ApiSection < Taski::Section
86
79
  end
87
80
  end
88
81
 
89
- # Note: exports are automatically inherited from interface declaration - DRY principle!
90
82
  class Staging < Taski::Task
91
- def build
83
+ def run
92
84
  @base_url = "https://staging-api.example.com/v1"
93
85
  @api_key = ENV["STAGING_API_KEY"] || "staging-key-456"
94
86
  @timeout = 60
@@ -101,7 +93,7 @@ end
101
93
  class ApplicationSetup < Taski::Task
102
94
  exports :config_summary
103
95
 
104
- def build
96
+ def run
105
97
  puts "Setting up application with configuration:"
106
98
  puts "Database: #{DatabaseSection.host}:#{DatabaseSection.port}/#{DatabaseSection.database_name}"
107
99
  puts "API: #{ApiSection.base_url}"
@@ -128,7 +120,7 @@ end
128
120
  class DatabaseConnection < Taski::Task
129
121
  exports :connection
130
122
 
131
- def build
123
+ def run
132
124
  puts "Connecting to database..."
133
125
  # Use section configuration to create connection
134
126
  connection_string = "postgresql://#{DatabaseSection.username}:#{DatabaseSection.password}@#{DatabaseSection.host}:#{DatabaseSection.port}/#{DatabaseSection.database_name}"
@@ -140,7 +132,7 @@ end
140
132
  class ApiClient < Taski::Task
141
133
  exports :client
142
134
 
143
- def build
135
+ def run
144
136
  puts "Initializing API client..."
145
137
  @client = "API Client: #{ApiSection.base_url} (timeout: #{ApiSection.timeout}s, retries: #{ApiSection.retry_count})"
146
138
  puts @client
@@ -148,12 +140,12 @@ class ApiClient < Taski::Task
148
140
  end
149
141
 
150
142
  class Application < Taski::Task
151
- def build
143
+ def run
152
144
  puts "\n=== Starting Application ==="
153
145
 
154
146
  # Dependencies are automatically resolved
155
- # DatabaseConnection and ApiClient will be built first
156
- # which triggers building of their respective sections
147
+ # DatabaseConnection and ApiClient will be executed first
148
+ # which triggers execution of their respective sections
157
149
 
158
150
  puts "\nDatabase ready: #{DatabaseConnection.connection}"
159
151
  puts "API ready: #{ApiClient.client}"
@@ -165,42 +157,39 @@ class Application < Taski::Task
165
157
  end
166
158
  end
167
159
 
168
- # Demo script
169
- if __FILE__ == $0
170
- puts "Taski Section Configuration Example"
171
- puts "=" * 50
160
+ puts "Taski Section Configuration Example"
161
+ puts "=" * 50
172
162
 
173
- puts "\n1. Development Environment (default)"
174
- ENV["RAILS_ENV"] = "development"
175
- ENV["USE_STAGING_API"] = "false"
163
+ puts "\n1. Development Environment (default)"
164
+ ENV["RAILS_ENV"] = "development"
165
+ ENV["USE_STAGING_API"] = "false"
176
166
 
177
- # Reset all tasks to ensure fresh build
178
- [DatabaseSection, ApiSection, ApplicationSetup, DatabaseConnection, ApiClient, Application].each(&:reset!)
167
+ # Reset all tasks to ensure fresh build
168
+ [DatabaseSection, ApiSection, ApplicationSetup, DatabaseConnection, ApiClient, Application].each(&:reset!)
179
169
 
180
- Application.build
170
+ Application.run
181
171
 
182
- puts "\n" + "=" * 50
183
- puts "\n2. Production Environment with Staging API"
184
- ENV["RAILS_ENV"] = "production"
185
- ENV["USE_STAGING_API"] = "true"
172
+ puts "\n" + "=" * 50
173
+ puts "\n2. Production Environment with Staging API"
174
+ ENV["RAILS_ENV"] = "production"
175
+ ENV["USE_STAGING_API"] = "true"
186
176
 
187
- # Reset all tasks to see different configuration
188
- [DatabaseSection, ApiSection, ApplicationSetup, DatabaseConnection, ApiClient, Application].each(&:reset!)
177
+ # Reset all tasks to see different configuration
178
+ [DatabaseSection, ApiSection, ApplicationSetup, DatabaseConnection, ApiClient, Application].each(&:reset!)
189
179
 
190
- Application.build
180
+ Application.run
191
181
 
192
- puts "\n" + "=" * 50
193
- puts "\n3. Dependency Tree Visualization"
194
- puts "\nApplication dependency tree:"
195
- puts Application.tree
182
+ puts "\n" + "=" * 50
183
+ puts "\n3. Dependency Tree Visualization"
184
+ puts "\nApplication dependency tree:"
185
+ puts Application.tree
196
186
 
197
- puts "\nDatabaseConnection dependency tree:"
198
- puts DatabaseConnection.tree
187
+ puts "\nDatabaseConnection dependency tree:"
188
+ puts DatabaseConnection.tree
199
189
 
200
- puts "\nApiClient dependency tree:"
201
- puts ApiClient.tree
190
+ puts "\nApiClient dependency tree:"
191
+ puts ApiClient.tree
202
192
 
203
- puts "\n" + "=" * 50
204
- puts "\nSection dependency resolution successfully demonstrated!"
205
- puts "Notice how sections appear in the dependency trees and logs."
206
- end
193
+ puts "\n" + "=" * 50
194
+ puts "\nSection dependency resolution successfully demonstrated!"
195
+ puts "Notice how sections appear in the dependency trees and logs."
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+
5
+ module Taski
6
+ # Runtime context accessible from any task.
7
+ # Holds user-defined options and execution metadata.
8
+ # Context is immutable after creation - options cannot be modified during task execution.
9
+ class Context
10
+ attr_reader :started_at, :working_directory, :root_task
11
+
12
+ # @param options [Hash] User-defined options (immutable after creation)
13
+ # @param root_task [Class] The root task class that initiated execution
14
+ def initialize(options:, root_task:)
15
+ @options = options.dup.freeze
16
+ @root_task = root_task
17
+ @started_at = Time.now
18
+ @working_directory = Dir.pwd
19
+ end
20
+
21
+ # Get a user-defined option value
22
+ # @param key [Symbol, String] The option key
23
+ # @return [Object, nil] The option value or nil if not set
24
+ def [](key)
25
+ @options[key]
26
+ end
27
+
28
+ # Get a user-defined option value with a default
29
+ # @param key [Symbol, String] The option key
30
+ # @param default [Object] Default value if key is not present
31
+ # @yield Block to compute default value if key is not present
32
+ # @return [Object] The option value or default
33
+ def fetch(key, default = nil, &block)
34
+ if @options.key?(key)
35
+ @options[key]
36
+ elsif block
37
+ block.call
38
+ else
39
+ default
40
+ end
41
+ end
42
+
43
+ # Check if a user-defined option key exists
44
+ # @param key [Symbol, String] The option key
45
+ # @return [Boolean] true if the key exists
46
+ def key?(key)
47
+ @options.key?(key)
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Taski
4
+ module Execution
5
+ class Coordinator
6
+ def initialize(registry:, analyzer:)
7
+ @registry = registry
8
+ @analyzer = analyzer
9
+ end
10
+
11
+ # @param task_class [Class] The task class whose dependencies should be started
12
+ def start_dependencies(task_class)
13
+ dependencies = get_dependencies(task_class)
14
+ return if dependencies.empty?
15
+
16
+ dependencies.each do |dep_class|
17
+ start_dependency_execution(dep_class)
18
+ end
19
+ end
20
+
21
+ # @param task_class [Class] The task class whose dependencies should be cleaned
22
+ def start_clean_dependencies(task_class)
23
+ dependencies = get_dependencies(task_class)
24
+ return if dependencies.empty?
25
+
26
+ dependencies.each do |dep_class|
27
+ start_dependency_clean(dep_class)
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def get_dependencies(task_class)
34
+ if task_class.respond_to?(:cached_dependencies)
35
+ task_class.cached_dependencies
36
+ else
37
+ @analyzer.analyze(task_class)
38
+ end
39
+ end
40
+
41
+ def start_thread_with(&block)
42
+ thread = Thread.new(&block)
43
+ @registry.register_thread(thread)
44
+ end
45
+
46
+ def start_dependency_execution(dep_class)
47
+ exported_methods = dep_class.exported_methods
48
+
49
+ exported_methods.each do |method|
50
+ start_thread_with do
51
+ dep_class.public_send(method)
52
+ end
53
+ end
54
+ end
55
+
56
+ def start_dependency_clean(dep_class)
57
+ start_thread_with do
58
+ dep_class.public_send(:clean)
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+
5
+ module Taski
6
+ module Execution
7
+ class ParallelProgressDisplay
8
+ SPINNER_FRAMES = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
9
+
10
+ class TaskProgress
11
+ attr_accessor :state, :start_time, :end_time, :error, :duration
12
+
13
+ def initialize
14
+ @state = :pending
15
+ @start_time = nil
16
+ @end_time = nil
17
+ @error = nil
18
+ @duration = nil
19
+ end
20
+ end
21
+
22
+ def initialize(output: $stdout)
23
+ @output = output
24
+ @tasks = {}
25
+ @monitor = Monitor.new
26
+ @spinner_index = 0
27
+ @renderer_thread = nil
28
+ @running = false
29
+ end
30
+
31
+ # @param task_class [Class] The task class to register
32
+ def register_task(task_class)
33
+ @monitor.synchronize do
34
+ @tasks[task_class] = TaskProgress.new
35
+ end
36
+ end
37
+
38
+ # @param task_class [Class] The task class to check
39
+ # @return [Boolean] true if the task is registered
40
+ def task_registered?(task_class)
41
+ @monitor.synchronize do
42
+ @tasks.key?(task_class)
43
+ end
44
+ end
45
+
46
+ # @param task_class [Class] The task class to update
47
+ # @param state [Symbol] The new state (:pending, :running, :completed, :failed)
48
+ # @param duration [Float] Duration in milliseconds (for completed tasks)
49
+ # @param error [Exception] Error object (for failed tasks)
50
+ def update_task(task_class, state:, duration: nil, error: nil)
51
+ @monitor.synchronize do
52
+ progress = @tasks[task_class]
53
+ return unless progress
54
+
55
+ progress.state = state
56
+ progress.duration = duration if duration
57
+ progress.error = error if error
58
+
59
+ case state
60
+ when :running
61
+ progress.start_time = Time.now
62
+ when :completed, :failed
63
+ progress.end_time = Time.now
64
+ end
65
+ end
66
+ end
67
+
68
+ # @param task_class [Class] The task class
69
+ # @return [Symbol] The task state
70
+ def task_state(task_class)
71
+ @monitor.synchronize do
72
+ @tasks[task_class]&.state
73
+ end
74
+ end
75
+
76
+ def render
77
+ @monitor.synchronize do
78
+ @tasks.each do |task_class, progress|
79
+ line = format_task_line(task_class, progress)
80
+ @output.puts line
81
+ end
82
+ end
83
+ end
84
+
85
+ def start
86
+ return if @running
87
+
88
+ @running = true
89
+ @renderer_thread = Thread.new do
90
+ loop do
91
+ break unless @running
92
+ render_live
93
+ sleep 0.1
94
+ end
95
+ end
96
+ end
97
+
98
+ def stop
99
+ return unless @running
100
+
101
+ @running = false
102
+ @renderer_thread&.join
103
+ render_final
104
+ end
105
+
106
+ private
107
+
108
+ # @return [Array<String>] Array of formatted task lines
109
+ def collect_task_lines
110
+ @tasks.map do |task_class, progress|
111
+ format_task_line(task_class, progress)
112
+ end
113
+ end
114
+
115
+ def render_live
116
+ return unless @output.tty?
117
+
118
+ @monitor.synchronize do
119
+ @spinner_index += 1
120
+
121
+ lines = collect_task_lines
122
+
123
+ lines.each_with_index do |line, index|
124
+ @output.print "\r\e[K#{line}"
125
+ @output.print "\n" unless index == lines.length - 1
126
+ end
127
+
128
+ @output.print "\e[#{lines.length - 1}A" if lines.length > 1
129
+ end
130
+ end
131
+
132
+ def render_final
133
+ @monitor.synchronize do
134
+ lines = collect_task_lines
135
+
136
+ if @output.tty? && lines.length > 0
137
+ lines.each_with_index do |_, index|
138
+ @output.print "\r\e[K"
139
+ @output.print "\e[1B" unless index == lines.length - 1
140
+ end
141
+ @output.print "\e[#{lines.length - 1}A" if lines.length > 1
142
+ end
143
+
144
+ lines.each do |line|
145
+ @output.puts line
146
+ end
147
+ end
148
+ end
149
+
150
+ # @param task_class [Class] The task class
151
+ # @param progress [TaskProgress] The task progress
152
+ # @return [String] Formatted line
153
+ def format_task_line(task_class, progress)
154
+ icon = task_icon(progress.state)
155
+ name = task_class.name || "AnonymousTask"
156
+ details = task_details(progress)
157
+
158
+ "#{icon} #{name}#{details}"
159
+ end
160
+
161
+ # @param state [Symbol] The task state
162
+ # @return [String] The icon character
163
+ def task_icon(state)
164
+ case state
165
+ when :completed
166
+ "✅"
167
+ when :failed
168
+ "❌"
169
+ when :running
170
+ spinner_char
171
+ when :pending
172
+ "⏳"
173
+ else
174
+ "❓"
175
+ end
176
+ end
177
+
178
+ # @return [String] Current spinner frame
179
+ def spinner_char
180
+ SPINNER_FRAMES[@spinner_index % SPINNER_FRAMES.length]
181
+ end
182
+
183
+ # @param progress [TaskProgress] The task progress
184
+ # @return [String] Details string
185
+ def task_details(progress)
186
+ case progress.state
187
+ when :completed
188
+ " (#{progress.duration}ms)"
189
+ when :failed
190
+ " (failed)"
191
+ when :running
192
+ " (running)"
193
+ when :pending
194
+ " (pending)"
195
+ else
196
+ ""
197
+ end
198
+ end
199
+ end
200
+ end
201
+ end