taski 0.7.0 → 0.8.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.
@@ -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
@@ -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
@@ -18,14 +18,42 @@ module Taski
18
18
  include MonitorMixin
19
19
 
20
20
  POLL_TIMEOUT = 0.05 # 50ms timeout for IO.select
21
+ POLL_INTERVAL = 0.1 # 100ms between polls (matches TreeProgressDisplay)
21
22
  READ_BUFFER_SIZE = 4096
23
+ MAX_RECENT_LINES = 30 # Maximum number of recent lines to keep per task
22
24
 
23
25
  def initialize(original_stdout)
24
26
  super()
25
27
  @original = original_stdout
26
- @pipes = {} # task_class => TaskOutputPipe
27
- @thread_map = {} # Thread => task_class
28
- @last_lines = {} # task_class => String
28
+ @pipes = {} # task_class => TaskOutputPipe
29
+ @thread_map = {} # Thread => task_class
30
+ @recent_lines = {} # task_class => Array<String>
31
+ @poll_thread = nil
32
+ @polling = false
33
+ end
34
+
35
+ # Start the background polling thread
36
+ # This ensures pipes are drained even when display doesn't poll
37
+ def start_polling
38
+ synchronize do
39
+ return if @polling
40
+ @polling = true
41
+ end
42
+
43
+ @poll_thread = Thread.new do
44
+ loop do
45
+ break unless @polling
46
+ poll
47
+ sleep POLL_INTERVAL
48
+ end
49
+ end
50
+ end
51
+
52
+ # Stop the background polling thread
53
+ def stop_polling
54
+ synchronize { @polling = false }
55
+ @poll_thread&.join(0.5)
56
+ @poll_thread = nil
29
57
  end
30
58
 
31
59
  # Start capturing output for the current thread
@@ -41,8 +69,11 @@ module Taski
41
69
  end
42
70
 
43
71
  # Stop capturing output for the current thread
44
- # Closes the write end of the pipe
72
+ # Closes the write end of the pipe and drains remaining data
45
73
  def stop_capture
74
+ task_class = nil
75
+ pipe = nil
76
+
46
77
  synchronize do
47
78
  task_class = @thread_map.delete(Thread.current)
48
79
  unless task_class
@@ -54,6 +85,29 @@ module Taski
54
85
  pipe&.close_write
55
86
  debug_log("Stopped capture for #{task_class} on thread #{Thread.current.object_id}")
56
87
  end
88
+
89
+ # Drain any remaining data from the pipe after closing write end
90
+ drain_pipe(pipe) if pipe
91
+ end
92
+
93
+ # Drain all remaining data from a pipe
94
+ # Called after close_write to ensure all output is captured
95
+ def drain_pipe(pipe)
96
+ return if pipe.read_closed?
97
+
98
+ loop do
99
+ data = pipe.read_io.read_nonblock(READ_BUFFER_SIZE)
100
+ debug_log("drain_pipe read #{data.bytesize} bytes for #{pipe.task_class}")
101
+ store_output_lines(pipe.task_class, data)
102
+ rescue IO::WaitReadable
103
+ # Check if there's more data with a very short timeout
104
+ ready, = IO.select([pipe.read_io], nil, nil, 0.001)
105
+ break unless ready
106
+ rescue IOError
107
+ # All data has been read (EOFError) or pipe was closed by another thread
108
+ synchronize { pipe.close_read }
109
+ break
110
+ end
57
111
  end
58
112
 
59
113
  # Poll all open pipes for available data
@@ -64,7 +118,8 @@ module Taski
64
118
  end
65
119
  return if readable_pipes.empty?
66
120
 
67
- ready, _, _ = IO.select(readable_pipes, nil, nil, POLL_TIMEOUT)
121
+ # Handle race condition: pipe may be closed between check and select
122
+ ready, = IO.select(readable_pipes, nil, nil, POLL_TIMEOUT)
68
123
  return unless ready
69
124
 
70
125
  ready.each do |read_io|
@@ -73,13 +128,22 @@ module Taski
73
128
 
74
129
  read_from_pipe(pipe)
75
130
  end
131
+ rescue IOError
132
+ # Pipe was closed by another thread (drain_pipe), ignore
76
133
  end
77
134
 
