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
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base_progress_display"
|
|
4
|
+
|
|
5
|
+
module Taski
|
|
6
|
+
module Execution
|
|
7
|
+
# PlainProgressDisplay provides plain text output without terminal escape codes.
|
|
8
|
+
# Designed for non-TTY environments (CI, log files, piped output).
|
|
9
|
+
#
|
|
10
|
+
# Output format:
|
|
11
|
+
# [START] TaskName
|
|
12
|
+
# [DONE] TaskName (123.4ms)
|
|
13
|
+
# [FAIL] TaskName: Error message
|
|
14
|
+
#
|
|
15
|
+
# Enable with: TASKI_PROGRESS_MODE=plain
|
|
16
|
+
class PlainProgressDisplay < BaseProgressDisplay
|
|
17
|
+
def initialize(output: $stderr)
|
|
18
|
+
super
|
|
19
|
+
@output.sync = true if @output.respond_to?(:sync=)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
protected
|
|
23
|
+
|
|
24
|
+
# Template method: Called when a section impl is registered
|
|
25
|
+
def on_section_impl_registered(_section_class, impl_class)
|
|
26
|
+
@tasks[impl_class] ||= TaskProgress.new
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Template method: Called when a task state is updated
|
|
30
|
+
def on_task_updated(task_class, state, duration, error)
|
|
31
|
+
case state
|
|
32
|
+
when :running
|
|
33
|
+
@output.puts "[START] #{short_name(task_class)}"
|
|
34
|
+
when :completed
|
|
35
|
+
duration_str = duration ? " (#{format_duration(duration)})" : ""
|
|
36
|
+
@output.puts "[DONE] #{short_name(task_class)}#{duration_str}"
|
|
37
|
+
when :failed
|
|
38
|
+
error_msg = error ? ": #{error.message}" : ""
|
|
39
|
+
@output.puts "[FAIL] #{short_name(task_class)}#{error_msg}"
|
|
40
|
+
when :cleaning
|
|
41
|
+
@output.puts "[CLEAN] #{short_name(task_class)}"
|
|
42
|
+
when :clean_completed
|
|
43
|
+
duration_str = duration ? " (#{format_duration(duration)})" : ""
|
|
44
|
+
@output.puts "[CLEAN DONE] #{short_name(task_class)}#{duration_str}"
|
|
45
|
+
when :clean_failed
|
|
46
|
+
error_msg = error ? ": #{error.message}" : ""
|
|
47
|
+
@output.puts "[CLEAN FAIL] #{short_name(task_class)}#{error_msg}"
|
|
48
|
+
end
|
|
49
|
+
@output.flush
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Template method: Called when display starts
|
|
53
|
+
def on_start
|
|
54
|
+
if @root_task_class
|
|
55
|
+
@output.puts "[TASKI] Starting #{short_name(@root_task_class)}"
|
|
56
|
+
@output.flush
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Template method: Called when display stops
|
|
61
|
+
def on_stop
|
|
62
|
+
total_duration = @start_time ? ((Time.now - @start_time) * 1000).to_i : 0
|
|
63
|
+
completed = @tasks.values.count { |t| t.run_state == :completed }
|
|
64
|
+
failed = @tasks.values.count { |t| t.run_state == :failed }
|
|
65
|
+
total = @tasks.size
|
|
66
|
+
|
|
67
|
+
if failed > 0
|
|
68
|
+
@output.puts "[TASKI] Failed: #{failed}/#{total} tasks (#{total_duration}ms)"
|
|
69
|
+
else
|
|
70
|
+
@output.puts "[TASKI] Completed: #{completed}/#{total} tasks (#{total_duration}ms)"
|
|
71
|
+
end
|
|
72
|
+
@output.flush
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -16,7 +16,9 @@ module Taski
|
|
|
16
16
|
# @yield Block to create the task instance if it doesn't exist
|
|
17
17
|
# @return [Object] The task instance
|
|
18
18
|
def get_or_create(task_class)
|
|
19
|
-
@
|
|
19
|
+
@monitor.synchronize do
|
|
20
|
+
@tasks[task_class] ||= yield
|
|
21
|
+
end
|
|
20
22
|
end
|
|
21
23
|
|
|
22
24
|
# @param task_class [Class] The task class
|
|
@@ -61,6 +63,20 @@ module Taski
|
|
|
61
63
|
@monitor.synchronize { @abort_requested }
|
|
62
64
|
end
|
|
63
65
|
|
|
66
|
+
# @return [Array<TaskWrapper>] All wrappers that have errors
|
|
67
|
+
def failed_wrappers
|
|
68
|
+
@monitor.synchronize do
|
|
69
|
+
@tasks.values.select { |w| w.error }
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @return [Array<TaskWrapper>] All wrappers that have clean errors
|
|
74
|
+
def failed_clean_wrappers
|
|
75
|
+
@monitor.synchronize do
|
|
76
|
+
@tasks.values.select { |w| w.clean_error }
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
64
80
|
# @param task_class [Class] The task class to run
|
|
65
81
|
# @param exported_methods [Array<Symbol>] Methods to call to trigger execution
|
|
66
82
|
# @return [Object] The result of the task execution
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Taski
|
|
4
|
+
module Execution
|
|
5
|
+
# Scheduler manages task dependency state and determines execution order.
|
|
6
|
+
# It tracks which tasks are pending, enqueued, or completed, and provides
|
|
7
|
+
# methods to determine which tasks are ready to execute.
|
|
8
|
+
#
|
|
9
|
+
# == Responsibilities
|
|
10
|
+
#
|
|
11
|
+
# - Build dependency graph from root task via static analysis
|
|
12
|
+
# - Track task states: pending, enqueued, completed
|
|
13
|
+
# - Determine which tasks are ready to execute (all dependencies completed)
|
|
14
|
+
# - Provide next_ready_tasks for the Executor's event loop
|
|
15
|
+
# - Build reverse dependency graph for clean operations
|
|
16
|
+
# - Track clean states independently from run states
|
|
17
|
+
# - Provide next_ready_clean_tasks for reverse dependency order execution
|
|
18
|
+
#
|
|
19
|
+
# == API
|
|
20
|
+
#
|
|
21
|
+
# Run operations:
|
|
22
|
+
# - {#build_dependency_graph} - Initialize dependency graph from root task
|
|
23
|
+
# - {#next_ready_tasks} - Get tasks ready for execution
|
|
24
|
+
# - {#mark_enqueued} - Mark task as sent to worker pool
|
|
25
|
+
# - {#mark_completed} - Mark task as finished
|
|
26
|
+
# - {#completed?} - Check if task is completed
|
|
27
|
+
# - {#running_tasks?} - Check if any tasks are currently executing
|
|
28
|
+
#
|
|
29
|
+
# Clean operations:
|
|
30
|
+
# - {#build_reverse_dependency_graph} - Build reverse graph for clean order
|
|
31
|
+
# - {#next_ready_clean_tasks} - Get tasks ready for clean (reverse order)
|
|
32
|
+
# - {#mark_clean_enqueued} - Mark task as sent for clean
|
|
33
|
+
# - {#mark_clean_completed} - Mark task as clean finished
|
|
34
|
+
# - {#clean_completed?} - Check if task clean is completed
|
|
35
|
+
# - {#running_clean_tasks?} - Check if any clean tasks are currently executing
|
|
36
|
+
#
|
|
37
|
+
# == Thread Safety
|
|
38
|
+
#
|
|
39
|
+
# Scheduler is only accessed from the main thread in Executor,
|
|
40
|
+
# so no synchronization is needed. The Executor serializes all
|
|
41
|
+
# access to the Scheduler through its event loop.
|
|
42
|
+
class Scheduler
|
|
43
|
+
# Task execution states
|
|
44
|
+
STATE_PENDING = :pending
|
|
45
|
+
STATE_ENQUEUED = :enqueued
|
|
46
|
+
STATE_COMPLETED = :completed
|
|
47
|
+
|
|
48
|
+
# Clean execution states (independent from run states)
|
|
49
|
+
CLEAN_STATE_PENDING = :clean_pending
|
|
50
|
+
CLEAN_STATE_ENQUEUED = :clean_enqueued
|
|
51
|
+
CLEAN_STATE_COMPLETED = :clean_completed
|
|
52
|
+
|
|
53
|
+
##
|
|
54
|
+
# Initializes internal data structures used to track normal and clean task execution.
|
|
55
|
+
#
|
|
56
|
+
# Sets up:
|
|
57
|
+
# - @dependencies: map from task class to its dependency task classes.
|
|
58
|
+
# - @task_states: map from task class to its normal execution state.
|
|
59
|
+
# - @completed_tasks: set of task classes that have completed normal execution.
|
|
60
|
+
# - @reverse_dependencies: map from task class to dependent task classes (used for clean ordering).
|
|
61
|
+
# - @clean_task_states: map from task class to its clean execution state.
|
|
62
|
+
# - @clean_completed_tasks: set of task classes that have completed clean execution.
|
|
63
|
+
def initialize
|
|
64
|
+
# Run execution state
|
|
65
|
+
@dependencies = {}
|
|
66
|
+
@task_states = {}
|
|
67
|
+
@completed_tasks = Set.new
|
|
68
|
+
|
|
69
|
+
# Clean execution state (independent tracking)
|
|
70
|
+
@reverse_dependencies = {}
|
|
71
|
+
@clean_task_states = {}
|
|
72
|
+
@clean_completed_tasks = Set.new
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Build dependency graph by traversing from root task.
|
|
76
|
+
# Populates internal state with all tasks and their dependencies.
|
|
77
|
+
#
|
|
78
|
+
# @param root_task_class [Class] The root task class to start from
|
|
79
|
+
def build_dependency_graph(root_task_class)
|
|
80
|
+
# @type var queue: Array[singleton(Taski::Task)]
|
|
81
|
+
queue = [root_task_class]
|
|
82
|
+
|
|
83
|
+
while (task_class = queue.shift)
|
|
84
|
+
next if @task_states.key?(task_class)
|
|
85
|
+
|
|
86
|
+
deps = task_class.cached_dependencies
|
|
87
|
+
@dependencies[task_class] = deps.dup
|
|
88
|
+
@task_states[task_class] = STATE_PENDING
|
|
89
|
+
|
|
90
|
+
deps.each { |dep| queue << dep }
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Get all tasks that are ready to execute.
|
|
95
|
+
# A task is ready when all its dependencies are completed.
|
|
96
|
+
#
|
|
97
|
+
# @return [Array<Class>] Array of task classes ready for execution
|
|
98
|
+
def next_ready_tasks
|
|
99
|
+
ready = []
|
|
100
|
+
@task_states.each_key do |task_class|
|
|
101
|
+
next unless @task_states[task_class] == STATE_PENDING
|
|
102
|
+
next unless ready_to_execute?(task_class)
|
|
103
|
+
ready << task_class
|
|
104
|
+
end
|
|
105
|
+
ready
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Mark a task as enqueued for execution.
|
|
109
|
+
#
|
|
110
|
+
# @param task_class [Class] The task class to mark
|
|
111
|
+
def mark_enqueued(task_class)
|
|
112
|
+
@task_states[task_class] = STATE_ENQUEUED
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Mark a task as completed.
|
|
116
|
+
#
|
|
117
|
+
# @param task_class [Class] The task class to mark
|
|
118
|
+
def mark_completed(task_class)
|
|
119
|
+
@task_states[task_class] = STATE_COMPLETED
|
|
120
|
+
@completed_tasks.add(task_class)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Check if a task is completed.
|
|
124
|
+
#
|
|
125
|
+
# @param task_class [Class] The task class to check
|
|
126
|
+
# @return [Boolean] true if the task is completed
|
|
127
|
+
def completed?(task_class)
|
|
128
|
+
@completed_tasks.include?(task_class)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Check if there are any running (enqueued) tasks.
|
|
132
|
+
#
|
|
133
|
+
##
|
|
134
|
+
# Indicates whether any tasks are currently enqueued for execution.
|
|
135
|
+
# @return [Boolean] `true` if any task is enqueued, `false` otherwise.
|
|
136
|
+
def running_tasks?
|
|
137
|
+
@task_states.values.any? { |state| state == STATE_ENQUEUED }
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# ========================================
|
|
141
|
+
# Runtime Dependency Merging
|
|
142
|
+
# ========================================
|
|
143
|
+
|
|
144
|
+
# Merge runtime dependencies into the dependency graph.
|
|
145
|
+
# Used to incorporate dynamically selected dependencies (e.g., Section implementations)
|
|
146
|
+
# that were determined during the run phase.
|
|
147
|
+
#
|
|
148
|
+
# This method also updates reverse dependencies if they exist (for clean operations).
|
|
149
|
+
# This method is idempotent - calling it multiple times with the same data is safe.
|
|
150
|
+
#
|
|
151
|
+
# @param runtime_deps [Hash{Class => Set<Class>}] Runtime dependencies from ExecutionContext
|
|
152
|
+
def merge_runtime_dependencies(runtime_deps)
|
|
153
|
+
runtime_deps.each do |from_class, to_classes|
|
|
154
|
+
# Ensure the from_class exists in the graph
|
|
155
|
+
@dependencies[from_class] ||= Set.new
|
|
156
|
+
@task_states[from_class] ||= STATE_PENDING
|
|
157
|
+
|
|
158
|
+
to_classes.each do |to_class|
|
|
159
|
+
# Add the dependency relationship
|
|
160
|
+
@dependencies[from_class].add(to_class)
|
|
161
|
+
|
|
162
|
+
# Add the to_class to the graph if not present
|
|
163
|
+
unless @dependencies.key?(to_class)
|
|
164
|
+
@dependencies[to_class] = to_class.cached_dependencies
|
|
165
|
+
@task_states[to_class] = STATE_PENDING
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Update reverse dependencies if they exist (for clean operations)
|
|
169
|
+
# If A depends on B (from_class→to_class), then B→[A] in reverse graph
|
|
170
|
+
if @reverse_dependencies.any?
|
|
171
|
+
@reverse_dependencies[to_class] ||= Set.new
|
|
172
|
+
@reverse_dependencies[to_class].add(from_class)
|
|
173
|
+
|
|
174
|
+
# Ensure to_class has clean state initialized
|
|
175
|
+
@clean_task_states[to_class] ||= CLEAN_STATE_PENDING
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# ========================================
|
|
182
|
+
# Clean Operations (Reverse Dependency Order)
|
|
183
|
+
# ========================================
|
|
184
|
+
|
|
185
|
+
# Build reverse dependency graph for clean operations.
|
|
186
|
+
# Clean operations run in reverse order: if A depends on B, then B must
|
|
187
|
+
# be cleaned after A (so A→[B] in reverse graph means B depends on A's clean).
|
|
188
|
+
#
|
|
189
|
+
# Also initializes clean states for all tasks to CLEAN_STATE_PENDING.
|
|
190
|
+
#
|
|
191
|
+
##
|
|
192
|
+
# Builds the reverse dependency graph and initializes per-task clean execution state starting from the given root task.
|
|
193
|
+
#
|
|
194
|
+
# Ensures the forward dependency graph is present, clears prior clean-state data, initializes each discovered task with
|
|
195
|
+
# an empty reverse-dependency set and `CLEAN_STATE_PENDING`, and populates reverse mappings so a task's clean run
|
|
196
|
+
# depends on the clean completion of tasks that depend on it.
|
|
197
|
+
# @param [Class] root_task_class The root task class from which to discover tasks and their dependencies.
|
|
198
|
+
def build_reverse_dependency_graph(root_task_class)
|
|
199
|
+
# First, ensure we have the forward dependency graph
|
|
200
|
+
build_dependency_graph(root_task_class) if @dependencies.empty?
|
|
201
|
+
|
|
202
|
+
# Clear previous clean state
|
|
203
|
+
@reverse_dependencies.clear
|
|
204
|
+
@clean_task_states.clear
|
|
205
|
+
@clean_completed_tasks.clear
|
|
206
|
+
|
|
207
|
+
# Initialize all tasks with empty reverse dependency sets
|
|
208
|
+
@dependencies.each_key do |task_class|
|
|
209
|
+
@reverse_dependencies[task_class] = Set.new
|
|
210
|
+
@clean_task_states[task_class] = CLEAN_STATE_PENDING
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Build reverse mappings: if A depends on B, then B→[A] in reverse graph
|
|
214
|
+
# This means B's clean depends on A's clean completing first
|
|
215
|
+
@dependencies.each do |task_class, deps|
|
|
216
|
+
deps.each do |dep_class|
|
|
217
|
+
@reverse_dependencies[dep_class].add(task_class)
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Get all tasks that are ready to clean.
|
|
223
|
+
# A task is ready to clean when all its reverse dependencies (dependents)
|
|
224
|
+
# have completed their clean operation.
|
|
225
|
+
#
|
|
226
|
+
##
|
|
227
|
+
# Lists task classes that are ready for clean execution.
|
|
228
|
+
# A task is considered ready when its clean state is `CLEAN_STATE_PENDING` and all of its reverse dependencies have completed their clean execution.
|
|
229
|
+
# @return [Array<Class>] Array of task classes ready for clean execution.
|
|
230
|
+
def next_ready_clean_tasks
|
|
231
|
+
ready = []
|
|
232
|
+
@clean_task_states.each_key do |task_class|
|
|
233
|
+
next unless @clean_task_states[task_class] == CLEAN_STATE_PENDING
|
|
234
|
+
next unless ready_to_clean?(task_class)
|
|
235
|
+
ready << task_class
|
|
236
|
+
end
|
|
237
|
+
ready
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Mark a task as enqueued for clean execution.
|
|
241
|
+
#
|
|
242
|
+
##
|
|
243
|
+
# Marks the given task class as enqueued for clean execution.
|
|
244
|
+
# @param [Class] task_class The task class to mark as enqueued for clean execution.
|
|
245
|
+
def mark_clean_enqueued(task_class)
|
|
246
|
+
@clean_task_states[task_class] = CLEAN_STATE_ENQUEUED
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Mark a task as clean completed.
|
|
250
|
+
#
|
|
251
|
+
##
|
|
252
|
+
# Marks the clean execution of the given task class as completed and records it.
|
|
253
|
+
# @param [Class] task_class - The task class to mark as clean completed.
|
|
254
|
+
def mark_clean_completed(task_class)
|
|
255
|
+
@clean_task_states[task_class] = CLEAN_STATE_COMPLETED
|
|
256
|
+
@clean_completed_tasks.add(task_class)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Check if a task's clean is completed.
|
|
260
|
+
#
|
|
261
|
+
# @param task_class [Class] The task class to check
|
|
262
|
+
##
|
|
263
|
+
# Checks whether a task class has completed its clean execution.
|
|
264
|
+
# @param [Class] task_class - The task class to check.
|
|
265
|
+
# @return [Boolean] `true` if the task's clean is completed, `false` otherwise.
|
|
266
|
+
def clean_completed?(task_class)
|
|
267
|
+
@clean_completed_tasks.include?(task_class)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Check if there are any running (enqueued) clean tasks.
|
|
271
|
+
#
|
|
272
|
+
##
|
|
273
|
+
# Indicates whether any clean tasks are currently enqueued for execution.
|
|
274
|
+
# @return [Boolean] `true` if at least one clean task is enqueued, `false` otherwise.
|
|
275
|
+
def running_clean_tasks?
|
|
276
|
+
@clean_task_states.values.any? { |state| state == CLEAN_STATE_ENQUEUED }
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
private
|
|
280
|
+
|
|
281
|
+
# Check if a task is ready to execute (all dependencies completed).
|
|
282
|
+
#
|
|
283
|
+
# @param task_class [Class] The task class to check
|
|
284
|
+
##
|
|
285
|
+
# Determines whether a task's dependencies have all completed.
|
|
286
|
+
# @param [Class] task_class - The task class to check.
|
|
287
|
+
# @return [Boolean] `true` if every dependency of `task_class` is in the set of completed tasks, `false` otherwise.
|
|
288
|
+
def ready_to_execute?(task_class)
|
|
289
|
+
task_deps = @dependencies[task_class] || Set.new
|
|
290
|
+
task_deps.subset?(@completed_tasks)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Check if a task is ready to clean (all reverse dependencies completed).
|
|
294
|
+
# Reverse dependencies are tasks that depend on this task, which must
|
|
295
|
+
# be cleaned before this task can be cleaned.
|
|
296
|
+
#
|
|
297
|
+
# @param task_class [Class] The task class to check
|
|
298
|
+
##
|
|
299
|
+
# Determines whether a task is ready for clean execution.
|
|
300
|
+
# @param [Class] task_class - The task class to check.
|
|
301
|
+
# @return [Boolean] `true` if all tasks that depend on `task_class` have completed their clean execution, `false` otherwise.
|
|
302
|
+
def ready_to_clean?(task_class)
|
|
303
|
+
reverse_deps = @reverse_dependencies[task_class] || Set.new
|
|
304
|
+
reverse_deps.subset?(@clean_completed_tasks)
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
end
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base_progress_display"
|
|
4
|
+
|
|
5
|
+
module Taski
|
|
6
|
+
module Execution
|
|
7
|
+
# SimpleProgressDisplay provides a minimalist single-line progress display
|
|
8
|
+
# that shows task execution status in a compact format:
|
|
9
|
+
#
|
|
10
|
+
# ⠹ [3/5] DeployTask | Uploading files...
|
|
11
|
+
#
|
|
12
|
+
# This is an alternative to TreeProgressDisplay for users who prefer
|
|
13
|
+
# less verbose output.
|
|
14
|
+
class SimpleProgressDisplay < BaseProgressDisplay
|
|
15
|
+
SPINNER_FRAMES = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
|
|
16
|
+
RENDER_INTERVAL = 0.1
|
|
17
|
+
|
|
18
|
+
ICONS = {
|
|
19
|
+
success: "✓",
|
|
20
|
+
failure: "✗",
|
|
21
|
+
pending: "○"
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
COLORS = {
|
|
25
|
+
green: "\e[32m",
|
|
26
|
+
red: "\e[31m",
|
|
27
|
+
yellow: "\e[33m",
|
|
28
|
+
dim: "\e[2m",
|
|
29
|
+
reset: "\e[0m"
|
|
30
|
+
}.freeze
|
|
31
|
+
|
|
32
|
+
def initialize(output: $stdout)
|
|
33
|
+
super
|
|
34
|
+
@spinner_index = 0
|
|
35
|
+
@renderer_thread = nil
|
|
36
|
+
@running = false
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
protected
|
|
40
|
+
|
|
41
|
+
# Template method: Called when root task is set
|
|
42
|
+
def on_root_task_set
|
|
43
|
+
build_tree_structure
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Template method: Called when a section impl is registered
|
|
47
|
+
def on_section_impl_registered(_section_class, impl_class)
|
|
48
|
+
@tasks[impl_class] ||= TaskProgress.new
|
|
49
|
+
@tasks[impl_class].is_impl_candidate = false
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Template method: Determine if display should activate
|
|
53
|
+
def should_activate?
|
|
54
|
+
tty?
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Template method: Called when display starts
|
|
58
|
+
def on_start
|
|
59
|
+
@running = true
|
|
60
|
+
@output.print "\e[?25l" # Hide cursor
|
|
61
|
+
@renderer_thread = Thread.new do
|
|
62
|
+
loop do
|
|
63
|
+
break unless @running
|
|
64
|
+
render_live
|
|
65
|
+
sleep RENDER_INTERVAL
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Template method: Called when display stops
|
|
71
|
+
def on_stop
|
|
72
|
+
@running = false
|
|
73
|
+
@renderer_thread&.join
|
|
74
|
+
@output.print "\e[?25h" # Show cursor
|
|
75
|
+
render_final
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def build_tree_structure
|
|
81
|
+
return unless @root_task_class
|
|
82
|
+
|
|
83
|
+
# Use TreeProgressDisplay's static method for tree building
|
|
84
|
+
tree = TreeProgressDisplay.build_tree_node(@root_task_class)
|
|
85
|
+
register_tasks_from_tree(tree)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def render_live
|
|
89
|
+
@monitor.synchronize do
|
|
90
|
+
@spinner_index = (@spinner_index + 1) % SPINNER_FRAMES.size
|
|
91
|
+
line = build_status_line
|
|
92
|
+
# Clear line and write new content
|
|
93
|
+
@output.print "\r\e[K#{line}"
|
|
94
|
+
@output.flush
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def render_final
|
|
99
|
+
@monitor.synchronize do
|
|
100
|
+
total_duration = @start_time ? ((Time.now - @start_time) * 1000).to_i : 0
|
|
101
|
+
completed = @tasks.values.count { |p| p.run_state == :completed }
|
|
102
|
+
failed = @tasks.values.count { |p| p.run_state == :failed }
|
|
103
|
+
total = @tasks.size
|
|
104
|
+
|
|
105
|
+
line = if failed > 0
|
|
106
|
+
failed_tasks = @tasks.select { |_, p| p.run_state == :failed }
|
|
107
|
+
first_error = failed_tasks.values.first&.run_error
|
|
108
|
+
error_msg = first_error ? ": #{first_error.message}" : ""
|
|
109
|
+
"#{COLORS[:red]}#{ICONS[:failure]}#{COLORS[:reset]} [#{completed}/#{total}] " \
|
|
110
|
+
"#{failed_tasks.keys.first} failed#{error_msg}"
|
|
111
|
+
else
|
|
112
|
+
"#{COLORS[:green]}#{ICONS[:success]}#{COLORS[:reset]} [#{completed}/#{total}] " \
|
|
113
|
+
"All tasks completed (#{total_duration}ms)"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
@output.print "\r\e[K#{line}\n"
|
|
117
|
+
@output.flush
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def build_status_line
|
|
122
|
+
running_tasks = @tasks.select { |_, p| p.run_state == :running }
|
|
123
|
+
cleaning_tasks = @tasks.select { |_, p| p.clean_state == :cleaning }
|
|
124
|
+
completed = @tasks.values.count { |p| p.run_state == :completed }
|
|
125
|
+
failed = @tasks.values.count { |p| p.run_state == :failed }
|
|
126
|
+
total = @tasks.size
|
|
127
|
+
|
|
128
|
+
spinner = SPINNER_FRAMES[@spinner_index]
|
|
129
|
+
status_icon = if failed > 0
|
|
130
|
+
"#{COLORS[:red]}#{ICONS[:failure]}#{COLORS[:reset]}"
|
|
131
|
+
elsif running_tasks.any? || cleaning_tasks.any?
|
|
132
|
+
"#{COLORS[:yellow]}#{spinner}#{COLORS[:reset]}"
|
|
133
|
+
else
|
|
134
|
+
"#{COLORS[:green]}#{ICONS[:success]}#{COLORS[:reset]}"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Get current task names
|
|
138
|
+
current_tasks = if cleaning_tasks.any?
|
|
139
|
+
cleaning_tasks.keys.map { |t| short_name(t) }
|
|
140
|
+
else
|
|
141
|
+
running_tasks.keys.map { |t| short_name(t) }
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
task_names = current_tasks.first(3).join(", ")
|
|
145
|
+
task_names += "..." if current_tasks.size > 3
|
|
146
|
+
|
|
147
|
+
# Get last output message if available
|
|
148
|
+
output_suffix = build_output_suffix(running_tasks.keys.first || cleaning_tasks.keys.first)
|
|
149
|
+
|
|
150
|
+
parts = ["#{status_icon} [#{completed}/#{total}]"]
|
|
151
|
+
parts << task_names if task_names && !task_names.empty?
|
|
152
|
+
parts << "|" << output_suffix if output_suffix
|
|
153
|
+
|
|
154
|
+
parts.join(" ")
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def build_output_suffix(task_class)
|
|
158
|
+
return nil unless @output_capture && task_class
|
|
159
|
+
|
|
160
|
+
last_line = @output_capture.last_line_for(task_class)
|
|
161
|
+
return nil unless last_line && !last_line.strip.empty?
|
|
162
|
+
|
|
163
|
+
# Truncate if too long
|
|
164
|
+
max_length = 40
|
|
165
|
+
if last_line.length > max_length
|
|
166
|
+
last_line[0, max_length - 3] + "..."
|
|
167
|
+
else
|
|
168
|
+
last_line
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Taski
|
|
4
|
+
module Execution
|
|
5
|
+
# Manages a single IO pipe for capturing task output
|
|
6
|
+
# Each task gets its own dedicated pipe for stdout capture
|
|
7
|
+
class TaskOutputPipe
|
|
8
|
+
attr_reader :read_io, :write_io, :task_class
|
|
9
|
+
|
|
10
|
+
def initialize(task_class)
|
|
11
|
+
@task_class = task_class
|
|
12
|
+
@read_io, @write_io = IO.pipe
|
|
13
|
+
@write_io.sync = true
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def close_write
|
|
17
|
+
@write_io.close unless @write_io.closed?
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def close_read
|
|
21
|
+
@read_io.close unless @read_io.closed?
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def close
|
|
25
|
+
close_write
|
|
26
|
+
close_read
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def write_closed?
|
|
30
|
+
@write_io.closed?
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def read_closed?
|
|
34
|
+
@read_io.closed?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def closed?
|
|
38
|
+
write_closed? && read_closed?
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|