taski 0.8.3 → 0.9.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 +52 -0
- data/README.md +108 -50
- data/docs/GUIDE.md +79 -55
- data/examples/README.md +10 -29
- data/examples/clean_demo.rb +25 -65
- data/examples/large_tree_demo.rb +356 -0
- data/examples/message_demo.rb +0 -1
- data/examples/progress_demo.rb +13 -24
- data/examples/reexecution_demo.rb +8 -44
- data/lib/taski/execution/execution_facade.rb +150 -0
- data/lib/taski/execution/executor.rb +167 -359
- data/lib/taski/execution/fiber_protocol.rb +27 -0
- data/lib/taski/execution/registry.rb +15 -19
- data/lib/taski/execution/scheduler.rb +161 -140
- data/lib/taski/execution/task_observer.rb +41 -0
- data/lib/taski/execution/task_output_router.rb +41 -58
- data/lib/taski/execution/task_wrapper.rb +123 -219
- data/lib/taski/execution/worker_pool.rb +279 -64
- data/lib/taski/logging.rb +105 -0
- data/lib/taski/progress/layout/base.rb +600 -0
- data/lib/taski/progress/layout/filters.rb +126 -0
- data/lib/taski/progress/layout/log.rb +27 -0
- data/lib/taski/progress/layout/simple.rb +166 -0
- data/lib/taski/progress/layout/tags.rb +76 -0
- data/lib/taski/progress/layout/theme_drop.rb +84 -0
- data/lib/taski/progress/layout/tree.rb +300 -0
- data/lib/taski/progress/theme/base.rb +224 -0
- data/lib/taski/progress/theme/compact.rb +58 -0
- data/lib/taski/progress/theme/default.rb +25 -0
- data/lib/taski/progress/theme/detail.rb +48 -0
- data/lib/taski/progress/theme/plain.rb +40 -0
- data/lib/taski/static_analysis/analyzer.rb +5 -17
- data/lib/taski/static_analysis/dependency_graph.rb +19 -1
- data/lib/taski/static_analysis/start_dep_analyzer.rb +400 -0
- data/lib/taski/static_analysis/visitor.rb +1 -39
- data/lib/taski/task.rb +49 -58
- data/lib/taski/task_proxy.rb +59 -0
- data/lib/taski/test_helper/errors.rb +1 -1
- data/lib/taski/test_helper.rb +22 -36
- data/lib/taski/version.rb +1 -1
- data/lib/taski.rb +62 -61
- data/sig/taski.rbs +194 -203
- metadata +34 -8
- data/examples/section_demo.rb +0 -195
- data/lib/taski/execution/base_progress_display.rb +0 -393
- data/lib/taski/execution/execution_context.rb +0 -390
- data/lib/taski/execution/plain_progress_display.rb +0 -76
- data/lib/taski/execution/simple_progress_display.rb +0 -247
- data/lib/taski/execution/tree_progress_display.rb +0 -643
- data/lib/taski/section.rb +0 -74
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
require "liquid"
|
|
5
|
+
require_relative "../theme/default"
|
|
6
|
+
require_relative "../../execution/task_observer"
|
|
7
|
+
require_relative "filters"
|
|
8
|
+
require_relative "tags"
|
|
9
|
+
require_relative "theme_drop"
|
|
10
|
+
|
|
11
|
+
module Taski
|
|
12
|
+
module Progress
|
|
13
|
+
module Layout
|
|
14
|
+
# Base class for layout implementations.
|
|
15
|
+
# Layouts are responsible for:
|
|
16
|
+
# - Receiving events from ExecutionFacade (Observer pattern)
|
|
17
|
+
# - Managing task state tracking
|
|
18
|
+
# - Rendering templates using Liquid
|
|
19
|
+
# - Handling screen output
|
|
20
|
+
#
|
|
21
|
+
# === Observer Interface ===
|
|
22
|
+
#
|
|
23
|
+
# ExecutionFacade Event | Observer Method | Theme Method
|
|
24
|
+
# -----------------------------|----------------------|----------------------------
|
|
25
|
+
# notify_ready | on_ready | (pulls root_task, output_capture)
|
|
26
|
+
# notify_start | on_start | execution_start
|
|
27
|
+
# notify_stop | on_stop | execution_complete/fail
|
|
28
|
+
# notify_task_updated | on_task_updated | task_start/success/fail/skip/clean_*
|
|
29
|
+
# notify_group_started | on_group_started | group_start
|
|
30
|
+
# notify_group_completed | on_group_completed | group_success/fail
|
|
31
|
+
class Base < Taski::Execution::TaskObserver
|
|
32
|
+
attr_reader :spinner_index
|
|
33
|
+
|
|
34
|
+
def initialize(output: $stderr, theme: nil)
|
|
35
|
+
@output = output
|
|
36
|
+
@context = nil
|
|
37
|
+
@theme = theme || Theme::Default.new
|
|
38
|
+
@theme_drop = ThemeDrop.new(@theme)
|
|
39
|
+
@liquid_environment = build_liquid_environment
|
|
40
|
+
@monitor = Monitor.new
|
|
41
|
+
@tasks = {}
|
|
42
|
+
@nest_level = 0
|
|
43
|
+
@start_time = nil
|
|
44
|
+
@root_task_class = nil
|
|
45
|
+
@output_capture = nil
|
|
46
|
+
@message_queue = []
|
|
47
|
+
@group_start_times = {}
|
|
48
|
+
@spinner_index = 0
|
|
49
|
+
@spinner_timer = nil
|
|
50
|
+
@spinner_running = false
|
|
51
|
+
@active = false
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# === Observer Interface (called by ExecutionFacade) ===
|
|
55
|
+
|
|
56
|
+
# Event 1: Facade is ready (root task set, output capture available).
|
|
57
|
+
# Pulls root_task_class and output_capture from context.
|
|
58
|
+
# Only sets root_task_class once (prevents nested executor overwrite).
|
|
59
|
+
def on_ready
|
|
60
|
+
@monitor.synchronize do
|
|
61
|
+
return if @root_task_class
|
|
62
|
+
@root_task_class = @context&.root_task_class
|
|
63
|
+
@output_capture = @context&.output_capture
|
|
64
|
+
handle_ready
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Event 2: Start progress display.
|
|
69
|
+
# Increments nest level for nested executor support.
|
|
70
|
+
def on_start
|
|
71
|
+
should_start = false
|
|
72
|
+
@monitor.synchronize do
|
|
73
|
+
@nest_level += 1
|
|
74
|
+
return if @nest_level > 1
|
|
75
|
+
return unless should_activate?
|
|
76
|
+
@start_time = Time.now
|
|
77
|
+
@active = true
|
|
78
|
+
should_start = true
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
handle_start if should_start
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Event 3: Stop progress display.
|
|
85
|
+
# Only finalizes when nest level reaches 0 and layout was actually activated.
|
|
86
|
+
def on_stop
|
|
87
|
+
should_stop = false
|
|
88
|
+
was_active = false
|
|
89
|
+
@monitor.synchronize do
|
|
90
|
+
@nest_level -= 1 if @nest_level > 0
|
|
91
|
+
return unless @nest_level == 0
|
|
92
|
+
was_active = @active
|
|
93
|
+
@active = false
|
|
94
|
+
should_stop = true
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
return unless should_stop
|
|
98
|
+
handle_stop if was_active
|
|
99
|
+
flush_queued_messages
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Event 4: Task state transition.
|
|
103
|
+
# @param task_class [Class]
|
|
104
|
+
# @param previous_state [Symbol, nil]
|
|
105
|
+
# @param current_state [Symbol]
|
|
106
|
+
# @param phase [Symbol] :run or :clean
|
|
107
|
+
# @param timestamp [Time]
|
|
108
|
+
def on_task_updated(task_class, previous_state:, current_state:, phase:, timestamp:)
|
|
109
|
+
@monitor.synchronize do
|
|
110
|
+
progress = @tasks[task_class] ||= new_task_progress
|
|
111
|
+
apply_state_transition(progress, current_state, phase, timestamp)
|
|
112
|
+
handle_task_update(task_class, current_state, phase)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Event 5: Group started within a task.
|
|
117
|
+
# @param task_class [Class]
|
|
118
|
+
# @param group_name [String]
|
|
119
|
+
# @param phase [Symbol] :run or :clean
|
|
120
|
+
# @param timestamp [Time]
|
|
121
|
+
def on_group_started(task_class, group_name, phase:, timestamp:)
|
|
122
|
+
@monitor.synchronize do
|
|
123
|
+
@group_start_times[[task_class, group_name]] = timestamp
|
|
124
|
+
handle_group_started(task_class, group_name, phase)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Event 6: Group completed within a task.
|
|
129
|
+
# @param task_class [Class]
|
|
130
|
+
# @param group_name [String]
|
|
131
|
+
# @param phase [Symbol] :run or :clean
|
|
132
|
+
# @param timestamp [Time]
|
|
133
|
+
def on_group_completed(task_class, group_name, phase:, timestamp:)
|
|
134
|
+
@monitor.synchronize do
|
|
135
|
+
started_at = @group_start_times.delete([task_class, group_name])
|
|
136
|
+
duration = started_at ? ((timestamp - started_at) * 1000).round : nil
|
|
137
|
+
handle_group_completed(task_class, group_name, phase, duration)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Check if a task is registered
|
|
142
|
+
# @param task_class [Class] The task class to check
|
|
143
|
+
# @return [Boolean] true if the task is registered
|
|
144
|
+
def task_registered?(task_class)
|
|
145
|
+
@monitor.synchronize { @tasks.key?(task_class) }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Get the current state of a task
|
|
149
|
+
# @param task_class [Class] The task class
|
|
150
|
+
# @return [Symbol, nil] The task state or nil if not registered
|
|
151
|
+
def task_state(task_class)
|
|
152
|
+
p = @monitor.synchronize { @tasks[task_class] }
|
|
153
|
+
return nil unless p
|
|
154
|
+
p[:clean_state] || p[:run_state]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Queue a message to be displayed after progress display stops
|
|
158
|
+
# Thread-safe for concurrent task execution
|
|
159
|
+
# @param text [String] The message text to queue
|
|
160
|
+
def queue_message(text)
|
|
161
|
+
@monitor.synchronize { @message_queue << text }
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Render a Liquid template string with the given variables.
|
|
165
|
+
# Uses scoped Liquid environment with ColorFilter and SpinnerTag.
|
|
166
|
+
#
|
|
167
|
+
# @param template_string [String] Liquid template string
|
|
168
|
+
# @param state [Symbol, nil] State for icon tag
|
|
169
|
+
# @param task [TaskDrop, nil] Task drop
|
|
170
|
+
# @param execution [ExecutionDrop, nil] Execution drop
|
|
171
|
+
# @return [String] Rendered output
|
|
172
|
+
def render_template_string(template_string, state: nil, task: nil, execution: nil, **variables)
|
|
173
|
+
context_vars = build_context_vars(task:, execution:, **variables)
|
|
174
|
+
template = Liquid::Template.parse(template_string, environment: @liquid_environment)
|
|
175
|
+
template.assigns["state"] = state
|
|
176
|
+
template.render(context_vars)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Start the spinner animation timer.
|
|
180
|
+
# Increments spinner_index at the template's spinner_interval.
|
|
181
|
+
def start_spinner_timer
|
|
182
|
+
@monitor.synchronize do
|
|
183
|
+
return if @spinner_running
|
|
184
|
+
@spinner_running = true
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
@spinner_timer = Thread.new do
|
|
188
|
+
loop do
|
|
189
|
+
running = @monitor.synchronize { @spinner_running }
|
|
190
|
+
break unless running
|
|
191
|
+
sleep @theme.spinner_interval
|
|
192
|
+
@monitor.synchronize do
|
|
193
|
+
@spinner_index = (@spinner_index + 1) % @theme.spinner_frames.size
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Stop the spinner animation timer.
|
|
200
|
+
def stop_spinner_timer
|
|
201
|
+
@monitor.synchronize { @spinner_running = false }
|
|
202
|
+
@spinner_timer&.join
|
|
203
|
+
@spinner_timer = nil
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
protected
|
|
207
|
+
|
|
208
|
+
# === Template methods - Override in subclasses ===
|
|
209
|
+
|
|
210
|
+
# Called when facade is ready (root task and output capture available).
|
|
211
|
+
# Override to build tree structure.
|
|
212
|
+
def handle_ready
|
|
213
|
+
# Default: no-op
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Called when a task state is updated.
|
|
217
|
+
# Default: render and output the event.
|
|
218
|
+
def handle_task_update(task_class, current_state, phase)
|
|
219
|
+
progress = @tasks[task_class]
|
|
220
|
+
duration = compute_duration(progress, phase)
|
|
221
|
+
text = render_for_task_event(task_class, current_state, duration, nil, phase)
|
|
222
|
+
output_line(text) if text
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Called when a group has started.
|
|
226
|
+
# Default: render and output the event.
|
|
227
|
+
def handle_group_started(task_class, group_name, phase)
|
|
228
|
+
text = render_group_started(task_class, group_name: group_name)
|
|
229
|
+
output_line(text) if text
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Called when a group has completed.
|
|
233
|
+
# Default: render and output the event.
|
|
234
|
+
def handle_group_completed(task_class, group_name, phase, duration)
|
|
235
|
+
text = render_group_succeeded(task_class, group_name: group_name, task_duration: duration)
|
|
236
|
+
output_line(text) if text
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Register a task for tracking (used internally by subclasses like Tree).
|
|
240
|
+
# @param task_class [Class] The task class to register
|
|
241
|
+
def register_task(task_class)
|
|
242
|
+
return if @tasks.key?(task_class)
|
|
243
|
+
@tasks[task_class] = new_task_progress
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Determine if display should activate.
|
|
247
|
+
# @return [Boolean] true if display should start
|
|
248
|
+
def should_activate?
|
|
249
|
+
true
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Called when display starts.
|
|
253
|
+
# Default: output execution start message.
|
|
254
|
+
def handle_start
|
|
255
|
+
return unless @root_task_class
|
|
256
|
+
output_line(render_execution_started(@root_task_class))
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Called when display stops.
|
|
260
|
+
# Default: output execution complete or fail message.
|
|
261
|
+
def handle_stop
|
|
262
|
+
output_line(render_execution_summary)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Render execution summary based on current state (success or failure)
|
|
266
|
+
def render_execution_summary
|
|
267
|
+
if failed_count > 0
|
|
268
|
+
render_execution_failed(failed_count: failed_count, total_count: total_count, total_duration: total_duration, skipped_count: skipped_count)
|
|
269
|
+
else
|
|
270
|
+
render_execution_completed(completed_count: completed_count, total_count: total_count, total_duration: total_duration, skipped_count: skipped_count)
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# === Template rendering helpers ===
|
|
275
|
+
|
|
276
|
+
# Render a task-level template with task and execution drops.
|
|
277
|
+
# Uses task.state for icon tag.
|
|
278
|
+
#
|
|
279
|
+
# @param method_name [Symbol] The template method to call
|
|
280
|
+
# @param task [TaskDrop] Task-level drop
|
|
281
|
+
# @param execution [ExecutionDrop] Execution-level drop
|
|
282
|
+
# @return [String] The rendered template
|
|
283
|
+
def render_task_template(method_name, task:, execution:)
|
|
284
|
+
template_string = @theme.public_send(method_name)
|
|
285
|
+
render_template_string(template_string, state: task.invoke_drop("state"), task:, execution:)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Render an execution-level template with execution drop only.
|
|
289
|
+
# Uses execution.state for icon tag.
|
|
290
|
+
#
|
|
291
|
+
# @param method_name [Symbol] The template method to call
|
|
292
|
+
# @param execution [ExecutionDrop] Execution-level drop
|
|
293
|
+
# @param task [TaskDrop, nil] Optional task drop (for stdout in execution_running)
|
|
294
|
+
# @return [String] The rendered template
|
|
295
|
+
def render_execution_template(method_name, execution:, task: nil)
|
|
296
|
+
template_string = @theme.public_send(method_name)
|
|
297
|
+
render_template_string(template_string, state: execution.invoke_drop("state"), task:, execution:)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# === Event-to-template rendering methods ===
|
|
301
|
+
# These methods define which template is used for each event.
|
|
302
|
+
# Subclasses call these instead of render_template directly.
|
|
303
|
+
#
|
|
304
|
+
# Task-level methods pass both TaskDrop and ExecutionDrop so templates
|
|
305
|
+
# can display progress like "[3/5] TaskName".
|
|
306
|
+
# Execution-level methods pass only ExecutionDrop.
|
|
307
|
+
|
|
308
|
+
# Render task start event
|
|
309
|
+
def render_task_started(task_class)
|
|
310
|
+
task = TaskDrop.new(name: task_class_name(task_class), state: :running)
|
|
311
|
+
render_task_template(:task_start, task:, execution: execution_drop)
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# Render task success event
|
|
315
|
+
def render_task_succeeded(task_class, task_duration:)
|
|
316
|
+
task = TaskDrop.new(name: task_class_name(task_class), state: :completed, duration: task_duration)
|
|
317
|
+
render_task_template(:task_success, task:, execution: execution_drop)
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# Render task failure event
|
|
321
|
+
def render_task_failed(task_class, error:)
|
|
322
|
+
task = TaskDrop.new(name: task_class_name(task_class), state: :failed, error_message: error&.message)
|
|
323
|
+
render_task_template(:task_fail, task:, execution: execution_drop)
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# Render task skipped event
|
|
327
|
+
def render_task_skipped(task_class)
|
|
328
|
+
task = TaskDrop.new(name: task_class_name(task_class), state: :skipped)
|
|
329
|
+
render_task_template(:task_skip, task:, execution: execution_drop)
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# Render clean start event (uses unified :running state)
|
|
333
|
+
def render_clean_started(task_class)
|
|
334
|
+
task = TaskDrop.new(name: task_class_name(task_class), state: :running)
|
|
335
|
+
render_task_template(:clean_start, task:, execution: execution_drop)
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# Render clean success event (uses unified :completed state)
|
|
339
|
+
def render_clean_succeeded(task_class, task_duration:)
|
|
340
|
+
task = TaskDrop.new(name: task_class_name(task_class), state: :completed, duration: task_duration)
|
|
341
|
+
render_task_template(:clean_success, task:, execution: execution_drop)
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# Render clean failure event (uses unified :failed state)
|
|
345
|
+
def render_clean_failed(task_class, error:)
|
|
346
|
+
task = TaskDrop.new(name: task_class_name(task_class), state: :failed, error_message: error&.message)
|
|
347
|
+
render_task_template(:clean_fail, task:, execution: execution_drop)
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# Render group start event
|
|
351
|
+
def render_group_started(task_class, group_name:)
|
|
352
|
+
task = TaskDrop.new(name: task_class_name(task_class), state: :running, group_name:)
|
|
353
|
+
render_task_template(:group_start, task:, execution: execution_drop)
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# Render group success event
|
|
357
|
+
def render_group_succeeded(task_class, group_name:, task_duration:)
|
|
358
|
+
task = TaskDrop.new(name: task_class_name(task_class), state: :completed, group_name:, duration: task_duration)
|
|
359
|
+
render_task_template(:group_success, task:, execution: execution_drop)
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# Render group failure event
|
|
363
|
+
def render_group_failed(task_class, group_name:, error:)
|
|
364
|
+
task = TaskDrop.new(name: task_class_name(task_class), state: :failed, group_name:, error_message: error&.message)
|
|
365
|
+
render_task_template(:group_fail, task:, execution: execution_drop)
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# Render execution start event
|
|
369
|
+
def render_execution_started(root_task_class)
|
|
370
|
+
execution = ExecutionDrop.new(state: :running, root_task_name: task_class_name(root_task_class), **execution_context)
|
|
371
|
+
render_execution_template(:execution_start, execution:)
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# Render execution complete event
|
|
375
|
+
def render_execution_completed(completed_count:, total_count:, total_duration:, skipped_count: 0)
|
|
376
|
+
execution = ExecutionDrop.new(state: :completed, completed_count:, total_count:, total_duration:, skipped_count:)
|
|
377
|
+
render_execution_template(:execution_complete, execution:)
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# Render execution failure event
|
|
381
|
+
def render_execution_failed(failed_count:, total_count:, total_duration:, skipped_count: 0)
|
|
382
|
+
execution = ExecutionDrop.new(state: :failed, failed_count:, total_count:, total_duration:, skipped_count:)
|
|
383
|
+
render_execution_template(:execution_fail, execution:)
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# Render execution running state (includes task for stdout display)
|
|
387
|
+
def render_execution_running(done_count:, total_count:, task_names:, task_stdout:)
|
|
388
|
+
task = TaskDrop.new(stdout: task_stdout)
|
|
389
|
+
execution = ExecutionDrop.new(state: :running, done_count:, total_count:, task_names:)
|
|
390
|
+
render_execution_template(:execution_running, execution:, task:)
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
# Returns current execution context as a hash
|
|
394
|
+
def execution_context
|
|
395
|
+
{
|
|
396
|
+
state: execution_state,
|
|
397
|
+
pending_count: pending_count,
|
|
398
|
+
done_count: done_count,
|
|
399
|
+
completed_count: completed_count,
|
|
400
|
+
failed_count: failed_count,
|
|
401
|
+
skipped_count: skipped_count,
|
|
402
|
+
total_count: total_count,
|
|
403
|
+
total_duration: total_duration,
|
|
404
|
+
root_task_name: task_class_name(@root_task_class)
|
|
405
|
+
}
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# Returns current execution state
|
|
409
|
+
def execution_state
|
|
410
|
+
if failed_count > 0
|
|
411
|
+
:failed
|
|
412
|
+
elsif done_count == total_count && total_count > 0
|
|
413
|
+
:completed
|
|
414
|
+
else
|
|
415
|
+
:running
|
|
416
|
+
end
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# Returns current execution context as an ExecutionDrop
|
|
420
|
+
def execution_drop
|
|
421
|
+
ExecutionDrop.new(**execution_context)
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
# === State-to-render dispatchers ===
|
|
425
|
+
# These methods map state values to the appropriate render method.
|
|
426
|
+
|
|
427
|
+
# Dispatch task event to appropriate render method
|
|
428
|
+
# @return [String, nil] Rendered output or nil if state not handled
|
|
429
|
+
def render_for_task_event(task_class, state, task_duration, error, phase = nil)
|
|
430
|
+
if phase == :clean
|
|
431
|
+
case state
|
|
432
|
+
when :running
|
|
433
|
+
render_clean_started(task_class)
|
|
434
|
+
when :completed
|
|
435
|
+
render_clean_succeeded(task_class, task_duration: task_duration)
|
|
436
|
+
when :failed
|
|
437
|
+
render_clean_failed(task_class, error: error)
|
|
438
|
+
end
|
|
439
|
+
else
|
|
440
|
+
case state
|
|
441
|
+
when :running
|
|
442
|
+
render_task_started(task_class)
|
|
443
|
+
when :completed
|
|
444
|
+
render_task_succeeded(task_class, task_duration: task_duration)
|
|
445
|
+
when :failed
|
|
446
|
+
render_task_failed(task_class, error: error)
|
|
447
|
+
when :skipped
|
|
448
|
+
render_task_skipped(task_class)
|
|
449
|
+
end
|
|
450
|
+
end
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
# Output a line to the output stream
|
|
454
|
+
# @param text [String] The text to output
|
|
455
|
+
def output_line(text)
|
|
456
|
+
@output.puts(text)
|
|
457
|
+
@output.flush
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
# === Task state query helpers ===
|
|
461
|
+
|
|
462
|
+
def running_tasks
|
|
463
|
+
@tasks.select { |_, p| p[:run_state] == :running }
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
def cleaning_tasks
|
|
467
|
+
@tasks.select { |_, p| p[:clean_state] == :running }
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
def pending_tasks
|
|
471
|
+
@tasks.select { |_, p| p[:run_state] == :pending }
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
def completed_tasks
|
|
475
|
+
@tasks.select { |_, p| p[:run_state] == :completed }
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
def failed_tasks
|
|
479
|
+
@tasks.select { |_, p| p[:run_state] == :failed }
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
def pending_count
|
|
483
|
+
@tasks.values.count { |p| p[:run_state] == :pending }
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
def done_count
|
|
487
|
+
@tasks.values.count { |p| [:completed, :failed, :skipped].include?(p[:run_state]) }
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
def skipped_count
|
|
491
|
+
@tasks.values.count { |p| p[:run_state] == :skipped }
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
def completed_count
|
|
495
|
+
@tasks.values.count { |p| p[:run_state] == :completed }
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
def failed_count
|
|
499
|
+
@tasks.values.count { |p| p[:run_state] == :failed }
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
def total_count
|
|
503
|
+
@tasks.size
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
def total_duration
|
|
507
|
+
@start_time ? ((Time.now - @start_time) * 1000).to_i : 0
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
# === Utility methods ===
|
|
511
|
+
|
|
512
|
+
# Get full name of a task class (for use with short_name filter in templates)
|
|
513
|
+
def task_class_name(task_class)
|
|
514
|
+
return nil unless task_class
|
|
515
|
+
task_class.name || task_class.to_s
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
# Check if output is a TTY
|
|
519
|
+
def tty?
|
|
520
|
+
@output.tty?
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
private
|
|
524
|
+
|
|
525
|
+
# Build the Liquid environment with filters and tags registered.
|
|
526
|
+
# Uses scoped registration (not global) per Liquid 5.x recommendations.
|
|
527
|
+
#
|
|
528
|
+
# @return [Liquid::Environment] Configured Liquid environment
|
|
529
|
+
def build_liquid_environment
|
|
530
|
+
Liquid::Environment.build do |env|
|
|
531
|
+
env.register_filter(ColorFilter)
|
|
532
|
+
env.register_tag("spinner", SpinnerTag)
|
|
533
|
+
env.register_tag("icon", IconTag)
|
|
534
|
+
end
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
# Build context variables hash for Liquid rendering.
|
|
538
|
+
#
|
|
539
|
+
# @param variables [Hash] User-provided variables
|
|
540
|
+
# @return [Hash] Context variables with template drop and spinner index
|
|
541
|
+
def build_context_vars(variables)
|
|
542
|
+
spinner_idx = @monitor.synchronize { @spinner_index }
|
|
543
|
+
base_vars = {
|
|
544
|
+
"template" => @theme_drop,
|
|
545
|
+
"spinner_index" => variables[:spinner_index] || spinner_idx
|
|
546
|
+
}
|
|
547
|
+
stringify_keys(variables).merge(base_vars)
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
def stringify_keys(hash)
|
|
551
|
+
hash.transform_keys(&:to_s)
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
def flush_queued_messages
|
|
555
|
+
messages = @monitor.synchronize { @message_queue.dup.tap { @message_queue.clear } }
|
|
556
|
+
messages.each { |msg| @output.puts(msg) }
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
def new_task_progress
|
|
560
|
+
{run_state: :pending, clean_state: nil, run_duration: nil, clean_duration: nil}
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
def compute_duration(progress, phase)
|
|
564
|
+
return nil unless progress
|
|
565
|
+
(phase == :clean) ? progress[:clean_duration] : progress[:run_duration]
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
# Apply state transition. Computes duration when transitioning out of :running.
|
|
569
|
+
def apply_state_transition(progress, state, phase, timestamp)
|
|
570
|
+
state_key, duration_key, started_key = if phase == :clean
|
|
571
|
+
[:clean_state, :clean_duration, :_clean_started]
|
|
572
|
+
else
|
|
573
|
+
[:run_state, :run_duration, :_run_started]
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
case state
|
|
577
|
+
when :running
|
|
578
|
+
return if phase != :clean && run_state_terminal?(progress)
|
|
579
|
+
progress[state_key] = :running
|
|
580
|
+
progress[started_key] = timestamp
|
|
581
|
+
when :completed, :failed
|
|
582
|
+
progress[state_key] = state
|
|
583
|
+
progress[duration_key] = duration_ms(progress[started_key], timestamp)
|
|
584
|
+
when :skipped
|
|
585
|
+
return if run_state_terminal?(progress)
|
|
586
|
+
progress[state_key] = :skipped
|
|
587
|
+
end
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
def run_state_terminal?(progress)
|
|
591
|
+
[:completed, :failed, :skipped].include?(progress[:run_state])
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
def duration_ms(started, ended)
|
|
595
|
+
(started && ended) ? ((ended - started) * 1000).round(1) : nil
|
|
596
|
+
end
|
|
597
|
+
end
|
|
598
|
+
end
|
|
599
|
+
end
|
|
600
|
+
end
|