78
135
  # Get the last output line for a task
79
136
  # @param task_class [Class] The task class
80
137
  # @return [String, nil] The last output line
81
138
  def last_line_for(task_class)
82
- synchronize { @last_lines[task_class] }
139
+ synchronize { @recent_lines[task_class]&.last }
140
+ end
141
+
142
+ # Get recent output lines for a task (up to MAX_RECENT_LINES)
143
+ # @param task_class [Class] The task class
144
+ # @return [Array<String>] Recent output lines
145
+ def recent_lines_for(task_class)
146
+ synchronize { (@recent_lines[task_class] || []).dup }
83
147
  end
84
148
 
85
149
  # Close all pipes and clean up
@@ -104,14 +168,13 @@ module Taski
104
168
  def write(str)
105
169
  pipe = current_thread_pipe
106
170
  if pipe && !pipe.write_closed?
107
- pipe.write_io.write(str)
108
- else
109
- # Fallback to original stdout - log why capture failed
110
- if @thread_map.empty?
111
- debug_log("Output not captured: no threads registered")
112
- else
113
- debug_log("Output not captured: thread #{Thread.current.object_id} not mapped")
171
+ begin
172
+ pipe.write_io.write(str)
173
+ rescue IOError
174
+ # Pipe was closed by another thread (e.g., stop_capture), fall back to original
175
+ @original.write(str)
114
176
  end
177
+ else
115
178
  @original.write(str)
116
179
  end
117
180
  end
@@ -189,27 +252,35 @@ module Taski
189
252
 
190
253
  def read_from_pipe(pipe)
191
254
  data = pipe.read_io.read_nonblock(READ_BUFFER_SIZE)
192
- update_last_line(pipe.task_class, data)
255
+ store_output_lines(pipe.task_class, data)
193
256
  rescue IO::WaitReadable
194
257
  # No data available yet
195
- rescue EOFError
196
- # Pipe closed by writer, close read end
258
+ rescue IOError
259
+ # Pipe closed by writer (EOFError) or by another thread, close read end
197
260
  synchronize { pipe.close_read }
198
261
  end
199
262
 
200
- def update_last_line(task_class, data)
263
+ def store_output_lines(task_class, data)
201
264
  return if data.nil? || data.empty?
202
265
 
203
266
  lines = data.lines
204
- last_non_empty = lines.reverse.find { |l| !l.strip.empty? }
205
267
  synchronize do
206
- @last_lines[task_class] = last_non_empty&.strip if last_non_empty
268
+ @recent_lines[task_class] ||= []
269
+ lines.each do |line|
270
+ stripped = line.chomp
271
+ @recent_lines[task_class] << stripped unless stripped.strip.empty?
272
+ end
273
+ # Keep only the last MAX_RECENT_LINES
274
+ if @recent_lines[task_class].size > MAX_RECENT_LINES
275
+ @recent_lines[task_class] = @recent_lines[task_class].last(MAX_RECENT_LINES)
276
+ end
277
+ debug_log("store_output_lines: #{task_class} now has #{@recent_lines[task_class].size} lines")
207
278
  end
208
279
  end
209
280
 
210
281
  def debug_log(message)
211
282
  return unless ENV["TASKI_DEBUG"]
212
- @original.puts "[TaskOutputRouter] #{message}"
283
+ warn "[TaskOutputRouter] #{message}"
213
284
  end
214
285
  end
215
286
  end
@@ -38,10 +38,12 @@ module Taski
38
38
  # @param [Object] task - The task instance being wrapped.
39
39
  # @param [Object] registry - The registry used to query abort status and coordinate execution.
40
40
  # @param [Object, nil] execution_context - Optional execution context used to trigger and report execution and cleanup.
41
- def initialize(task, registry:, execution_context: nil)
41
+ # @param [Hash, nil] args - User-defined arguments for Task.new usage.
42
+ def initialize(task, registry:, execution_context: nil, args: nil)
42
43
  @task = task
43
44
  @registry = registry
44
45
  @execution_context = execution_context
46
+ @args = args
45
47
  @result = nil
46
48
  @clean_result = nil
47
49
  @error = nil
@@ -261,14 +263,18 @@ module Taski
261
263
  ##
262
264
  # Ensures args are set during block execution, then resets if they weren't set before.
