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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +50 -0
- data/README.md +168 -21
- data/docs/GUIDE.md +394 -0
- data/examples/README.md +65 -17
- data/examples/{context_demo.rb → args_demo.rb} +27 -27
- data/examples/clean_demo.rb +204 -0
- data/examples/data_pipeline_demo.rb +1 -1
- data/examples/group_demo.rb +113 -0
- data/examples/large_tree_demo.rb +519 -0
- data/examples/reexecution_demo.rb +93 -80
- data/examples/simple_progress_demo.rb +80 -0
- data/examples/system_call_demo.rb +56 -0
- data/lib/taski/{context.rb → args.rb} +3 -3
- data/lib/taski/execution/base_progress_display.rb +348 -0
- data/lib/taski/execution/execution_context.rb +383 -0
- data/lib/taski/execution/executor.rb +405 -134
- data/lib/taski/execution/plain_progress_display.rb +76 -0
- data/lib/taski/execution/registry.rb +17 -1
- data/lib/taski/execution/scheduler.rb +308 -0
- data/lib/taski/execution/simple_progress_display.rb +173 -0
- data/lib/taski/execution/task_output_pipe.rb +42 -0
- data/lib/taski/execution/task_output_router.rb +287 -0
- data/lib/taski/execution/task_wrapper.rb +215 -52
- data/lib/taski/execution/tree_progress_display.rb +349 -212
- data/lib/taski/execution/worker_pool.rb +104 -0
- data/lib/taski/section.rb +16 -3
- data/lib/taski/static_analysis/visitor.rb +3 -0
- data/lib/taski/task.rb +218 -37
- 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 +51 -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 +214 -0
- data/lib/taski/version.rb +1 -1
- data/lib/taski.rb +211 -23
- data/sig/taski.rbs +207 -27
- metadata +25 -8
- data/docs/advanced-features.md +0 -625
- data/docs/api-guide.md +0 -509
- data/docs/error-handling.md +0 -684
- data/examples/section_progress_demo.rb +0 -78
|
@@ -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",
|
|
@@ -27,49 +39,105 @@ module Taski
|
|
|
27
39
|
|
|
28
40
|
# Status icons
|
|
29
41
|
ICONS = {
|
|
42
|
+
# Run lifecycle states
|
|
30
43
|
pending: "⏸", # Pause for waiting
|
|
31
44
|
running_prefix: "", # Will use spinner
|
|
32
45
|
completed: "✓",
|
|
33
46
|
failed: "✗",
|
|
34
|
-
skipped: "⊘"
|
|
47
|
+
skipped: "⊘", # Prohibition sign for unselected impl candidates
|
|
48
|
+
# Clean lifecycle states
|
|
49
|
+
cleaning_prefix: "", # Will use spinner
|
|
50
|
+
clean_completed: "♻",
|
|
51
|
+
clean_failed: "✗"
|
|
35
52
|
}.freeze
|
|
36
53
|
|
|
37
|
-
|
|
54
|
+
##
|
|
55
|
+
# Checks if a class is a Taski::Section subclass.
|
|
56
|
+
# @param klass [Class] The class to check.
|
|
57
|
+
# @return [Boolean] true if the class is a Section.
|
|
38
58
|
def self.section_class?(klass)
|
|
39
59
|
defined?(Taski::Section) && klass < Taski::Section
|
|
40
60
|
end
|
|
41
61
|
|
|
62
|
+
##
|
|
63
|
+
# Checks if a class is nested within another class by name prefix.
|
|
64
|
+
# @param child_class [Class] The potential nested class.
|
|
65
|
+
# @param parent_class [Class] The potential parent class.
|
|
66
|
+
# @return [Boolean] true if child_class name starts with parent_class name and "::".
|
|
42
67
|
def self.nested_class?(child_class, parent_class)
|
|
43
68
|
child_name = child_class.name.to_s
|
|
44
69
|
parent_name = parent_class.name.to_s
|
|
45
70
|
child_name.start_with?("#{parent_name}::")
|
|
46
71
|
end
|
|
47
72
|
|
|
73
|
+
# Build a tree structure from a root task class.
|
|
74
|
+
# This is the shared tree building logic used by both static and progress display.
|
|
75
|
+
#
|
|
76
|
+
# @param task_class [Class] The task class to build tree for
|
|
77
|
+
# @param ancestors [Set] Set of ancestor task classes for circular detection
|
|
78
|
+
# @return [Hash, nil] Tree node hash or nil if circular
|
|
79
|
+
#
|
|
80
|
+
# Tree node structure:
|
|
81
|
+
# {
|
|
82
|
+
# task_class: Class, # The task class
|
|
83
|
+
# is_section: Boolean, # Whether this is a Section
|
|
84
|
+
# is_circular: Boolean, # Whether this is a circular reference
|
|
85
|
+
# is_impl_candidate: Boolean, # Whether this is an impl candidate
|
|
86
|
+
# children: Array<Hash> # Child nodes
|
|
87
|
+
# }
|
|
88
|
+
def self.build_tree_node(task_class, ancestors = Set.new)
|
|
89
|
+
is_circular = ancestors.include?(task_class)
|
|
90
|
+
|
|
91
|
+
node = {
|
|
92
|
+
task_class: task_class,
|
|
93
|
+
is_section: section_class?(task_class),
|
|
94
|
+
is_circular: is_circular,
|
|
95
|
+
is_impl_candidate: false,
|
|
96
|
+
children: []
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
# Don't traverse children for circular references
|
|
100
|
+
return node if is_circular
|
|
101
|
+
|
|
102
|
+
new_ancestors = ancestors + [task_class]
|
|
103
|
+
dependencies = StaticAnalysis::Analyzer.analyze(task_class).to_a
|
|
104
|
+
is_section = section_class?(task_class)
|
|
105
|
+
|
|
106
|
+
dependencies.each do |dep|
|
|
107
|
+
child_node = build_tree_node(dep, new_ancestors)
|
|
108
|
+
child_node[:is_impl_candidate] = is_section && nested_class?(dep, task_class)
|
|
109
|
+
node[:children] << child_node
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
node
|
|
113
|
+
end
|
|
114
|
+
|
|
48
115
|
# Render a static tree structure for a task class (used by Task.tree)
|
|
49
116
|
# @param root_task_class [Class] The root task class
|
|
50
117
|
# @return [String] The rendered tree string
|
|
51
118
|
def self.render_static_tree(root_task_class)
|
|
52
|
-
|
|
53
|
-
|
|
119
|
+
tree = build_tree_node(root_task_class)
|
|
120
|
+
formatter = StaticTreeFormatter.new
|
|
121
|
+
formatter.format(tree)
|
|
54
122
|
end
|
|
55
123
|
|
|
56
|
-
#
|
|
57
|
-
class
|
|
58
|
-
def
|
|
124
|
+
# Formatter for static tree display (no progress tracking, uses task numbers)
|
|
125
|
+
class StaticTreeFormatter
|
|
126
|
+
def format(tree)
|
|
59
127
|
@task_index_map = {}
|
|
60
|
-
|
|
128
|
+
format_node(tree, "", false)
|
|
61
129
|
end
|
|
62
130
|
|
|
63
131
|
private
|
|
64
132
|
|
|
65
|
-
def
|
|
133
|
+
def format_node(node, prefix, is_impl)
|
|
134
|
+
task_class = node[:task_class]
|
|
66
135
|
type_label = colored_type_label(task_class)
|
|
67
136
|
impl_prefix = is_impl ? "#{COLORS[:impl]}[impl]#{COLORS[:reset]} " : ""
|
|
68
137
|
task_number = get_task_number(task_class)
|
|
69
138
|
name = "#{COLORS[:name]}#{task_class.name}#{COLORS[:reset]}"
|
|
70
139
|
|
|
71
|
-
|
|
72
|
-
if ancestors.include?(task_class)
|
|
140
|
+
if node[:is_circular]
|
|
73
141
|
circular_marker = "#{COLORS[:impl]}(circular)#{COLORS[:reset]}"
|
|
74
142
|
return "#{impl_prefix}#{task_number} #{name} #{type_label} #{circular_marker}\n"
|
|
75
143
|
end
|
|
@@ -79,26 +147,21 @@ module Taski
|
|
|
79
147
|
# Register task number if not already registered
|
|
80
148
|
@task_index_map[task_class] = @task_index_map.size + 1 unless @task_index_map.key?(task_class)
|
|
81
149
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
dependencies.each_with_index do |dep, index|
|
|
87
|
-
is_last = (index == dependencies.size - 1)
|
|
88
|
-
is_impl_candidate = is_section && TreeProgressDisplay.nested_class?(dep, task_class)
|
|
89
|
-
result += render_dependency_branch(dep, prefix, is_last, is_impl_candidate, new_ancestors)
|
|
150
|
+
node[:children].each_with_index do |child, index|
|
|
151
|
+
is_last = (index == node[:children].size - 1)
|
|
152
|
+
result += format_child_branch(child, prefix, is_last)
|
|
90
153
|
end
|
|
91
154
|
|
|
92
155
|
result
|
|
93
156
|
end
|
|
94
157
|
|
|
95
|
-
def
|
|
158
|
+
def format_child_branch(child, prefix, is_last)
|
|
96
159
|
connector = is_last ? "└── " : "├── "
|
|
97
160
|
extension = is_last ? " " : "│ "
|
|
98
|
-
|
|
161
|
+
child_tree = format_node(child, "#{prefix}#{extension}", child[:is_impl_candidate])
|
|
99
162
|
|
|
100
163
|
result = "#{prefix}#{COLORS[:tree]}#{connector}#{COLORS[:reset]}"
|
|
101
|
-
lines =
|
|
164
|
+
lines = child_tree.lines
|
|
102
165
|
result += lines.first
|
|
103
166
|
lines.drop(1).each { |line| result += line }
|
|
104
167
|
result
|
|
@@ -118,141 +181,54 @@ module Taski
|
|
|
118
181
|
end
|
|
119
182
|
end
|
|
120
183
|
|
|
121
|
-
class TaskProgress
|
|
122
|
-
attr_accessor :state, :start_time, :end_time, :error, :duration
|
|
123
|
-
attr_accessor :is_impl_candidate
|
|
124
|
-
|
|
125
|
-
def initialize
|
|
126
|
-
@state = :pending
|
|
127
|
-
@start_time = nil
|
|
128
|
-
@end_time = nil
|
|
129
|
-
@error = nil
|
|
130
|
-
@duration = nil
|
|
131
|
-
@is_impl_candidate = false
|
|
132
|
-
end
|
|
133
|
-
end
|
|
134
|
-
|
|
135
184
|
def initialize(output: $stdout)
|
|
136
|
-
|
|
137
|
-
@tasks = {}
|
|
138
|
-
@monitor = Monitor.new
|
|
185
|
+
super
|
|
139
186
|
@spinner_index = 0
|
|
140
187
|
@renderer_thread = nil
|
|
141
188
|
@running = false
|
|
142
|
-
@nest_level = 0 # Track nested executor calls
|
|
143
|
-
@root_task_class = nil
|
|
144
189
|
@tree_structure = nil
|
|
145
190
|
@section_impl_map = {} # Section -> selected impl class
|
|
146
|
-
@last_line_count = 0
|
|
191
|
+
@last_line_count = 0 # Track number of lines drawn for cursor movement
|
|
147
192
|
end
|
|
148
193
|
|
|
149
|
-
|
|
150
|
-
# Only sets root task if not already set (prevents nested executor overwrite)
|
|
151
|
-
# @param root_task_class [Class] The root task class
|
|
152
|
-
def set_root_task(root_task_class)
|
|
153
|
-
@monitor.synchronize do
|
|
154
|
-
return if @root_task_class # Don't overwrite existing root task
|
|
155
|
-
@root_task_class = root_task_class
|
|
156
|
-
build_tree_structure
|
|
157
|
-
end
|
|
158
|
-
end
|
|
194
|
+
protected
|
|
159
195
|
|
|
160
|
-
#
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
def register_section_impl(section_class, impl_class)
|
|
164
|
-
@monitor.synchronize do
|
|
165
|
-
@section_impl_map[section_class] = impl_class
|
|
166
|
-
end
|
|
196
|
+
# Template method: Called when root task is set
|
|
197
|
+
def on_root_task_set
|
|
198
|
+
build_tree_structure
|
|
167
199
|
end
|
|
168
200
|
|
|
169
|
-
#
|
|
170
|
-
def
|
|
171
|
-
@
|
|
172
|
-
return if @tasks.key?(task_class)
|
|
173
|
-
@tasks[task_class] = TaskProgress.new
|
|
174
|
-
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
|
|
175
204
|
end
|
|
176
205
|
|
|
177
|
-
#
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
@monitor.synchronize do
|
|
181
|
-
@tasks.key?(task_class)
|
|
182
|
-
end
|
|
206
|
+
# Template method: Determine if display should activate
|
|
207
|
+
def should_activate?
|
|
208
|
+
tty?
|
|
183
209
|
end
|
|
184
210
|
|
|
185
|
-
#
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
@
|
|
191
|
-
progress = @tasks[task_class]
|
|
192
|
-
return unless progress
|
|
193
|
-
|
|
194
|
-
progress.state = state
|
|
195
|
-
progress.duration = duration if duration
|
|
196
|
-
progress.error = error if error
|
|
197
|
-
|
|
198
|
-
case state
|
|
199
|
-
when :running
|
|
200
|
-
progress.start_time = Time.now
|
|
201
|
-
when :completed, :failed
|
|
202
|
-
progress.end_time = Time.now
|
|
203
|
-
end
|
|
204
|
-
end
|
|
205
|
-
end
|
|
206
|
-
|
|
207
|
-
# @param task_class [Class] The task class
|
|
208
|
-
# @return [Symbol] The task state
|
|
209
|
-
def task_state(task_class)
|
|
210
|
-
@monitor.synchronize do
|
|
211
|
-
@tasks[task_class]&.state
|
|
212
|
-
end
|
|
213
|
-
end
|
|
214
|
-
|
|
215
|
-
def start
|
|
216
|
-
should_start = false
|
|
217
|
-
@monitor.synchronize do
|
|
218
|
-
@nest_level += 1
|
|
219
|
-
return if @nest_level > 1 # Already running from outer executor
|
|
220
|
-
return if @running
|
|
221
|
-
return unless @output.tty?
|
|
222
|
-
|
|
223
|
-
@running = true
|
|
224
|
-
should_start = true
|
|
225
|
-
end
|
|
226
|
-
|
|
227
|
-
return unless should_start
|
|
228
|
-
|
|
229
|
-
# Hide cursor (outside monitor to avoid holding lock during I/O)
|
|
230
|
-
@output.print "\e[?25l"
|
|
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
|
|
231
217
|
@renderer_thread = Thread.new do
|
|
232
218
|
loop do
|
|
233
219
|
break unless @running
|
|
234
220
|
render_live
|
|
235
|
-
sleep
|
|
221
|
+
sleep RENDER_INTERVAL
|
|
236
222
|
end
|
|
237
223
|
end
|
|
238
224
|
end
|
|
239
225
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
@
|
|
243
|
-
@nest_level -= 1 if @nest_level > 0
|
|
244
|
-
return unless @nest_level == 0
|
|
245
|
-
return unless @running
|
|
246
|
-
|
|
247
|
-
@running = false
|
|
248
|
-
should_stop = true
|
|
249
|
-
end
|
|
250
|
-
|
|
251
|
-
return unless should_stop
|
|
252
|
-
|
|
226
|
+
# Template method: Called when display stops
|
|
227
|
+
def on_stop
|
|
228
|
+
@running = false
|
|
253
229
|
@renderer_thread&.join
|
|
254
|
-
# Show cursor
|
|
255
|
-
@output.print "\e[?
|
|
230
|
+
@output.print "\e[?25h" # Show cursor
|
|
231
|
+
@output.print "\e[?1049l" # Switch back to main screen buffer
|
|
256
232
|
render_final
|
|
257
233
|
end
|
|
258
234
|
|
|
@@ -262,97 +238,97 @@ module Taski
|
|
|
262
238
|
def build_tree_structure
|
|
263
239
|
return unless @root_task_class
|
|
264
240
|
|
|
265
|
-
@tree_structure = build_tree_node(@root_task_class
|
|
241
|
+
@tree_structure = self.class.build_tree_node(@root_task_class)
|
|
266
242
|
register_tasks_from_tree(@tree_structure)
|
|
267
243
|
end
|
|
268
244
|
|
|
269
|
-
# Build a single tree node
|
|
270
|
-
def build_tree_node(task_class, ancestors)
|
|
271
|
-
return nil if ancestors.include?(task_class)
|
|
272
|
-
|
|
273
|
-
node = {
|
|
274
|
-
task_class: task_class,
|
|
275
|
-
is_section: section_class?(task_class),
|
|
276
|
-
children: [],
|
|
277
|
-
is_impl_candidate: false
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
new_ancestors = ancestors + [task_class]
|
|
281
|
-
dependencies = StaticAnalysis::Analyzer.analyze(task_class).to_a
|
|
282
|
-
is_section = section_class?(task_class)
|
|
283
|
-
|
|
284
|
-
dependencies.each do |dep|
|
|
285
|
-
child_node = build_tree_node(dep, new_ancestors)
|
|
286
|
-
if child_node
|
|
287
|
-
# Only mark as impl candidate if parent is Section AND
|
|
288
|
-
# the dependency is a nested class of that Section
|
|
289
|
-
child_node[:is_impl_candidate] = is_section && nested_class?(dep, task_class)
|
|
290
|
-
node[:children] << child_node
|
|
291
|
-
end
|
|
292
|
-
end
|
|
293
|
-
|
|
294
|
-
node
|
|
295
|
-
end
|
|
296
|
-
|
|
297
|
-
# Register all tasks from tree structure
|
|
298
|
-
def register_tasks_from_tree(node)
|
|
299
|
-
return unless node
|
|
300
|
-
|
|
301
|
-
task_class = node[:task_class]
|
|
302
|
-
register_task(task_class)
|
|
303
|
-
|
|
304
|
-
# Mark as impl candidate if applicable
|
|
305
|
-
if node[:is_impl_candidate]
|
|
306
|
-
@tasks[task_class].is_impl_candidate = true
|
|
307
|
-
end
|
|
308
|
-
|
|
309
|
-
node[:children].each { |child| register_tasks_from_tree(child) }
|
|
310
|
-
end
|
|
311
|
-
|
|
312
245
|
def render_live
|
|
246
|
+
# Poll for new output from task pipes
|
|
247
|
+
@output_capture&.poll
|
|
248
|
+
|
|
313
249
|
lines = nil
|
|
314
|
-
line_count = nil
|
|
315
250
|
|
|
316
251
|
@monitor.synchronize do
|
|
317
252
|
@spinner_index += 1
|
|
318
253
|
lines = build_tree_display
|
|
319
|
-
line_count = @last_line_count
|
|
320
254
|
end
|
|
321
255
|
|
|
322
256
|
return if lines.nil? || lines.empty?
|
|
323
257
|
|
|
324
|
-
#
|
|
325
|
-
|
|
326
|
-
@output.print "\e[#{line_count}A\r"
|
|
327
|
-
end
|
|
258
|
+
# Build complete frame in buffer for single write (flicker-free)
|
|
259
|
+
buffer = build_frame_buffer(lines)
|
|
328
260
|
|
|
329
|
-
#
|
|
261
|
+
# Write entire frame in single operation
|
|
262
|
+
@output.print buffer
|
|
263
|
+
@output.flush
|
|
264
|
+
|
|
265
|
+
@last_line_count = lines.size
|
|
266
|
+
end
|
|
267
|
+
|
|
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
|
|
330
280
|
lines.each do |line|
|
|
331
|
-
|
|
281
|
+
buffer << line
|
|
282
|
+
buffer << "\e[K" # Clear from cursor to end of line (removes old content)
|
|
283
|
+
buffer << "\n"
|
|
332
284
|
end
|
|
333
285
|
|
|
334
|
-
|
|
335
|
-
|
|
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
|
|
336
291
|
end
|
|
337
292
|
|
|
338
|
-
|
|
293
|
+
buffer
|
|
339
294
|
end
|
|
340
295
|
|
|
341
296
|
def render_final
|
|
342
297
|
@monitor.synchronize do
|
|
343
|
-
|
|
344
|
-
return if lines.empty?
|
|
298
|
+
return unless @root_task_class
|
|
345
299
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
300
|
+
root_progress = @tasks[@root_task_class]
|
|
301
|
+
return unless root_progress
|
|
302
|
+
|
|
303
|
+
# Print single summary line instead of full tree
|
|
304
|
+
@output.puts build_summary_line(@root_task_class, root_progress)
|
|
305
|
+
end
|
|
306
|
+
end
|
|
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]}"
|
|
352
319
|
|
|
353
|
-
|
|
354
|
-
|
|
320
|
+
# Calculate total duration
|
|
321
|
+
duration_str = ""
|
|
322
|
+
if progress.run_duration
|
|
323
|
+
duration_str = " (#{progress.run_duration}ms)"
|
|
355
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}"
|
|
356
332
|
end
|
|
357
333
|
|
|
358
334
|
# Build display lines from tree structure
|
|
@@ -431,11 +407,12 @@ module Taski
|
|
|
431
407
|
return "#{COLORS[:dim]}#{ICONS[:skipped]}#{COLORS[:reset]} #{impl_prefix}#{name} #{type_label}#{suffix}"
|
|
432
408
|
end
|
|
433
409
|
|
|
434
|
-
|
|
410
|
+
status_icons = combined_status_icons(progress)
|
|
435
411
|
name = "#{COLORS[:name]}#{task_class.name}#{COLORS[:reset]}"
|
|
436
|
-
details =
|
|
412
|
+
details = combined_task_details(progress)
|
|
413
|
+
output_suffix = task_output_suffix(task_class, progress.state)
|
|
437
414
|
|
|
438
|
-
"#{
|
|
415
|
+
"#{status_icons} #{impl_prefix}#{name} #{type_label}#{details}#{output_suffix}"
|
|
439
416
|
end
|
|
440
417
|
|
|
441
418
|
def format_unknown_task(task_class, is_selected = true)
|
|
@@ -450,12 +427,26 @@ module Taski
|
|
|
450
427
|
end
|
|
451
428
|
end
|
|
452
429
|
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
430
|
+
##
|
|
431
|
+
# Returns combined status icons for both run and clean phases.
|
|
432
|
+
# Shows run icon first, then clean icon if clean phase has started.
|
|
433
|
+
# @param [TaskProgress] progress - The task progress object with run_state and clean_state.
|
|
434
|
+
# @return [String] The combined ANSI-colored icons.
|
|
435
|
+
def combined_status_icons(progress)
|
|
436
|
+
run_icon = run_status_icon(progress.run_state)
|
|
458
437
|
|
|
438
|
+
# If clean phase hasn't started, only show run icon
|
|
439
|
+
return run_icon unless progress.clean_state
|
|
440
|
+
|
|
441
|
+
clean_icon = clean_status_icon(progress.clean_state)
|
|
442
|
+
"#{run_icon} #{clean_icon}"
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
##
|
|
446
|
+
# Returns the status icon for run phase.
|
|
447
|
+
# @param [Symbol] state - The run state (:pending, :running, :completed, :failed).
|
|
448
|
+
# @return [String] The ANSI-colored icon.
|
|
449
|
+
def run_status_icon(state)
|
|
459
450
|
case state
|
|
460
451
|
when :completed
|
|
461
452
|
"#{COLORS[:success]}#{ICONS[:completed]}#{COLORS[:reset]}"
|
|
@@ -468,10 +459,36 @@ module Taski
|
|
|
468
459
|
end
|
|
469
460
|
end
|
|
470
461
|
|
|
462
|
+
##
|
|
463
|
+
# Returns the status icon for clean phase.
|
|
464
|
+
# @param [Symbol] state - The clean state (:cleaning, :clean_completed, :clean_failed).
|
|
465
|
+
# @return [String] The ANSI-colored icon.
|
|
466
|
+
def clean_status_icon(state)
|
|
467
|
+
case state
|
|
468
|
+
when :cleaning
|
|
469
|
+
"#{COLORS[:running]}#{spinner_char}#{COLORS[:reset]}"
|
|
470
|
+
when :clean_completed
|
|
471
|
+
"#{COLORS[:success]}#{ICONS[:clean_completed]}#{COLORS[:reset]}"
|
|
472
|
+
when :clean_failed
|
|
473
|
+
"#{COLORS[:error]}#{ICONS[:clean_failed]}#{COLORS[:reset]}"
|
|
474
|
+
else
|
|
475
|
+
""
|
|
476
|
+
end
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
##
|
|
480
|
+
# Returns the current spinner character for animation.
|
|
481
|
+
# Cycles through SPINNER_FRAMES based on the current spinner index.
|
|
482
|
+
# @return [String] The current spinner frame character.
|
|
471
483
|
def spinner_char
|
|
472
484
|
SPINNER_FRAMES[@spinner_index % SPINNER_FRAMES.length]
|
|
473
485
|
end
|
|
474
486
|
|
|
487
|
+
##
|
|
488
|
+
# Returns a colored type label for the task class.
|
|
489
|
+
# @param task_class [Class] The task class to get the label for.
|
|
490
|
+
# @param is_selected [Boolean] Whether the task is selected (affects color).
|
|
491
|
+
# @return [String] The colored type label (Section or Task).
|
|
475
492
|
def type_label_for(task_class, is_selected = true)
|
|
476
493
|
if section_class?(task_class)
|
|
477
494
|
is_selected ? "#{COLORS[:section]}(Section)#{COLORS[:reset]}" : "#{COLORS[:dim]}(Section)#{COLORS[:reset]}"
|
|
@@ -480,24 +497,144 @@ module Taski
|
|
|
480
497
|
end
|
|
481
498
|
end
|
|
482
499
|
|
|
483
|
-
|
|
484
|
-
|
|
500
|
+
##
|
|
501
|
+
# Returns combined details for both run and clean phases.
|
|
502
|
+
# @param [TaskProgress] progress - Progress object with run_state, clean_state, etc.
|
|
503
|
+
# @return [String] Combined details for both phases.
|
|
504
|
+
def combined_task_details(progress)
|
|
505
|
+
run_detail = run_phase_details(progress)
|
|
506
|
+
clean_detail = clean_phase_details(progress)
|
|
507
|
+
|
|
508
|
+
if clean_detail.empty?
|
|
509
|
+
run_detail
|
|
510
|
+
else
|
|
511
|
+
"#{run_detail}#{clean_detail}"
|
|
512
|
+
end
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
##
|
|
516
|
+
# Returns details for the run phase only.
|
|
517
|
+
# @param [TaskProgress] progress - Progress object.
|
|
518
|
+
# @return [String] Run phase details.
|
|
519
|
+
def run_phase_details(progress)
|
|
520
|
+
case progress.run_state
|
|
485
521
|
when :completed
|
|
486
|
-
"
|
|
522
|
+
return "" unless progress.run_duration
|
|
523
|
+
" #{COLORS[:success]}(#{progress.run_duration}ms)#{COLORS[:reset]}"
|
|
487
524
|
when :failed
|
|
488
525
|
" #{COLORS[:error]}(failed)#{COLORS[:reset]}"
|
|
489
526
|
when :running
|
|
490
|
-
|
|
527
|
+
return "" unless progress.run_start_time
|
|
528
|
+
elapsed = ((Time.now - progress.run_start_time) * 1000).round(0)
|
|
491
529
|
" #{COLORS[:running]}(#{elapsed}ms)#{COLORS[:reset]}"
|
|
492
530
|
else
|
|
493
531
|
""
|
|
494
532
|
end
|
|
495
533
|
end
|
|
496
534
|
|
|
535
|
+
##
|
|
536
|
+
# Returns details for the clean phase only.
|
|
537
|
+
# @param [TaskProgress] progress - Progress object.
|
|
538
|
+
# @return [String] Clean phase details.
|
|
539
|
+
def clean_phase_details(progress)
|
|
540
|
+
case progress.clean_state
|
|
541
|
+
when :cleaning
|
|
542
|
+
return "" unless progress.clean_start_time
|
|
543
|
+
elapsed = ((Time.now - progress.clean_start_time) * 1000).round(0)
|
|
544
|
+
" #{COLORS[:running]}(cleaning #{elapsed}ms)#{COLORS[:reset]}"
|
|
545
|
+
when :clean_completed
|
|
546
|
+
return "" unless progress.clean_duration
|
|
547
|
+
" #{COLORS[:success]}(cleaned #{progress.clean_duration}ms)#{COLORS[:reset]}"
|
|
548
|
+
when :clean_failed
|
|
549
|
+
" #{COLORS[:error]}(clean failed)#{COLORS[:reset]}"
|
|
550
|
+
else
|
|
551
|
+
""
|
|
552
|
+
end
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
# Get task output suffix to display next to task
|
|
556
|
+
##
|
|
557
|
+
# Produces a trailing output suffix for a task when it is actively producing output.
|
|
558
|
+
#
|
|
559
|
+
# Fetches the last captured stdout/stderr line for the given task and returns a
|
|
560
|
+
# formatted, dimmed suffix containing that line only when the task `state` is
|
|
561
|
+
# `:running` or `:cleaning` and an output capture is available. The returned
|
|
562
|
+
# string is truncated to fit the terminal width (with a minimum visible length)
|
|
563
|
+
# and includes surrounding dim/reset color codes.
|
|
564
|
+
# @param [Class] task_class - The task class whose output to query.
|
|
565
|
+
# @param [Symbol] state - The task lifecycle state (only `:running` and `:cleaning` produce output).
|
|
566
|
+
# @return [String] A formatted, possibly truncated output suffix prefixed with a dim pipe, or an empty string when no output should be shown.
|
|
567
|
+
def task_output_suffix(task_class, state)
|
|
568
|
+
return "" unless state == :running || state == :cleaning
|
|
569
|
+
return "" unless @output_capture
|
|
570
|
+
|
|
571
|
+
last_line = @output_capture.last_line_for(task_class)
|
|
572
|
+
return "" unless last_line && !last_line.empty?
|
|
573
|
+
|
|
574
|
+
# Get current group name if any
|
|
575
|
+
progress = @tasks[task_class]
|
|
576
|
+
group_prefix = ""
|
|
577
|
+
if progress&.current_group_index
|
|
578
|
+
current_group = progress.groups[progress.current_group_index]
|
|
579
|
+
group_prefix = "#{current_group.name}#{GROUP_SEPARATOR}" if current_group
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
# Truncate if too long (leave space for tree structure)
|
|
583
|
+
terminal_cols = terminal_width
|
|
584
|
+
max_output_length = terminal_cols - OUTPUT_RESERVED_WIDTH
|
|
585
|
+
max_output_length = OUTPUT_MIN_LENGTH if max_output_length < OUTPUT_MIN_LENGTH
|
|
586
|
+
|
|
587
|
+
full_output = "#{group_prefix}#{last_line}"
|
|
588
|
+
truncated = if full_output.length > max_output_length
|
|
589
|
+
full_output[0, max_output_length - TRUNCATION_ELLIPSIS.length] + TRUNCATION_ELLIPSIS
|
|
590
|
+
else
|
|
591
|
+
full_output
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
"#{COLORS[:dim]}#{OUTPUT_SEPARATOR}#{truncated}#{COLORS[:reset]}"
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
##
|
|
598
|
+
# Returns the terminal width in columns.
|
|
599
|
+
# Defaults to 80 if the output IO doesn't support winsize.
|
|
600
|
+
# @return [Integer] The terminal width in columns.
|
|
601
|
+
def terminal_width
|
|
602
|
+
if @output.respond_to?(:winsize)
|
|
603
|
+
_, cols = @output.winsize
|
|
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
|
|
618
|
+
else
|
|
619
|
+
DEFAULT_TERMINAL_HEIGHT
|
|
620
|
+
end
|
|
621
|
+
end
|
|
622
|
+
|
|
623
|
+
##
|
|
624
|
+
# Checks if a class is a Taski::Section subclass.
|
|
625
|
+
# Delegates to the class method.
|
|
626
|
+
# @param klass [Class] The class to check.
|
|
627
|
+
# @return [Boolean] true if the class is a Section.
|
|
497
628
|
def section_class?(klass)
|
|
498
629
|
self.class.section_class?(klass)
|
|
499
630
|
end
|
|
500
631
|
|
|
632
|
+
##
|
|
633
|
+
# Checks if a class is nested within another class.
|
|
634
|
+
# Delegates to the class method.
|
|
635
|
+
# @param child_class [Class] The potential nested class.
|
|
636
|
+
# @param parent_class [Class] The potential parent class.
|
|
637
|
+
# @return [Boolean] true if child_class is nested within parent_class.
|
|
501
638
|
def nested_class?(child_class, parent_class)
|
|
502
639
|
self.class.nested_class?(child_class, parent_class)
|
|
503
640
|
end
|