taski 0.4.2 → 0.7.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 +50 -0
- data/README.md +51 -33
- data/Steepfile +1 -0
- data/docs/GUIDE.md +340 -0
- data/examples/README.md +68 -20
- data/examples/{context_demo.rb → args_demo.rb} +27 -27
- data/examples/clean_demo.rb +204 -0
- data/examples/data_pipeline_demo.rb +3 -3
- data/examples/group_demo.rb +113 -0
- data/examples/nested_section_demo.rb +161 -0
- data/examples/parallel_progress_demo.rb +1 -1
- data/examples/reexecution_demo.rb +93 -80
- data/examples/system_call_demo.rb +56 -0
- data/examples/tree_progress_demo.rb +164 -0
- data/lib/taski/{context.rb → args.rb} +3 -3
- data/lib/taski/execution/execution_context.rb +379 -0
- data/lib/taski/execution/executor.rb +538 -0
- data/lib/taski/execution/registry.rb +26 -2
- data/lib/taski/execution/scheduler.rb +308 -0
- data/lib/taski/execution/task_output_pipe.rb +42 -0
- data/lib/taski/execution/task_output_router.rb +216 -0
- data/lib/taski/execution/task_wrapper.rb +295 -146
- data/lib/taski/execution/tree_progress_display.rb +793 -0
- data/lib/taski/execution/worker_pool.rb +104 -0
- data/lib/taski/section.rb +23 -0
- data/lib/taski/static_analysis/analyzer.rb +4 -2
- data/lib/taski/static_analysis/visitor.rb +86 -5
- data/lib/taski/task.rb +223 -120
- data/lib/taski/version.rb +1 -1
- data/lib/taski.rb +147 -28
- data/sig/taski.rbs +310 -67
- metadata +17 -8
- data/docs/advanced-features.md +0 -625
- data/docs/api-guide.md +0 -509
- data/docs/error-handling.md +0 -684
- data/lib/taski/execution/coordinator.rb +0 -63
- data/lib/taski/execution/parallel_progress_display.rb +0 -201
|
@@ -0,0 +1,793 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
require "stringio"
|
|
5
|
+
|
|
6
|
+
module Taski
|
|
7
|
+
module Execution
|
|
8
|
+
# Tree-based progress display that shows task execution in a tree structure
|
|
9
|
+
# similar to Task.tree, with real-time status updates and stdout capture.
|
|
10
|
+
class TreeProgressDisplay
|
|
11
|
+
SPINNER_FRAMES = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
|
|
12
|
+
|
|
13
|
+
# ANSI color codes (matching Task.tree)
|
|
14
|
+
COLORS = {
|
|
15
|
+
reset: "\e[0m",
|
|
16
|
+
task: "\e[32m", # green
|
|
17
|
+
section: "\e[34m", # blue
|
|
18
|
+
impl: "\e[33m", # yellow
|
|
19
|
+
tree: "\e[90m", # gray
|
|
20
|
+
name: "\e[1m", # bold
|
|
21
|
+
success: "\e[32m", # green
|
|
22
|
+
error: "\e[31m", # red
|
|
23
|
+
running: "\e[36m", # cyan
|
|
24
|
+
pending: "\e[90m", # gray
|
|
25
|
+
dim: "\e[2m" # dim
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
# Status icons
|
|
29
|
+
ICONS = {
|
|
30
|
+
# Run lifecycle states
|
|
31
|
+
pending: "⏸", # Pause for waiting
|
|
32
|
+
running_prefix: "", # Will use spinner
|
|
33
|
+
completed: "✓",
|
|
34
|
+
failed: "✗",
|
|
35
|
+
skipped: "⊘", # Prohibition sign for unselected impl candidates
|
|
36
|
+
# Clean lifecycle states
|
|
37
|
+
cleaning_prefix: "", # Will use spinner
|
|
38
|
+
clean_completed: "♻",
|
|
39
|
+
clean_failed: "✗"
|
|
40
|
+
}.freeze
|
|
41
|
+
|
|
42
|
+
##
|
|
43
|
+
# Checks if a class is a Taski::Section subclass.
|
|
44
|
+
# @param klass [Class] The class to check.
|
|
45
|
+
# @return [Boolean] true if the class is a Section.
|
|
46
|
+
def self.section_class?(klass)
|
|
47
|
+
defined?(Taski::Section) && klass < Taski::Section
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
##
|
|
51
|
+
# Checks if a class is nested within another class by name prefix.
|
|
52
|
+
# @param child_class [Class] The potential nested class.
|
|
53
|
+
# @param parent_class [Class] The potential parent class.
|
|
54
|
+
# @return [Boolean] true if child_class name starts with parent_class name and "::".
|
|
55
|
+
def self.nested_class?(child_class, parent_class)
|
|
56
|
+
child_name = child_class.name.to_s
|
|
57
|
+
parent_name = parent_class.name.to_s
|
|
58
|
+
child_name.start_with?("#{parent_name}::")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Build a tree structure from a root task class.
|
|
62
|
+
# This is the shared tree building logic used by both static and progress display.
|
|
63
|
+
#
|
|
64
|
+
# @param task_class [Class] The task class to build tree for
|
|
65
|
+
# @param ancestors [Set] Set of ancestor task classes for circular detection
|
|
66
|
+
# @return [Hash, nil] Tree node hash or nil if circular
|
|
67
|
+
#
|
|
68
|
+
# Tree node structure:
|
|
69
|
+
# {
|
|
70
|
+
# task_class: Class, # The task class
|
|
71
|
+
# is_section: Boolean, # Whether this is a Section
|
|
72
|
+
# is_circular: Boolean, # Whether this is a circular reference
|
|
73
|
+
# is_impl_candidate: Boolean, # Whether this is an impl candidate
|
|
74
|
+
# children: Array<Hash> # Child nodes
|
|
75
|
+
# }
|
|
76
|
+
def self.build_tree_node(task_class, ancestors = Set.new)
|
|
77
|
+
is_circular = ancestors.include?(task_class)
|
|
78
|
+
|
|
79
|
+
node = {
|
|
80
|
+
task_class: task_class,
|
|
81
|
+
is_section: section_class?(task_class),
|
|
82
|
+
is_circular: is_circular,
|
|
83
|
+
is_impl_candidate: false,
|
|
84
|
+
children: []
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
# Don't traverse children for circular references
|
|
88
|
+
return node if is_circular
|
|
89
|
+
|
|
90
|
+
new_ancestors = ancestors + [task_class]
|
|
91
|
+
dependencies = StaticAnalysis::Analyzer.analyze(task_class).to_a
|
|
92
|
+
is_section = section_class?(task_class)
|
|
93
|
+
|
|
94
|
+
dependencies.each do |dep|
|
|
95
|
+
child_node = build_tree_node(dep, new_ancestors)
|
|
96
|
+
child_node[:is_impl_candidate] = is_section && nested_class?(dep, task_class)
|
|
97
|
+
node[:children] << child_node
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
node
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Render a static tree structure for a task class (used by Task.tree)
|
|
104
|
+
# @param root_task_class [Class] The root task class
|
|
105
|
+
# @return [String] The rendered tree string
|
|
106
|
+
def self.render_static_tree(root_task_class)
|
|
107
|
+
tree = build_tree_node(root_task_class)
|
|
108
|
+
formatter = StaticTreeFormatter.new
|
|
109
|
+
formatter.format(tree)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Formatter for static tree display (no progress tracking, uses task numbers)
|
|
113
|
+
class StaticTreeFormatter
|
|
114
|
+
def format(tree)
|
|
115
|
+
@task_index_map = {}
|
|
116
|
+
format_node(tree, "", false)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
def format_node(node, prefix, is_impl)
|
|
122
|
+
task_class = node[:task_class]
|
|
123
|
+
type_label = colored_type_label(task_class)
|
|
124
|
+
impl_prefix = is_impl ? "#{COLORS[:impl]}[impl]#{COLORS[:reset]} " : ""
|
|
125
|
+
task_number = get_task_number(task_class)
|
|
126
|
+
name = "#{COLORS[:name]}#{task_class.name}#{COLORS[:reset]}"
|
|
127
|
+
|
|
128
|
+
if node[:is_circular]
|
|
129
|
+
circular_marker = "#{COLORS[:impl]}(circular)#{COLORS[:reset]}"
|
|
130
|
+
return "#{impl_prefix}#{task_number} #{name} #{type_label} #{circular_marker}\n"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
result = "#{impl_prefix}#{task_number} #{name} #{type_label}\n"
|
|
134
|
+
|
|
135
|
+
# Register task number if not already registered
|
|
136
|
+
@task_index_map[task_class] = @task_index_map.size + 1 unless @task_index_map.key?(task_class)
|
|
137
|
+
|
|
138
|
+
node[:children].each_with_index do |child, index|
|
|
139
|
+
is_last = (index == node[:children].size - 1)
|
|
140
|
+
result += format_child_branch(child, prefix, is_last)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
result
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def format_child_branch(child, prefix, is_last)
|
|
147
|
+
connector = is_last ? "└── " : "├── "
|
|
148
|
+
extension = is_last ? " " : "│ "
|
|
149
|
+
child_tree = format_node(child, "#{prefix}#{extension}", child[:is_impl_candidate])
|
|
150
|
+
|
|
151
|
+
result = "#{prefix}#{COLORS[:tree]}#{connector}#{COLORS[:reset]}"
|
|
152
|
+
lines = child_tree.lines
|
|
153
|
+
result += lines.first
|
|
154
|
+
lines.drop(1).each { |line| result += line }
|
|
155
|
+
result
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def get_task_number(task_class)
|
|
159
|
+
number = @task_index_map[task_class] || (@task_index_map.size + 1)
|
|
160
|
+
"#{COLORS[:tree]}[#{number}]#{COLORS[:reset]}"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def colored_type_label(klass)
|
|
164
|
+
if TreeProgressDisplay.section_class?(klass)
|
|
165
|
+
"#{COLORS[:section]}(Section)#{COLORS[:reset]}"
|
|
166
|
+
else
|
|
167
|
+
"#{COLORS[:task]}(Task)#{COLORS[:reset]}"
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
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
|
+
def initialize(output: $stdout)
|
|
241
|
+
@output = output
|
|
242
|
+
@tasks = {}
|
|
243
|
+
@monitor = Monitor.new
|
|
244
|
+
@spinner_index = 0
|
|
245
|
+
@renderer_thread = nil
|
|
246
|
+
@running = false
|
|
247
|
+
@nest_level = 0 # Track nested executor calls
|
|
248
|
+
@root_task_class = nil
|
|
249
|
+
@tree_structure = nil
|
|
250
|
+
@section_impl_map = {} # Section -> selected impl class
|
|
251
|
+
@output_capture = nil # ThreadOutputCapture for getting task output
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Set the output capture for getting task output
|
|
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
|
|
261
|
+
|
|
262
|
+
# Set the root task to build tree structure
|
|
263
|
+
# Only sets root task if not already set (prevents nested executor overwrite)
|
|
264
|
+
# @param root_task_class [Class] The root task class
|
|
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
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# @param task_class [Class] The task class to check
|
|
291
|
+
# @return [Boolean] true if the task is registered
|
|
292
|
+
def task_registered?(task_class)
|
|
293
|
+
@monitor.synchronize do
|
|
294
|
+
@tasks.key?(task_class)
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# @param task_class [Class] The task class to update
|
|
299
|
+
# @param state [Symbol] The new state (:pending, :running, :completed, :failed, :cleaning, :clean_completed, :clean_failed)
|
|
300
|
+
# @param duration [Float] Duration in milliseconds (for completed tasks)
|
|
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
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def start
|
|
391
|
+
should_start = false
|
|
392
|
+
@monitor.synchronize do
|
|
393
|
+
@nest_level += 1
|
|
394
|
+
return if @nest_level > 1 # Already running from outer executor
|
|
395
|
+
return if @running
|
|
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)
|
|
406
|
+
@renderer_thread = Thread.new do
|
|
407
|
+
loop do
|
|
408
|
+
break unless @running
|
|
409
|
+
render_live
|
|
410
|
+
sleep 0.1
|
|
411
|
+
end
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
def stop
|
|
416
|
+
should_stop = false
|
|
417
|
+
@monitor.synchronize do
|
|
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
|
+
|
|
428
|
+
@renderer_thread&.join
|
|
429
|
+
@output.print "\e[?25h" # Show cursor
|
|
430
|
+
render_final
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
private
|
|
434
|
+
|
|
435
|
+
# Build tree structure from root task for display
|
|
436
|
+
def build_tree_structure
|
|
437
|
+
return unless @root_task_class
|
|
438
|
+
|
|
439
|
+
@tree_structure = self.class.build_tree_node(@root_task_class)
|
|
440
|
+
register_tasks_from_tree(@tree_structure)
|
|
441
|
+
end
|
|
442
|
+
|
|
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
|
+
def render_live
|
|
459
|
+
# Poll for new output from task pipes
|
|
460
|
+
@output_capture&.poll
|
|
461
|
+
|
|
462
|
+
lines = nil
|
|
463
|
+
|
|
464
|
+
@monitor.synchronize do
|
|
465
|
+
@spinner_index += 1
|
|
466
|
+
lines = build_tree_display
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
return if lines.nil? || lines.empty?
|
|
470
|
+
|
|
471
|
+
# Restore cursor to saved position (from start) and clear
|
|
472
|
+
@output.print "\e8" # Restore cursor position
|
|
473
|
+
@output.print "\e[J" # Clear from cursor to end of screen
|
|
474
|
+
|
|
475
|
+
# Redraw all lines
|
|
476
|
+
lines.each do |line|
|
|
477
|
+
@output.print "#{line}\n"
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
@output.flush
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
def render_final
|
|
484
|
+
@monitor.synchronize do
|
|
485
|
+
lines = build_tree_display
|
|
486
|
+
return if lines.empty?
|
|
487
|
+
|
|
488
|
+
# Restore cursor to saved position (from start) and clear
|
|
489
|
+
@output.print "\e8" # Restore cursor position
|
|
490
|
+
@output.print "\e[J" # Clear from cursor to end of screen
|
|
491
|
+
|
|
492
|
+
# Print final state
|
|
493
|
+
lines.each { |line| @output.puts line }
|
|
494
|
+
end
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
# Build display lines from tree structure
|
|
498
|
+
def build_tree_display
|
|
499
|
+
return [] unless @tree_structure
|
|
500
|
+
|
|
501
|
+
lines = []
|
|
502
|
+
build_root_tree_lines(@tree_structure, "", lines)
|
|
503
|
+
lines
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
# Build tree lines starting from root node
|
|
507
|
+
# @param node [Hash] Tree node (root)
|
|
508
|
+
# @param prefix [String] Line prefix for tree drawing
|
|
509
|
+
# @param lines [Array<String>] Accumulated output lines
|
|
510
|
+
def build_root_tree_lines(node, prefix, lines)
|
|
511
|
+
task_class = node[:task_class]
|
|
512
|
+
progress = @tasks[task_class]
|
|
513
|
+
|
|
514
|
+
# Root node is never an impl candidate and is always selected
|
|
515
|
+
line = format_tree_line(task_class, progress, false, true)
|
|
516
|
+
lines << "#{prefix}#{line}"
|
|
517
|
+
|
|
518
|
+
render_children(node, prefix, lines, task_class, true)
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
# Render all children of a node recursively
|
|
522
|
+
# @param node [Hash] Tree node
|
|
523
|
+
# @param prefix [String] Line prefix for tree drawing
|
|
524
|
+
# @param lines [Array<String>] Accumulated output lines
|
|
525
|
+
# @param parent_task_class [Class] Parent task class (for impl selection lookup)
|
|
526
|
+
# @param ancestor_selected [Boolean] Whether all ancestor impl candidates were selected
|
|
527
|
+
def render_children(node, prefix, lines, parent_task_class, ancestor_selected)
|
|
528
|
+
children = node[:children]
|
|
529
|
+
children.each_with_index do |child, index|
|
|
530
|
+
is_last = (index == children.size - 1)
|
|
531
|
+
connector = is_last ? "└── " : "├── "
|
|
532
|
+
extension = is_last ? " " : "│ "
|
|
533
|
+
|
|
534
|
+
child_progress = @tasks[child[:task_class]]
|
|
535
|
+
|
|
536
|
+
# Determine child's selection status
|
|
537
|
+
child_is_selected = true
|
|
538
|
+
if child[:is_impl_candidate]
|
|
539
|
+
selected_impl = @section_impl_map[parent_task_class]
|
|
540
|
+
child_is_selected = (selected_impl == child[:task_class])
|
|
541
|
+
end
|
|
542
|
+
# Propagate ancestor selection state
|
|
543
|
+
child_effective_selected = ancestor_selected && child_is_selected
|
|
544
|
+
|
|
545
|
+
child_line = format_tree_line(
|
|
546
|
+
child[:task_class],
|
|
547
|
+
child_progress,
|
|
548
|
+
child[:is_impl_candidate],
|
|
549
|
+
child_effective_selected
|
|
550
|
+
)
|
|
551
|
+
lines << "#{prefix}#{COLORS[:tree]}#{connector}#{COLORS[:reset]}#{child_line}"
|
|
552
|
+
|
|
553
|
+
if child[:children].any?
|
|
554
|
+
render_children(child, "#{prefix}#{COLORS[:tree]}#{extension}#{COLORS[:reset]}", lines, child[:task_class], child_effective_selected)
|
|
555
|
+
end
|
|
556
|
+
end
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
def format_tree_line(task_class, progress, is_impl, is_selected)
|
|
560
|
+
return format_unknown_task(task_class, is_selected) unless progress
|
|
561
|
+
|
|
562
|
+
type_label = type_label_for(task_class, is_selected)
|
|
563
|
+
impl_prefix = is_impl ? "#{COLORS[:impl]}[impl]#{COLORS[:reset]} " : ""
|
|
564
|
+
|
|
565
|
+
# Handle unselected nodes (either impl candidates or children of unselected impl)
|
|
566
|
+
# Show dimmed regardless of task state since they belong to unselected branch
|
|
567
|
+
unless is_selected
|
|
568
|
+
name = "#{COLORS[:dim]}#{task_class.name}#{COLORS[:reset]}"
|
|
569
|
+
suffix = is_impl ? " #{COLORS[:dim]}(not selected)#{COLORS[:reset]}" : ""
|
|
570
|
+
return "#{COLORS[:dim]}#{ICONS[:skipped]}#{COLORS[:reset]} #{impl_prefix}#{name} #{type_label}#{suffix}"
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
status_icons = combined_status_icons(progress)
|
|
574
|
+
name = "#{COLORS[:name]}#{task_class.name}#{COLORS[:reset]}"
|
|
575
|
+
details = combined_task_details(progress)
|
|
576
|
+
output_suffix = task_output_suffix(task_class, progress.state)
|
|
577
|
+
|
|
578
|
+
"#{status_icons} #{impl_prefix}#{name} #{type_label}#{details}#{output_suffix}"
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
def format_unknown_task(task_class, is_selected = true)
|
|
582
|
+
if is_selected
|
|
583
|
+
name = "#{COLORS[:name]}#{task_class.name}#{COLORS[:reset]}"
|
|
584
|
+
type_label = type_label_for(task_class, true)
|
|
585
|
+
"#{COLORS[:pending]}#{ICONS[:pending]}#{COLORS[:reset]} #{name} #{type_label}"
|
|
586
|
+
else
|
|
587
|
+
name = "#{COLORS[:dim]}#{task_class.name}#{COLORS[:reset]}"
|
|
588
|
+
type_label = type_label_for(task_class, false)
|
|
589
|
+
"#{COLORS[:dim]}#{ICONS[:skipped]}#{COLORS[:reset]} #{name} #{type_label}"
|
|
590
|
+
end
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
##
|
|
594
|
+
# Returns combined status icons for both run and clean phases.
|
|
595
|
+
# Shows run icon first, then clean icon if clean phase has started.
|
|
596
|
+
# @param [TaskProgress] progress - The task progress object with run_state and clean_state.
|
|
597
|
+
# @return [String] The combined ANSI-colored icons.
|
|
598
|
+
def combined_status_icons(progress)
|
|
599
|
+
run_icon = run_status_icon(progress.run_state)
|
|
600
|
+
|
|
601
|
+
# If clean phase hasn't started, only show run icon
|
|
602
|
+
return run_icon unless progress.clean_state
|
|
603
|
+
|
|
604
|
+
clean_icon = clean_status_icon(progress.clean_state)
|
|
605
|
+
"#{run_icon} #{clean_icon}"
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
##
|
|
609
|
+
# Returns the status icon for run phase.
|
|
610
|
+
# @param [Symbol] state - The run state (:pending, :running, :completed, :failed).
|
|
611
|
+
# @return [String] The ANSI-colored icon.
|
|
612
|
+
def run_status_icon(state)
|
|
613
|
+
case state
|
|
614
|
+
when :completed
|
|
615
|
+
"#{COLORS[:success]}#{ICONS[:completed]}#{COLORS[:reset]}"
|
|
616
|
+
when :failed
|
|
617
|
+
"#{COLORS[:error]}#{ICONS[:failed]}#{COLORS[:reset]}"
|
|
618
|
+
when :running
|
|
619
|
+
"#{COLORS[:running]}#{spinner_char}#{COLORS[:reset]}"
|
|
620
|
+
else
|
|
621
|
+
"#{COLORS[:pending]}#{ICONS[:pending]}#{COLORS[:reset]}"
|
|
622
|
+
end
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
##
|
|
626
|
+
# Returns the status icon for clean phase.
|
|
627
|
+
# @param [Symbol] state - The clean state (:cleaning, :clean_completed, :clean_failed).
|
|
628
|
+
# @return [String] The ANSI-colored icon.
|
|
629
|
+
def clean_status_icon(state)
|
|
630
|
+
case state
|
|
631
|
+
when :cleaning
|
|
632
|
+
"#{COLORS[:running]}#{spinner_char}#{COLORS[:reset]}"
|
|
633
|
+
when :clean_completed
|
|
634
|
+
"#{COLORS[:success]}#{ICONS[:clean_completed]}#{COLORS[:reset]}"
|
|
635
|
+
when :clean_failed
|
|
636
|
+
"#{COLORS[:error]}#{ICONS[:clean_failed]}#{COLORS[:reset]}"
|
|
637
|
+
else
|
|
638
|
+
""
|
|
639
|
+
end
|
|
640
|
+
end
|
|
641
|
+
|
|
642
|
+
##
|
|
643
|
+
# Returns the current spinner character for animation.
|
|
644
|
+
# Cycles through SPINNER_FRAMES based on the current spinner index.
|
|
645
|
+
# @return [String] The current spinner frame character.
|
|
646
|
+
def spinner_char
|
|
647
|
+
SPINNER_FRAMES[@spinner_index % SPINNER_FRAMES.length]
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
##
|
|
651
|
+
# Returns a colored type label for the task class.
|
|
652
|
+
# @param task_class [Class] The task class to get the label for.
|
|
653
|
+
# @param is_selected [Boolean] Whether the task is selected (affects color).
|
|
654
|
+
# @return [String] The colored type label (Section or Task).
|
|
655
|
+
def type_label_for(task_class, is_selected = true)
|
|
656
|
+
if section_class?(task_class)
|
|
657
|
+
is_selected ? "#{COLORS[:section]}(Section)#{COLORS[:reset]}" : "#{COLORS[:dim]}(Section)#{COLORS[:reset]}"
|
|
658
|
+
else
|
|
659
|
+
is_selected ? "#{COLORS[:task]}(Task)#{COLORS[:reset]}" : "#{COLORS[:dim]}(Task)#{COLORS[:reset]}"
|
|
660
|
+
end
|
|
661
|
+
end
|
|
662
|
+
|
|
663
|
+
##
|
|
664
|
+
# Returns combined details for both run and clean phases.
|
|
665
|
+
# @param [TaskProgress] progress - Progress object with run_state, clean_state, etc.
|
|
666
|
+
# @return [String] Combined details for both phases.
|
|
667
|
+
def combined_task_details(progress)
|
|
668
|
+
run_detail = run_phase_details(progress)
|
|
669
|
+
clean_detail = clean_phase_details(progress)
|
|
670
|
+
|
|
671
|
+
if clean_detail.empty?
|
|
672
|
+
run_detail
|
|
673
|
+
else
|
|
674
|
+
"#{run_detail}#{clean_detail}"
|
|
675
|
+
end
|
|
676
|
+
end
|
|
677
|
+
|
|
678
|
+
##
|
|
679
|
+
# Returns details for the run phase only.
|
|
680
|
+
# @param [TaskProgress] progress - Progress object.
|
|
681
|
+
# @return [String] Run phase details.
|
|
682
|
+
def run_phase_details(progress)
|
|
683
|
+
case progress.run_state
|
|
684
|
+
when :completed
|
|
685
|
+
return "" unless progress.run_duration
|
|
686
|
+
" #{COLORS[:success]}(#{progress.run_duration}ms)#{COLORS[:reset]}"
|
|
687
|
+
when :failed
|
|
688
|
+
" #{COLORS[:error]}(failed)#{COLORS[:reset]}"
|
|
689
|
+
when :running
|
|
690
|
+
return "" unless progress.run_start_time
|
|
691
|
+
elapsed = ((Time.now - progress.run_start_time) * 1000).round(0)
|
|
692
|
+
" #{COLORS[:running]}(#{elapsed}ms)#{COLORS[:reset]}"
|
|
693
|
+
else
|
|
694
|
+
""
|
|
695
|
+
end
|
|
696
|
+
end
|
|
697
|
+
|
|
698
|
+
##
|
|
699
|
+
# Returns details for the clean phase only.
|
|
700
|
+
# @param [TaskProgress] progress - Progress object.
|
|
701
|
+
# @return [String] Clean phase details.
|
|
702
|
+
def clean_phase_details(progress)
|
|
703
|
+
case progress.clean_state
|
|
704
|
+
when :cleaning
|
|
705
|
+
return "" unless progress.clean_start_time
|
|
706
|
+
elapsed = ((Time.now - progress.clean_start_time) * 1000).round(0)
|
|
707
|
+
" #{COLORS[:running]}(cleaning #{elapsed}ms)#{COLORS[:reset]}"
|
|
708
|
+
when :clean_completed
|
|
709
|
+
return "" unless progress.clean_duration
|
|
710
|
+
" #{COLORS[:success]}(cleaned #{progress.clean_duration}ms)#{COLORS[:reset]}"
|
|
711
|
+
when :clean_failed
|
|
712
|
+
" #{COLORS[:error]}(clean failed)#{COLORS[:reset]}"
|
|
713
|
+
else
|
|
714
|
+
""
|
|
715
|
+
end
|
|
716
|
+
end
|
|
717
|
+
|
|
718
|
+
# Get task output suffix to display next to task
|
|
719
|
+
##
|
|
720
|
+
# Produces a trailing output suffix for a task when it is actively producing output.
|
|
721
|
+
#
|
|
722
|
+
# Fetches the last captured stdout/stderr line for the given task and returns a
|
|
723
|
+
# formatted, dimmed suffix containing that line only when the task `state` is
|
|
724
|
+
# `:running` or `:cleaning` and an output capture is available. The returned
|
|
725
|
+
# string is truncated to fit the terminal width (with a minimum visible length)
|
|
726
|
+
# and includes surrounding dim/reset color codes.
|
|
727
|
+
# @param [Class] task_class - The task class whose output to query.
|
|
728
|
+
# @param [Symbol] state - The task lifecycle state (only `:running` and `:cleaning` produce output).
|
|
729
|
+
# @return [String] A formatted, possibly truncated output suffix prefixed with a dim pipe, or an empty string when no output should be shown.
|
|
730
|
+
def task_output_suffix(task_class, state)
|
|
731
|
+
return "" unless state == :running || state == :cleaning
|
|
732
|
+
return "" unless @output_capture
|
|
733
|
+
|
|
734
|
+
last_line = @output_capture.last_line_for(task_class)
|
|
735
|
+
return "" unless last_line && !last_line.empty?
|
|
736
|
+
|
|
737
|
+
# Get current group name if any
|
|
738
|
+
progress = @tasks[task_class]
|
|
739
|
+
group_prefix = ""
|
|
740
|
+
if progress&.current_group_index
|
|
741
|
+
current_group = progress.groups[progress.current_group_index]
|
|
742
|
+
group_prefix = "#{current_group.name}: " if current_group
|
|
743
|
+
end
|
|
744
|
+
|
|
745
|
+
# Truncate if too long (leave space for tree structure)
|
|
746
|
+
terminal_cols = terminal_width
|
|
747
|
+
max_output_length = terminal_cols - 50
|
|
748
|
+
max_output_length = 20 if max_output_length < 20
|
|
749
|
+
|
|
750
|
+
full_output = "#{group_prefix}#{last_line}"
|
|
751
|
+
truncated = if full_output.length > max_output_length
|
|
752
|
+
full_output[0, max_output_length - 3] + "..."
|
|
753
|
+
else
|
|
754
|
+
full_output
|
|
755
|
+
end
|
|
756
|
+
|
|
757
|
+
" #{COLORS[:dim]}| #{truncated}#{COLORS[:reset]}"
|
|
758
|
+
end
|
|
759
|
+
|
|
760
|
+
##
|
|
761
|
+
# Returns the terminal width in columns.
|
|
762
|
+
# Defaults to 80 if the output IO doesn't support winsize.
|
|
763
|
+
# @return [Integer] The terminal width in columns.
|
|
764
|
+
def terminal_width
|
|
765
|
+
if @output.respond_to?(:winsize)
|
|
766
|
+
_, cols = @output.winsize
|
|
767
|
+
cols || 80
|
|
768
|
+
else
|
|
769
|
+
80
|
|
770
|
+
end
|
|
771
|
+
end
|
|
772
|
+
|
|
773
|
+
##
|
|
774
|
+
# Checks if a class is a Taski::Section subclass.
|
|
775
|
+
# Delegates to the class method.
|
|
776
|
+
# @param klass [Class] The class to check.
|
|
777
|
+
# @return [Boolean] true if the class is a Section.
|
|
778
|
+
def section_class?(klass)
|
|
779
|
+
self.class.section_class?(klass)
|
|
780
|
+
end
|
|
781
|
+
|
|
782
|
+
##
|
|
783
|
+
# Checks if a class is nested within another class.
|
|
784
|
+
# Delegates to the class method.
|
|
785
|
+
# @param child_class [Class] The potential nested class.
|
|
786
|
+
# @param parent_class [Class] The potential parent class.
|
|
787
|
+
# @return [Boolean] true if child_class is nested within parent_class.
|
|
788
|
+
def nested_class?(child_class, parent_class)
|
|
789
|
+
self.class.nested_class?(child_class, parent_class)
|
|
790
|
+
end
|
|
791
|
+
end
|
|
792
|
+
end
|
|
793
|
+
end
|