263
265
  # This allows Task.new.run usage without requiring explicit args setup.
266
+ # If args are already set (e.g., from Task.run class method), just yields the block.
267
+ # Uses stored @args if set (from Task.new), otherwise uses empty hash.
264
268
  # @yield The block to execute with args lifecycle management
265
269
  # @return [Object] The result of the block
266
- def with_args_lifecycle
267
- args_was_nil = Taski.args.nil?
268
- Taski.start_args(options: {}, root_task: @task.class) if args_was_nil
269
- yield
270
- ensure
271
- Taski.reset_args! if args_was_nil
270
+ def with_args_lifecycle(&block)
271
+ # If args are already set, just execute the block
272
+ return yield if Taski.args
273
+
274
+ options = @args || {}
275
+ Taski.send(:with_env, root_task: @task.class) do
276
+ Taski.send(:with_args, options: options, &block)
277
+ end
272
278
  end
273
279
 
274
280
  ##
@@ -276,26 +282,11 @@ module Taski
276
282
  # If the task is pending, triggers execution (via the configured ExecutionContext when present, otherwise via Executor) outside the monitor; if the task is running, waits until it becomes completed; if already completed, returns immediately.
277
283
  # @raise [Taski::TaskAbortException] If the registry requested an abort before execution begins.
278
284
  def trigger_execution_and_wait
279
- should_execute = false
280
- @monitor.synchronize do
281
- case @state
282
- when STATE_PENDING
283
- check_abort!
284
- should_execute = true
285
- when STATE_RUNNING
286
- @condition.wait_until { @state == STATE_COMPLETED }
287
- when STATE_COMPLETED
288
- # Already done
289
- end
290
- end
291
-
292
- if should_execute
293
- # Execute outside the lock to avoid deadlock
294
- # Use ensure_execution_context to create a shared context if not set
295
- context = ensure_execution_context
296
- context.trigger_execution(@task.class, registry: @registry)
297
- # After execution returns, the task is completed
298
- end
285
+ trigger_and_wait(
286
+ state_accessor: -> { @state },
287
+ condition: @condition,
288
+ trigger: ->(ctx) { ctx.trigger_execution(@task.class, registry: @registry) }
289
+ )
299
290
  end
300
291
 
301
292
  ##
@@ -304,14 +295,27 @@ module Taski
304
295
  # If an ExecutionContext is configured the cleanup is invoked through it; otherwise a fallback executor is used.
305
296
  # @raise [Taski::TaskAbortException] if the registry has requested an abort.
306
297
  def trigger_clean_and_wait
298
+ trigger_and_wait(
299
+ state_accessor: -> { @clean_state },
300
+ condition: @clean_condition,
301
+ trigger: ->(ctx) { ctx.trigger_clean(@task.class, registry: @registry) }
302
+ )
303
+ end
304
+
305
+ # Generic trigger-and-wait implementation for both run and clean phases.
306
+ # @param state_accessor [Proc] Lambda returning the current state
307
+ # @param condition [MonitorMixin::ConditionVariable] Condition to wait on
308
+ # @param trigger [Proc] Lambda receiving context to trigger execution
309
+ # @raise [Taski::TaskAbortException] If the registry requested an abort
310
+ def trigger_and_wait(state_accessor:, condition:, trigger:)
307
311
  should_execute = false
308
312
  @monitor.synchronize do
309
- case @clean_state
313
+ case state_accessor.call
310
314
  when STATE_PENDING
311
315
  check_abort!
312
316
  should_execute = true
313
317
  when STATE_RUNNING
314
- @clean_condition.wait_until { @clean_state == STATE_COMPLETED }
318
+ condition.wait_until { state_accessor.call == STATE_COMPLETED }
315
319
  when STATE_COMPLETED
316
320
  # Already done
317
321
  end
@@ -319,9 +323,8 @@ module Taski
319
323
 
320
324
  if should_execute
321
325
  # Execute outside the lock to avoid deadlock
322
- # Use ensure_execution_context to reuse the context from run phase
323
326
  context = ensure_execution_context
324
- context.trigger_clean(@task.class, registry: @registry)
327
+ trigger.call(context)
325
328
  # After execution returns, the task is completed
326
329
  end
327
330
  end