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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +38 -0
- data/README.md +139 -9
- data/docs/GUIDE.md +54 -0
- data/examples/README.md +3 -3
- data/examples/args_demo.rb +21 -20
- data/examples/data_pipeline_demo.rb +1 -1
- data/examples/large_tree_demo.rb +519 -0
- data/examples/simple_progress_demo.rb +80 -0
- data/lib/taski/args.rb +2 -8
- data/lib/taski/env.rb +17 -0
- data/lib/taski/execution/base_progress_display.rb +348 -0
- data/lib/taski/execution/execution_context.rb +4 -0
- data/lib/taski/execution/executor.rb +111 -131
- data/lib/taski/execution/plain_progress_display.rb +76 -0
- data/lib/taski/execution/simple_progress_display.rb +173 -0
- data/lib/taski/execution/task_output_router.rb +91 -20
- data/lib/taski/execution/task_wrapper.rb +34 -31
- data/lib/taski/execution/tree_progress_display.rb +121 -271
- data/lib/taski/static_analysis/visitor.rb +3 -0
- data/lib/taski/task.rb +42 -30
- data/lib/taski/test_helper/errors.rb +13 -0
- data/lib/taski/test_helper/minitest.rb +38 -0
- data/lib/taski/test_helper/mock_registry.rb +53 -0
- data/lib/taski/test_helper/mock_wrapper.rb +46 -0
- data/lib/taski/test_helper/rspec.rb +38 -0
- data/lib/taski/test_helper.rb +246 -0
- data/lib/taski/version.rb +1 -1
- data/lib/taski.rb +119 -8
- metadata +14 -2
|
@@ -1,15 +1,27 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "monitor"
|
|
4
3
|
require "stringio"
|
|
4
|
+
require_relative "base_progress_display"
|
|
5
5
|
|
|
6
6
|
module Taski
|
|
7
7
|
module Execution
|
|
8
8
|
# Tree-based progress display that shows task execution in a tree structure
|
|
9
9
|
# similar to Task.tree, with real-time status updates and stdout capture.
|
|
10
|
-
class TreeProgressDisplay
|
|
10
|
+
class TreeProgressDisplay < BaseProgressDisplay
|
|
11
11
|
SPINNER_FRAMES = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
|
|
12
12
|
|
|
13
|
+
# Output display settings
|
|
14
|
+
OUTPUT_RESERVED_WIDTH = 30 # Characters reserved for tree structure
|
|
15
|
+
OUTPUT_MIN_LENGTH = 70 # Minimum visible output length
|
|
16
|
+
OUTPUT_SEPARATOR = " > " # Separator before task output
|
|
17
|
+
GROUP_SEPARATOR = " | " # Separator between group name and task name
|
|
18
|
+
TRUNCATION_ELLIPSIS = "..." # Ellipsis for truncated output
|
|
19
|
+
|
|
20
|
+
# Display settings
|
|
21
|
+
RENDER_INTERVAL = 0.1 # Seconds between display updates
|
|
22
|
+
DEFAULT_TERMINAL_WIDTH = 80 # Default terminal width when unknown
|
|
23
|
+
DEFAULT_TERMINAL_HEIGHT = 24 # Default terminal height when unknown
|
|
24
|
+
|
|
13
25
|
# ANSI color codes (matching Task.tree)
|
|
14
26
|
COLORS = {
|
|
15
27
|
reset: "\e[0m",
|
|
@@ -169,264 +181,54 @@ module Taski
|
|
|
169
181
|
end
|
|
170
182
|
end
|
|
171
183
|
|
|
172
|
-
# Tracks the progress of a group within a task
|
|
173
|
-
class GroupProgress
|
|
174
|
-
attr_accessor :name, :state, :start_time, :end_time, :duration, :error, :last_message
|
|
175
|
-
|
|
176
|
-
def initialize(name)
|
|
177
|
-
@name = name
|
|
178
|
-
@state = :pending
|
|
179
|
-
@start_time = nil
|
|
180
|
-
@end_time = nil
|
|
181
|
-
@duration = nil
|
|
182
|
-
@error = nil
|
|
183
|
-
@last_message = nil
|
|
184
|
-
end
|
|
185
|
-
end
|
|
186
|
-
|
|
187
|
-
class TaskProgress
|
|
188
|
-
# Run lifecycle tracking
|
|
189
|
-
attr_accessor :run_state, :run_start_time, :run_end_time, :run_error, :run_duration
|
|
190
|
-
# Clean lifecycle tracking
|
|
191
|
-
attr_accessor :clean_state, :clean_start_time, :clean_end_time, :clean_error, :clean_duration
|
|
192
|
-
# Display properties
|
|
193
|
-
attr_accessor :is_impl_candidate
|
|
194
|
-
# Group tracking
|
|
195
|
-
attr_accessor :groups, :current_group_index
|
|
196
|
-
|
|
197
|
-
def initialize
|
|
198
|
-
# Run lifecycle
|
|
199
|
-
@run_state = :pending
|
|
200
|
-
@run_start_time = nil
|
|
201
|
-
@run_end_time = nil
|
|
202
|
-
@run_error = nil
|
|
203
|
-
@run_duration = nil
|
|
204
|
-
# Clean lifecycle
|
|
205
|
-
@clean_state = nil # nil means clean hasn't started
|
|
206
|
-
@clean_start_time = nil
|
|
207
|
-
@clean_end_time = nil
|
|
208
|
-
@clean_error = nil
|
|
209
|
-
@clean_duration = nil
|
|
210
|
-
# Display
|
|
211
|
-
@is_impl_candidate = false
|
|
212
|
-
# Groups
|
|
213
|
-
@groups = []
|
|
214
|
-
@current_group_index = nil
|
|
215
|
-
end
|
|
216
|
-
|
|
217
|
-
# For backward compatibility - returns the most relevant state for display
|
|
218
|
-
def state
|
|
219
|
-
@clean_state || @run_state
|
|
220
|
-
end
|
|
221
|
-
|
|
222
|
-
# Legacy accessors for backward compatibility
|
|
223
|
-
def start_time
|
|
224
|
-
@clean_start_time || @run_start_time
|
|
225
|
-
end
|
|
226
|
-
|
|
227
|
-
def end_time
|
|
228
|
-
@clean_end_time || @run_end_time
|
|
229
|
-
end
|
|
230
|
-
|
|
231
|
-
def error
|
|
232
|
-
@clean_error || @run_error
|
|
233
|
-
end
|
|
234
|
-
|
|
235
|
-
def duration
|
|
236
|
-
@clean_duration || @run_duration
|
|
237
|
-
end
|
|
238
|
-
end
|
|
239
|
-
|
|
240
184
|
def initialize(output: $stdout)
|
|
241
|
-
|
|
242
|
-
@tasks = {}
|
|
243
|
-
@monitor = Monitor.new
|
|
185
|
+
super
|
|
244
186
|
@spinner_index = 0
|
|
245
187
|
@renderer_thread = nil
|
|
246
188
|
@running = false
|
|
247
|
-
@nest_level = 0 # Track nested executor calls
|
|
248
|
-
@root_task_class = nil
|
|
249
189
|
@tree_structure = nil
|
|
250
190
|
@section_impl_map = {} # Section -> selected impl class
|
|
251
|
-
@
|
|
191
|
+
@last_line_count = 0 # Track number of lines drawn for cursor movement
|
|
252
192
|
end
|
|
253
193
|
|
|
254
|
-
|
|
255
|
-
# @param capture [ThreadOutputCapture] The output capture instance
|
|
256
|
-
def set_output_capture(capture)
|
|
257
|
-
@monitor.synchronize do
|
|
258
|
-
@output_capture = capture
|
|
259
|
-
end
|
|
260
|
-
end
|
|
194
|
+
protected
|
|
261
195
|
|
|
262
|
-
#
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
def set_root_task(root_task_class)
|
|
266
|
-
@monitor.synchronize do
|
|
267
|
-
return if @root_task_class # Don't overwrite existing root task
|
|
268
|
-
@root_task_class = root_task_class
|
|
269
|
-
build_tree_structure
|
|
270
|
-
end
|
|
271
|
-
end
|
|
272
|
-
|
|
273
|
-
# Register which impl was selected for a section
|
|
274
|
-
# @param section_class [Class] The section class
|
|
275
|
-
# @param impl_class [Class] The selected implementation class
|
|
276
|
-
def register_section_impl(section_class, impl_class)
|
|
277
|
-
@monitor.synchronize do
|
|
278
|
-
@section_impl_map[section_class] = impl_class
|
|
279
|
-
end
|
|
280
|
-
end
|
|
281
|
-
|
|
282
|
-
# @param task_class [Class] The task class to register
|
|
283
|
-
def register_task(task_class)
|
|
284
|
-
@monitor.synchronize do
|
|
285
|
-
return if @tasks.key?(task_class)
|
|
286
|
-
@tasks[task_class] = TaskProgress.new
|
|
287
|
-
end
|
|
196
|
+
# Template method: Called when root task is set
|
|
197
|
+
def on_root_task_set
|
|
198
|
+
build_tree_structure
|
|
288
199
|
end
|
|
289
200
|
|
|
290
|
-
#
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
@monitor.synchronize do
|
|
294
|
-
@tasks.key?(task_class)
|
|
295
|
-
end
|
|
201
|
+
# Template method: Called when a section impl is registered
|
|
202
|
+
def on_section_impl_registered(section_class, impl_class)
|
|
203
|
+
@section_impl_map[section_class] = impl_class
|
|
296
204
|
end
|
|
297
205
|
|
|
298
|
-
#
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
# @param error [Exception] Error object (for failed tasks)
|
|
302
|
-
def update_task(task_class, state:, duration: nil, error: nil)
|
|
303
|
-
@monitor.synchronize do
|
|
304
|
-
progress = @tasks[task_class]
|
|
305
|
-
return unless progress
|
|
306
|
-
|
|
307
|
-
case state
|
|
308
|
-
# Run lifecycle states
|
|
309
|
-
when :pending
|
|
310
|
-
progress.run_state = :pending
|
|
311
|
-
when :running
|
|
312
|
-
progress.run_state = :running
|
|
313
|
-
progress.run_start_time = Time.now
|
|
314
|
-
when :completed
|
|
315
|
-
progress.run_state = :completed
|
|
316
|
-
progress.run_end_time = Time.now
|
|
317
|
-
progress.run_duration = duration if duration
|
|
318
|
-
when :failed
|
|
319
|
-
progress.run_state = :failed
|
|
320
|
-
progress.run_end_time = Time.now
|
|
321
|
-
progress.run_error = error if error
|
|
322
|
-
# Clean lifecycle states
|
|
323
|
-
when :cleaning
|
|
324
|
-
progress.clean_state = :cleaning
|
|
325
|
-
progress.clean_start_time = Time.now
|
|
326
|
-
when :clean_completed
|
|
327
|
-
progress.clean_state = :clean_completed
|
|
328
|
-
progress.clean_end_time = Time.now
|
|
329
|
-
progress.clean_duration = duration if duration
|
|
330
|
-
when :clean_failed
|
|
331
|
-
progress.clean_state = :clean_failed
|
|
332
|
-
progress.clean_end_time = Time.now
|
|
333
|
-
progress.clean_error = error if error
|
|
334
|
-
end
|
|
335
|
-
end
|
|
336
|
-
end
|
|
337
|
-
|
|
338
|
-
# @param task_class [Class] The task class
|
|
339
|
-
# @return [Symbol] The task state
|
|
340
|
-
def task_state(task_class)
|
|
341
|
-
@monitor.synchronize do
|
|
342
|
-
@tasks[task_class]&.state
|
|
343
|
-
end
|
|
344
|
-
end
|
|
345
|
-
|
|
346
|
-
# Update group state for a task.
|
|
347
|
-
# Called by ExecutionContext when group lifecycle events occur.
|
|
348
|
-
#
|
|
349
|
-
# @param task_class [Class] The task class containing the group
|
|
350
|
-
# @param group_name [String] The name of the group
|
|
351
|
-
# @param state [Symbol] The new state (:running, :completed, :failed)
|
|
352
|
-
# @param duration [Float, nil] Duration in milliseconds (for completed groups)
|
|
353
|
-
# @param error [Exception, nil] Error object (for failed groups)
|
|
354
|
-
def update_group(task_class, group_name, state:, duration: nil, error: nil)
|
|
355
|
-
@monitor.synchronize do
|
|
356
|
-
progress = @tasks[task_class]
|
|
357
|
-
return unless progress
|
|
358
|
-
|
|
359
|
-
case state
|
|
360
|
-
when :running
|
|
361
|
-
# Create new group and set as current
|
|
362
|
-
group = GroupProgress.new(group_name)
|
|
363
|
-
group.state = :running
|
|
364
|
-
group.start_time = Time.now
|
|
365
|
-
progress.groups << group
|
|
366
|
-
progress.current_group_index = progress.groups.size - 1
|
|
367
|
-
when :completed
|
|
368
|
-
# Find the group by name and mark completed
|
|
369
|
-
group = progress.groups.find { |g| g.name == group_name && g.state == :running }
|
|
370
|
-
if group
|
|
371
|
-
group.state = :completed
|
|
372
|
-
group.end_time = Time.now
|
|
373
|
-
group.duration = duration
|
|
374
|
-
end
|
|
375
|
-
progress.current_group_index = nil
|
|
376
|
-
when :failed
|
|
377
|
-
# Find the group by name and mark failed
|
|
378
|
-
group = progress.groups.find { |g| g.name == group_name && g.state == :running }
|
|
379
|
-
if group
|
|
380
|
-
group.state = :failed
|
|
381
|
-
group.end_time = Time.now
|
|
382
|
-
group.duration = duration
|
|
383
|
-
group.error = error
|
|
384
|
-
end
|
|
385
|
-
progress.current_group_index = nil
|
|
386
|
-
end
|
|
387
|
-
end
|
|
206
|
+
# Template method: Determine if display should activate
|
|
207
|
+
def should_activate?
|
|
208
|
+
tty?
|
|
388
209
|
end
|
|
389
210
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
@
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
return unless @output.tty?
|
|
397
|
-
|
|
398
|
-
@running = true
|
|
399
|
-
should_start = true
|
|
400
|
-
end
|
|
401
|
-
|
|
402
|
-
return unless should_start
|
|
403
|
-
|
|
404
|
-
@output.print "\e[?25l" # Hide cursor
|
|
405
|
-
@output.print "\e7" # Save cursor position (before any tree output)
|
|
211
|
+
# Template method: Called when display starts
|
|
212
|
+
def on_start
|
|
213
|
+
@running = true
|
|
214
|
+
@output.print "\e[?1049h" # Switch to alternate screen buffer
|
|
215
|
+
@output.print "\e[H" # Move cursor to home (top-left)
|
|
216
|
+
@output.print "\e[?25l" # Hide cursor
|
|
406
217
|
@renderer_thread = Thread.new do
|
|
407
218
|
loop do
|
|
408
219
|
break unless @running
|
|
409
220
|
render_live
|
|
410
|
-
sleep
|
|
221
|
+
sleep RENDER_INTERVAL
|
|
411
222
|
end
|
|
412
223
|
end
|
|
413
224
|
end
|
|
414
225
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
@
|
|
418
|
-
@nest_level -= 1 if @nest_level > 0
|
|
419
|
-
return unless @nest_level == 0
|
|
420
|
-
return unless @running
|
|
421
|
-
|
|
422
|
-
@running = false
|
|
423
|
-
should_stop = true
|
|
424
|
-
end
|
|
425
|
-
|
|
426
|
-
return unless should_stop
|
|
427
|
-
|
|
226
|
+
# Template method: Called when display stops
|
|
227
|
+
def on_stop
|
|
228
|
+
@running = false
|
|
428
229
|
@renderer_thread&.join
|
|
429
|
-
@output.print "\e[?25h"
|
|
230
|
+
@output.print "\e[?25h" # Show cursor
|
|
231
|
+
@output.print "\e[?1049l" # Switch back to main screen buffer
|
|
430
232
|
render_final
|
|
431
233
|
end
|
|
432
234
|
|
|
@@ -440,21 +242,6 @@ module Taski
|
|
|
440
242
|
register_tasks_from_tree(@tree_structure)
|
|
441
243
|
end
|
|
442
244
|
|
|
443
|
-
# Register all tasks from tree structure
|
|
444
|
-
def register_tasks_from_tree(node)
|
|
445
|
-
return unless node
|
|
446
|
-
|
|
447
|
-
task_class = node[:task_class]
|
|
448
|
-
register_task(task_class)
|
|
449
|
-
|
|
450
|
-
# Mark as impl candidate if applicable
|
|
451
|
-
if node[:is_impl_candidate]
|
|
452
|
-
@tasks[task_class].is_impl_candidate = true
|
|
453
|
-
end
|
|
454
|
-
|
|
455
|
-
node[:children].each { |child| register_tasks_from_tree(child) }
|
|
456
|
-
end
|
|
457
|
-
|
|
458
245
|
def render_live
|
|
459
246
|
# Poll for new output from task pipes
|
|
460
247
|
@output_capture&.poll
|
|
@@ -468,32 +255,82 @@ module Taski
|
|
|
468
255
|
|
|
469
256
|
return if lines.nil? || lines.empty?
|
|
470
257
|
|
|
471
|
-
#
|
|
472
|
-
|
|
473
|
-
|
|
258
|
+
# Build complete frame in buffer for single write (flicker-free)
|
|
259
|
+
buffer = build_frame_buffer(lines)
|
|
260
|
+
|
|
261
|
+
# Write entire frame in single operation
|
|
262
|
+
@output.print buffer
|
|
263
|
+
@output.flush
|
|
264
|
+
|
|
265
|
+
@last_line_count = lines.size
|
|
266
|
+
end
|
|
474
267
|
|
|
475
|
-
|
|
268
|
+
##
|
|
269
|
+
# Builds a complete frame buffer for flicker-free rendering.
|
|
270
|
+
# Uses cursor home positioning and line-by-line overwrite instead of clear.
|
|
271
|
+
# @param lines [Array<String>] The lines to render.
|
|
272
|
+
# @return [String] The complete frame buffer ready for single write.
|
|
273
|
+
def build_frame_buffer(lines)
|
|
274
|
+
buffer = +""
|
|
275
|
+
|
|
276
|
+
# Move cursor to home position (top-left) for overwrite
|
|
277
|
+
buffer << "\e[H"
|
|
278
|
+
|
|
279
|
+
# Build each line with clear-to-end-of-line for clean overwrite
|
|
476
280
|
lines.each do |line|
|
|
477
|
-
|
|
281
|
+
buffer << line
|
|
282
|
+
buffer << "\e[K" # Clear from cursor to end of line (removes old content)
|
|
283
|
+
buffer << "\n"
|
|
478
284
|
end
|
|
479
285
|
|
|
480
|
-
|
|
286
|
+
# Clear any extra lines from previous render if current has fewer lines
|
|
287
|
+
if @last_line_count > lines.size
|
|
288
|
+
(@last_line_count - lines.size).times do
|
|
289
|
+
buffer << "\e[K\n" # Clear line and move to next
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
buffer
|
|
481
294
|
end
|
|
482
295
|
|
|
483
296
|
def render_final
|
|
484
297
|
@monitor.synchronize do
|
|
485
|
-
|
|
486
|
-
return if lines.empty?
|
|
298
|
+
return unless @root_task_class
|
|
487
299
|
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
@output.print "\e[J" # Clear from cursor to end of screen
|
|
300
|
+
root_progress = @tasks[@root_task_class]
|
|
301
|
+
return unless root_progress
|
|
491
302
|
|
|
492
|
-
# Print
|
|
493
|
-
|
|
303
|
+
# Print single summary line instead of full tree
|
|
304
|
+
@output.puts build_summary_line(@root_task_class, root_progress)
|
|
494
305
|
end
|
|
495
306
|
end
|
|
496
307
|
|
|
308
|
+
def build_summary_line(task_class, progress)
|
|
309
|
+
# Determine overall status and icon
|
|
310
|
+
if progress.run_state == :failed || progress.clean_state == :clean_failed
|
|
311
|
+
icon = "#{COLORS[:error]}#{ICONS[:failed]}#{COLORS[:reset]}"
|
|
312
|
+
status = "#{COLORS[:error]}failed#{COLORS[:reset]}"
|
|
313
|
+
else
|
|
314
|
+
icon = "#{COLORS[:success]}#{ICONS[:completed]}#{COLORS[:reset]}"
|
|
315
|
+
status = "#{COLORS[:success]}completed#{COLORS[:reset]}"
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
name = "#{COLORS[:name]}#{task_class.name}#{COLORS[:reset]}"
|
|
319
|
+
|
|
320
|
+
# Calculate total duration
|
|
321
|
+
duration_str = ""
|
|
322
|
+
if progress.run_duration
|
|
323
|
+
duration_str = " (#{progress.run_duration}ms)"
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# Count completed tasks
|
|
327
|
+
completed_count = @tasks.values.count { |p| p.run_state == :completed }
|
|
328
|
+
total_count = @tasks.values.count { |p| p.run_state != :pending || p == progress }
|
|
329
|
+
task_count_str = " [#{completed_count}/#{total_count} tasks]"
|
|
330
|
+
|
|
331
|
+
"#{icon} #{name} #{status}#{duration_str}#{task_count_str}"
|
|
332
|
+
end
|
|
333
|
+
|
|
497
334
|
# Build display lines from tree structure
|
|
498
335
|
def build_tree_display
|
|
499
336
|
return [] unless @tree_structure
|
|
@@ -739,22 +576,22 @@ module Taski
|
|
|
739
576
|
group_prefix = ""
|
|
740
577
|
if progress&.current_group_index
|
|
741
578
|
current_group = progress.groups[progress.current_group_index]
|
|
742
|
-
group_prefix = "#{current_group.name}
|
|
579
|
+
group_prefix = "#{current_group.name}#{GROUP_SEPARATOR}" if current_group
|
|
743
580
|
end
|
|
744
581
|
|
|
745
582
|
# Truncate if too long (leave space for tree structure)
|
|
746
583
|
terminal_cols = terminal_width
|
|
747
|
-
max_output_length = terminal_cols -
|
|
748
|
-
max_output_length =
|
|
584
|
+
max_output_length = terminal_cols - OUTPUT_RESERVED_WIDTH
|
|
585
|
+
max_output_length = OUTPUT_MIN_LENGTH if max_output_length < OUTPUT_MIN_LENGTH
|
|
749
586
|
|
|
750
587
|
full_output = "#{group_prefix}#{last_line}"
|
|
751
588
|
truncated = if full_output.length > max_output_length
|
|
752
|
-
full_output[0, max_output_length -
|
|
589
|
+
full_output[0, max_output_length - TRUNCATION_ELLIPSIS.length] + TRUNCATION_ELLIPSIS
|
|
753
590
|
else
|
|
754
591
|
full_output
|
|
755
592
|
end
|
|
756
593
|
|
|
757
|
-
"
|
|
594
|
+
"#{COLORS[:dim]}#{OUTPUT_SEPARATOR}#{truncated}#{COLORS[:reset]}"
|
|
758
595
|
end
|
|
759
596
|
|
|
760
597
|
##
|
|
@@ -764,9 +601,22 @@ module Taski
|
|
|
764
601
|
def terminal_width
|
|
765
602
|
if @output.respond_to?(:winsize)
|
|
766
603
|
_, cols = @output.winsize
|
|
767
|
-
cols ||
|
|
604
|
+
cols || DEFAULT_TERMINAL_WIDTH
|
|
605
|
+
else
|
|
606
|
+
DEFAULT_TERMINAL_WIDTH
|
|
607
|
+
end
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
##
|
|
611
|
+
# Returns the terminal height in rows.
|
|
612
|
+
# Defaults to 24 if the output IO doesn't support winsize.
|
|
613
|
+
# @return [Integer] The terminal height in rows.
|
|
614
|
+
def terminal_height
|
|
615
|
+
if @output.respond_to?(:winsize)
|
|
616
|
+
rows, _ = @output.winsize
|
|
617
|
+
rows || DEFAULT_TERMINAL_HEIGHT
|
|
768
618
|
else
|
|
769
|
-
|
|
619
|
+
DEFAULT_TERMINAL_HEIGHT
|
|
770
620
|
end
|
|
771
621
|
end
|
|
772
622
|
|
|
@@ -92,6 +92,9 @@ module Taski
|
|
|
92
92
|
# Re-analyze the class methods
|
|
93
93
|
# Preserve impl chain context: methods called from impl should continue
|
|
94
94
|
# detecting constants as impl candidates
|
|
95
|
+
# Set namespace path from target class name for constant resolution
|
|
96
|
+
@current_namespace_path = @target_task_class.name.split("::")
|
|
97
|
+
|
|
95
98
|
@class_method_defs.each do |method_name, method_node|
|
|
96
99
|
next unless new_methods.include?(method_name)
|
|
97
100
|
|
data/lib/taski/task.rb
CHANGED
|
@@ -50,11 +50,24 @@ module Taski
|
|
|
50
50
|
end
|
|
51
51
|
|
|
52
52
|
##
|
|
53
|
-
# Creates a
|
|
54
|
-
# Use class methods (e.g., MyTask.
|
|
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.
|
|
55
57
|
# @return [Execution::TaskWrapper] A new wrapper for this task.
|
|
56
|
-
def new
|
|
57
|
-
|
|
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
|
|
58
71
|
end
|
|
59
72
|
|
|
60
73
|
##
|
|
@@ -82,12 +95,7 @@ module Taski
|
|
|
82
95
|
# @raise [ArgumentError] If workers is not a positive integer or nil.
|
|
83
96
|
# @return [Object] The result of task execution.
|
|
84
97
|
def run(args: {}, workers: nil)
|
|
85
|
-
|
|
86
|
-
Taski.start_args(options: args.merge(_workers: workers), root_task: self)
|
|
87
|
-
validate_no_circular_dependencies!
|
|
88
|
-
fresh_wrapper.run
|
|
89
|
-
ensure
|
|
90
|
-
Taski.reset_args!
|
|
98
|
+
with_execution_setup(args: args, workers: workers) { |wrapper| wrapper.run }
|
|
91
99
|
end
|
|
92
100
|
|
|
93
101
|
##
|
|
@@ -99,12 +107,7 @@ module Taski
|
|
|
99
107
|
# Must be a positive integer or nil.
|
|
100
108
|
# @raise [ArgumentError] If workers is not a positive integer or nil.
|
|
101
109
|
def clean(args: {}, workers: nil)
|
|
102
|
-
|
|
103
|
-
Taski.start_args(options: args.merge(_workers: workers), root_task: self)
|
|
104
|
-
validate_no_circular_dependencies!
|
|
105
|
-
fresh_wrapper.clean
|
|
106
|
-
ensure
|
|
107
|
-
Taski.reset_args!
|
|
110
|
+
with_execution_setup(args: args, workers: workers) { |wrapper| wrapper.clean }
|
|
108
111
|
end
|
|
109
112
|
|
|
110
113
|
##
|
|
@@ -118,12 +121,7 @@ module Taski
|
|
|
118
121
|
# @raise [ArgumentError] If workers is not a positive integer or nil.
|
|
119
122
|
# @return [Object] The result of task execution
|
|
120
123
|
def run_and_clean(args: {}, workers: nil)
|
|
121
|
-
|
|
122
|
-
Taski.start_args(options: args.merge(_workers: workers), root_task: self)
|
|
123
|
-
validate_no_circular_dependencies!
|
|
124
|
-
fresh_wrapper.run_and_clean
|
|
125
|
-
ensure
|
|
126
|
-
Taski.reset_args!
|
|
124
|
+
with_execution_setup(args: args, workers: workers) { |wrapper| wrapper.run_and_clean }
|
|
127
125
|
end
|
|
128
126
|
|
|
129
127
|
##
|
|
@@ -144,6 +142,23 @@ module Taski
|
|
|
144
142
|
|
|
145
143
|
private
|
|
146
144
|
|
|
145
|
+
##
|
|
146
|
+
# Sets up execution environment and yields a fresh wrapper.
|
|
147
|
+
# Handles workers validation, args lifecycle, and dependency validation.
|
|
148
|
+
# @param args [Hash] User-defined arguments
|
|
149
|
+
# @param workers [Integer, nil] Number of worker threads
|
|
150
|
+
# @yield [wrapper] Block receiving the fresh wrapper to execute
|
|
151
|
+
# @return [Object] The result of the block
|
|
152
|
+
def with_execution_setup(args:, workers:)
|
|
153
|
+
validate_workers!(workers)
|
|
154
|
+
Taski.send(:with_env, root_task: self) do
|
|
155
|
+
Taski.send(:with_args, options: args.merge(_workers: workers)) do
|
|
156
|
+
validate_no_circular_dependencies!
|
|
157
|
+
yield fresh_wrapper
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
147
162
|
##
|
|
148
163
|
# Creates a fresh TaskWrapper with its own registry.
|
|
149
164
|
# Used for class method execution (Task.run) where each call is independent.
|
|
@@ -199,14 +214,11 @@ module Taski
|
|
|
199
214
|
wrapper.get_exported_value(method)
|
|
200
215
|
else
|
|
201
216
|
# Outside execution - fresh execution (top-level call)
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
ensure
|
|
208
|
-
# Only reset if we set args
|
|
209
|
-
Taski.reset_args! if args_was_nil
|
|
217
|
+
Taski.send(:with_env, root_task: self) do
|
|
218
|
+
Taski.send(:with_args, options: {}) do
|
|
219
|
+
validate_no_circular_dependencies!
|
|
220
|
+
fresh_wrapper.get_exported_value(method)
|
|
221
|
+
end
|
|
210
222
|
end
|
|
211
223
|
end
|
|
212
224
|
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Taski
|
|
4
|
+
module TestHelper
|
|
5
|
+
# Raised when attempting to mock a class that is not a Taski::Task or Taski::Section subclass.
|
|
6
|
+
class InvalidTaskError < ArgumentError
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
# Raised when attempting to mock a method that is not an exported method of the task.
|
|
10
|
+
class InvalidMethodError < ArgumentError
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../test_helper"
|
|
4
|
+
|
|
5
|
+
module Taski
|
|
6
|
+
module TestHelper
|
|
7
|
+
# Minitest integration module.
|
|
8
|
+
# Include this in your test class for automatic mock cleanup.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# class MyTaskTest < Minitest::Test
|
|
12
|
+
# include Taski::TestHelper::Minitest
|
|
13
|
+
#
|
|
14
|
+
# def test_something
|
|
15
|
+
# mock_task(FetchData, result: "mocked")
|
|
16
|
+
# # ... test code ...
|
|
17
|
+
# end
|
|
18
|
+
# # Mocks are automatically cleaned up after each test
|
|
19
|
+
# end
|
|
20
|
+
module Minitest
|
|
21
|
+
def self.included(base)
|
|
22
|
+
base.include(Taski::TestHelper)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Reset mocks before each test to ensure clean state.
|
|
26
|
+
def setup
|
|
27
|
+
super
|
|
28
|
+
Taski::TestHelper.reset_mocks!
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Reset mocks after each test to prevent pollution.
|
|
32
|
+
def teardown
|
|
33
|
+
Taski::TestHelper.reset_mocks!
|
|
34
|
+
super
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|