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
data/lib/taski/task.rb
CHANGED
|
@@ -2,12 +2,37 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "static_analysis/analyzer"
|
|
4
4
|
require_relative "execution/registry"
|
|
5
|
-
require_relative "execution/coordinator"
|
|
6
5
|
require_relative "execution/task_wrapper"
|
|
7
6
|
|
|
8
7
|
module Taski
|
|
8
|
+
# Base class for all tasks in the Taski framework.
|
|
9
|
+
# Tasks define units of work with dependencies and exported values.
|
|
10
|
+
#
|
|
11
|
+
# @example Defining a simple task
|
|
12
|
+
# class MyTask < Taski::Task
|
|
13
|
+
# exports :result
|
|
14
|
+
#
|
|
15
|
+
# def run
|
|
16
|
+
# @result = "completed"
|
|
17
|
+
# end
|
|
18
|
+
# end
|
|
9
19
|
class Task
|
|
10
20
|
class << self
|
|
21
|
+
##
|
|
22
|
+
# Callback invoked when a subclass is created.
|
|
23
|
+
# Automatically creates a task-specific Error class for each subclass.
|
|
24
|
+
# @param subclass [Class] The newly created subclass.
|
|
25
|
+
def inherited(subclass)
|
|
26
|
+
super
|
|
27
|
+
# Create TaskClass::Error that inherits from Taski::TaskError
|
|
28
|
+
error_class = Class.new(Taski::TaskError)
|
|
29
|
+
subclass.const_set(:Error, error_class)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
##
|
|
33
|
+
# Declares exported methods that will be accessible after task execution.
|
|
34
|
+
# Creates instance reader and class accessor methods for each export.
|
|
35
|
+
# @param export_methods [Array<Symbol>] The method names to export.
|
|
11
36
|
def exports(*export_methods)
|
|
12
37
|
@exported_methods = export_methods
|
|
13
38
|
|
|
@@ -17,157 +42,128 @@ module Taski
|
|
|
17
42
|
end
|
|
18
43
|
end
|
|
19
44
|
|
|
45
|
+
##
|
|
46
|
+
# Returns the list of exported method names.
|
|
47
|
+
# @return [Array<Symbol>] The exported method names.
|
|
20
48
|
def exported_methods
|
|
21
49
|
@exported_methods ||= []
|
|
22
50
|
end
|
|
23
51
|
|
|
24
|
-
|
|
52
|
+
##
|
|
53
|
+
# Creates a fresh TaskWrapper instance for re-execution support.
|
|
25
54
|
# Use class methods (e.g., MyTask.result) for cached single execution.
|
|
55
|
+
# @return [Execution::TaskWrapper] A new wrapper for this task.
|
|
26
56
|
def new
|
|
27
|
-
|
|
28
|
-
super,
|
|
29
|
-
registry: registry,
|
|
30
|
-
coordinator: coordinator
|
|
31
|
-
)
|
|
57
|
+
fresh_wrapper
|
|
32
58
|
end
|
|
33
59
|
|
|
60
|
+
##
|
|
61
|
+
# Returns cached static dependencies for this task class.
|
|
62
|
+
# Dependencies are analyzed from the run method body using static analysis.
|
|
63
|
+
# @return [Set<Class>] The set of task classes this task depends on.
|
|
34
64
|
def cached_dependencies
|
|
35
65
|
@dependencies_cache ||= StaticAnalysis::Analyzer.analyze(self)
|
|
36
66
|
end
|
|
37
67
|
|
|
68
|
+
##
|
|
69
|
+
# Clears the cached dependency analysis.
|
|
70
|
+
# Useful when task code has changed and dependencies need to be re-analyzed.
|
|
38
71
|
def clear_dependency_cache
|
|
39
72
|
@dependencies_cache = nil
|
|
40
73
|
end
|
|
41
74
|
|
|
42
|
-
|
|
43
|
-
|
|
75
|
+
##
|
|
76
|
+
# Executes the task and all its dependencies.
|
|
77
|
+
# Creates a fresh registry each time for independent execution.
|
|
78
|
+
# @param args [Hash] User-defined arguments accessible via Taski.args.
|
|
79
|
+
# @param workers [Integer, nil] Number of worker threads for parallel execution.
|
|
80
|
+
# Must be a positive integer or nil.
|
|
81
|
+
# Use workers: 1 for sequential execution (useful for debugging).
|
|
82
|
+
# @raise [ArgumentError] If workers is not a positive integer or nil.
|
|
83
|
+
# @return [Object] The result of task execution.
|
|
84
|
+
def run(args: {}, workers: nil)
|
|
85
|
+
validate_workers!(workers)
|
|
86
|
+
Taski.start_args(options: args.merge(_workers: workers), root_task: self)
|
|
44
87
|
validate_no_circular_dependencies!
|
|
45
|
-
|
|
88
|
+
fresh_wrapper.run
|
|
89
|
+
ensure
|
|
90
|
+
Taski.reset_args!
|
|
46
91
|
end
|
|
47
92
|
|
|
48
|
-
|
|
49
|
-
|
|
93
|
+
##
|
|
94
|
+
# Executes the clean phase for the task and all its dependencies.
|
|
95
|
+
# Clean is executed in reverse dependency order.
|
|
96
|
+
# Creates a fresh registry each time for independent execution.
|
|
97
|
+
# @param args [Hash] User-defined arguments accessible via Taski.args.
|
|
98
|
+
# @param workers [Integer, nil] Number of worker threads for parallel execution.
|
|
99
|
+
# Must be a positive integer or nil.
|
|
100
|
+
# @raise [ArgumentError] If workers is not a positive integer or nil.
|
|
101
|
+
def clean(args: {}, workers: nil)
|
|
102
|
+
validate_workers!(workers)
|
|
103
|
+
Taski.start_args(options: args.merge(_workers: workers), root_task: self)
|
|
50
104
|
validate_no_circular_dependencies!
|
|
51
|
-
|
|
105
|
+
fresh_wrapper.clean
|
|
106
|
+
ensure
|
|
107
|
+
Taski.reset_args!
|
|
52
108
|
end
|
|
53
109
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
110
|
+
##
|
|
111
|
+
# Execute run followed by clean in a single operation.
|
|
112
|
+
# If run fails, clean is still executed for resource release.
|
|
113
|
+
# Creates a fresh registry for both operations to share.
|
|
114
|
+
#
|
|
115
|
+
# @param args [Hash] User-defined arguments accessible via Taski.args.
|
|
116
|
+
# @param workers [Integer, nil] Number of worker threads for parallel execution.
|
|
117
|
+
# Must be a positive integer or nil.
|
|
118
|
+
# @raise [ArgumentError] If workers is not a positive integer or nil.
|
|
119
|
+
# @return [Object] The result of task execution
|
|
120
|
+
def run_and_clean(args: {}, workers: nil)
|
|
121
|
+
validate_workers!(workers)
|
|
122
|
+
Taski.start_args(options: args.merge(_workers: workers), root_task: self)
|
|
123
|
+
validate_no_circular_dependencies!
|
|
124
|
+
fresh_wrapper.run_and_clean
|
|
125
|
+
ensure
|
|
126
|
+
Taski.reset_args!
|
|
63
127
|
end
|
|
64
128
|
|
|
129
|
+
##
|
|
130
|
+
# Resets the task state and progress display.
|
|
131
|
+
# Useful for testing or re-running tasks from scratch.
|
|
65
132
|
def reset!
|
|
66
|
-
|
|
67
|
-
Taski.
|
|
68
|
-
Taski.reset_context!
|
|
69
|
-
@coordinator = nil
|
|
133
|
+
Taski.reset_args!
|
|
134
|
+
Taski.reset_progress_display!
|
|
70
135
|
@circular_dependency_checked = false
|
|
71
136
|
end
|
|
72
137
|
|
|
138
|
+
##
|
|
139
|
+
# Renders a static tree representation of the task dependencies.
|
|
140
|
+
# @return [String] The rendered tree string.
|
|
73
141
|
def tree
|
|
74
|
-
|
|
142
|
+
Execution::TreeProgressDisplay.render_static_tree(self)
|
|
75
143
|
end
|
|
76
144
|
|
|
77
145
|
private
|
|
78
146
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
# Detect circular reference
|
|
96
|
-
if ancestors.include?(task_class)
|
|
97
|
-
circular_marker = "#{COLORS[:impl]}(circular)#{COLORS[:reset]}"
|
|
98
|
-
return "#{impl_prefix}#{task_number} #{name} #{type_label} #{circular_marker}\n"
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
result = "#{impl_prefix}#{task_number} #{name} #{type_label}\n"
|
|
102
|
-
|
|
103
|
-
# Register task number if not already registered
|
|
104
|
-
task_index_map[task_class] = task_index_map.size + 1 unless task_index_map.key?(task_class)
|
|
105
|
-
|
|
106
|
-
# Add to ancestors for circular detection
|
|
107
|
-
new_ancestors = ancestors + [task_class]
|
|
108
|
-
|
|
109
|
-
# Use static analysis to include Section.impl candidates for visualization
|
|
110
|
-
dependencies = StaticAnalysis::Analyzer.analyze(task_class).to_a
|
|
111
|
-
is_section = section_class?(task_class)
|
|
112
|
-
|
|
113
|
-
dependencies.each_with_index do |dep, index|
|
|
114
|
-
is_last = (index == dependencies.size - 1)
|
|
115
|
-
result += format_dependency_branch(dep, prefix, is_last, task_index_map, is_section, new_ancestors)
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
result
|
|
119
|
-
end
|
|
120
|
-
|
|
121
|
-
def format_dependency_branch(dep, prefix, is_last, task_index_map, is_impl, ancestors)
|
|
122
|
-
connector, extension = tree_connector_chars(is_last)
|
|
123
|
-
dep_tree = build_tree(dep, "#{prefix}#{extension}", task_index_map, is_impl, ancestors)
|
|
124
|
-
|
|
125
|
-
result = "#{prefix}#{COLORS[:tree]}#{connector}#{COLORS[:reset]}"
|
|
126
|
-
lines = dep_tree.lines
|
|
127
|
-
result += lines.first
|
|
128
|
-
lines.drop(1).each { |line| result += line }
|
|
129
|
-
result
|
|
130
|
-
end
|
|
131
|
-
|
|
132
|
-
def tree_connector_chars(is_last)
|
|
133
|
-
if is_last
|
|
134
|
-
["└── ", " "]
|
|
135
|
-
else
|
|
136
|
-
["├── ", "│ "]
|
|
137
|
-
end
|
|
138
|
-
end
|
|
139
|
-
|
|
140
|
-
def get_task_number(task_class, task_index_map)
|
|
141
|
-
number = task_index_map[task_class] || (task_index_map.size + 1)
|
|
142
|
-
"#{COLORS[:tree]}[#{number}]#{COLORS[:reset]}"
|
|
143
|
-
end
|
|
144
|
-
|
|
145
|
-
def colored_type_label(klass)
|
|
146
|
-
if section_class?(klass)
|
|
147
|
-
"#{COLORS[:section]}(Section)#{COLORS[:reset]}"
|
|
148
|
-
else
|
|
149
|
-
"#{COLORS[:task]}(Task)#{COLORS[:reset]}"
|
|
150
|
-
end
|
|
151
|
-
end
|
|
152
|
-
|
|
153
|
-
def section_class?(klass)
|
|
154
|
-
defined?(Taski::Section) && klass < Taski::Section
|
|
155
|
-
end
|
|
156
|
-
|
|
157
|
-
# Use allocate + initialize instead of new to avoid infinite loop
|
|
158
|
-
# since new is overridden to return TaskWrapper
|
|
159
|
-
def cached_wrapper
|
|
160
|
-
registry.get_or_create(self) do
|
|
161
|
-
task_instance = allocate
|
|
162
|
-
task_instance.send(:initialize)
|
|
163
|
-
Execution::TaskWrapper.new(
|
|
164
|
-
task_instance,
|
|
165
|
-
registry: registry,
|
|
166
|
-
coordinator: coordinator
|
|
167
|
-
)
|
|
168
|
-
end
|
|
147
|
+
##
|
|
148
|
+
# Creates a fresh TaskWrapper with its own registry.
|
|
149
|
+
# Used for class method execution (Task.run) where each call is independent.
|
|
150
|
+
# @return [Execution::TaskWrapper] A new wrapper with fresh registry.
|
|
151
|
+
def fresh_wrapper
|
|
152
|
+
fresh_registry = Execution::Registry.new
|
|
153
|
+
task_instance = allocate
|
|
154
|
+
task_instance.__send__(:initialize)
|
|
155
|
+
wrapper = Execution::TaskWrapper.new(
|
|
156
|
+
task_instance,
|
|
157
|
+
registry: fresh_registry,
|
|
158
|
+
execution_context: Execution::ExecutionContext.current
|
|
159
|
+
)
|
|
160
|
+
fresh_registry.register(self, wrapper)
|
|
161
|
+
wrapper
|
|
169
162
|
end
|
|
170
163
|
|
|
164
|
+
##
|
|
165
|
+
# Defines an instance reader method for an exported value.
|
|
166
|
+
# @param method [Symbol] The method name to define.
|
|
171
167
|
def define_instance_reader(method)
|
|
172
168
|
undef_method(method) if method_defined?(method)
|
|
173
169
|
|
|
@@ -177,16 +173,48 @@ module Taski
|
|
|
177
173
|
end
|
|
178
174
|
end
|
|
179
175
|
|
|
176
|
+
##
|
|
177
|
+
# Defines a class accessor method for an exported value.
|
|
178
|
+
# When called inside an execution, returns cached value from registry.
|
|
179
|
+
# When called outside execution, creates fresh execution.
|
|
180
|
+
# @param method [Symbol] The method name to define.
|
|
180
181
|
def define_class_accessor(method)
|
|
181
182
|
singleton_class.undef_method(method) if singleton_class.method_defined?(method)
|
|
182
183
|
|
|
183
184
|
define_singleton_method(method) do
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
185
|
+
# Check if running inside an execution with a registry
|
|
186
|
+
registry = Taski.current_registry
|
|
187
|
+
if registry
|
|
188
|
+
# Inside execution - get or create wrapper in current registry
|
|
189
|
+
# This handles both pre-registered dependencies and dynamic ones (like Section impl)
|
|
190
|
+
wrapper = registry.get_or_create(self) do
|
|
191
|
+
task_instance = allocate
|
|
192
|
+
task_instance.__send__(:initialize)
|
|
193
|
+
Execution::TaskWrapper.new(
|
|
194
|
+
task_instance,
|
|
195
|
+
registry: registry,
|
|
196
|
+
execution_context: Execution::ExecutionContext.current
|
|
197
|
+
)
|
|
198
|
+
end
|
|
199
|
+
wrapper.get_exported_value(method)
|
|
200
|
+
else
|
|
201
|
+
# Outside execution - fresh execution (top-level call)
|
|
202
|
+
args_was_nil = Taski.args.nil?
|
|
203
|
+
begin
|
|
204
|
+
Taski.start_args(options: {}, root_task: self) if args_was_nil
|
|
205
|
+
validate_no_circular_dependencies!
|
|
206
|
+
fresh_wrapper.get_exported_value(method)
|
|
207
|
+
ensure
|
|
208
|
+
# Only reset if we set args
|
|
209
|
+
Taski.reset_args! if args_was_nil
|
|
210
|
+
end
|
|
211
|
+
end
|
|
187
212
|
end
|
|
188
213
|
end
|
|
189
214
|
|
|
215
|
+
##
|
|
216
|
+
# Validates that no circular dependencies exist in the task graph.
|
|
217
|
+
# @raise [Taski::CircularDependencyError] If circular dependencies are detected.
|
|
190
218
|
def validate_no_circular_dependencies!
|
|
191
219
|
return if @circular_dependency_checked
|
|
192
220
|
|
|
@@ -199,15 +227,90 @@ module Taski
|
|
|
199
227
|
|
|
200
228
|
@circular_dependency_checked = true
|
|
201
229
|
end
|
|
230
|
+
|
|
231
|
+
##
|
|
232
|
+
# Validates the workers parameter.
|
|
233
|
+
# @param workers [Object] The workers parameter to validate.
|
|
234
|
+
# @raise [ArgumentError] If workers is not a positive integer or nil.
|
|
235
|
+
def validate_workers!(workers)
|
|
236
|
+
return if workers.nil?
|
|
237
|
+
|
|
238
|
+
unless workers.is_a?(Integer) && workers >= 1
|
|
239
|
+
raise ArgumentError, "workers must be a positive integer or nil, got: #{workers.inspect}"
|
|
240
|
+
end
|
|
241
|
+
end
|
|
202
242
|
end
|
|
203
243
|
|
|
244
|
+
##
|
|
245
|
+
# Executes the task's main logic.
|
|
246
|
+
# Subclasses must override this method to implement task behavior.
|
|
247
|
+
# @raise [NotImplementedError] If not overridden in a subclass.
|
|
204
248
|
def run
|
|
205
249
|
raise NotImplementedError, "Subclasses must implement the run method"
|
|
206
250
|
end
|
|
207
251
|
|
|
252
|
+
##
|
|
253
|
+
# Cleans up resources after task execution.
|
|
254
|
+
# Override in subclasses to implement cleanup logic.
|
|
208
255
|
def clean
|
|
209
256
|
end
|
|
210
257
|
|
|
258
|
+
# Override system() to capture subprocess output through the pipe-based architecture.
|
|
259
|
+
# Uses Kernel.system with :out option to redirect output to the task's pipe.
|
|
260
|
+
# If user provides :out or :err options, they are respected (no automatic redirection).
|
|
261
|
+
# @param args [Array] Command arguments (shell mode if single string, exec mode if array)
|
|
262
|
+
# @param opts [Hash] Options passed to Kernel.system
|
|
263
|
+
# @return [Boolean, nil] true if command succeeded, false if failed, nil if command not found
|
|
264
|
+
def system(*args, **opts)
|
|
265
|
+
write_io = $stdout.respond_to?(:current_write_io) ? $stdout.current_write_io : nil
|
|
266
|
+
|
|
267
|
+
if write_io && !opts.key?(:out)
|
|
268
|
+
# Redirect subprocess output to the task's pipe (stderr merged into stdout)
|
|
269
|
+
Kernel.system(*args, out: write_io, err: [:child, :out], **opts)
|
|
270
|
+
else
|
|
271
|
+
# No capture active or user provided custom :out, use normal system
|
|
272
|
+
Kernel.system(*args, **opts)
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Groups related output within a task for organized progress display.
|
|
277
|
+
# The group name is shown in the progress tree as a child of the task.
|
|
278
|
+
# Groups cannot be nested.
|
|
279
|
+
#
|
|
280
|
+
# @param name [String] The group name to display
|
|
281
|
+
# @yield The block to execute within the group
|
|
282
|
+
# @return [Object] The result of the block
|
|
283
|
+
# @raise Re-raises any exception from the block after marking group as failed
|
|
284
|
+
#
|
|
285
|
+
# @example
|
|
286
|
+
# def run
|
|
287
|
+
# group("Preparing") do
|
|
288
|
+
# puts "Checking dependencies..."
|
|
289
|
+
# puts "Validating config..."
|
|
290
|
+
# end
|
|
291
|
+
# group("Deploying") do
|
|
292
|
+
# puts "Uploading files..."
|
|
293
|
+
# end
|
|
294
|
+
# end
|
|
295
|
+
def group(name)
|
|
296
|
+
context = Execution::ExecutionContext.current
|
|
297
|
+
context&.notify_group_started(self.class, name)
|
|
298
|
+
start_time = Time.now
|
|
299
|
+
|
|
300
|
+
begin
|
|
301
|
+
result = yield
|
|
302
|
+
duration_ms = ((Time.now - start_time) * 1000).round(0)
|
|
303
|
+
context&.notify_group_completed(self.class, name, duration: duration_ms)
|
|
304
|
+
result
|
|
305
|
+
rescue => e
|
|
306
|
+
duration_ms = ((Time.now - start_time) * 1000).round(0)
|
|
307
|
+
context&.notify_group_completed(self.class, name, duration: duration_ms, error: e)
|
|
308
|
+
raise
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
##
|
|
313
|
+
# Resets the instance's exported values to nil.
|
|
211
314
|
def reset!
|
|
212
315
|
self.class.exported_methods.each do |method|
|
|
213
316
|
instance_variable_set("@#{method}", nil)
|
data/lib/taski/version.rb
CHANGED
data/lib/taski.rb
CHANGED
|
@@ -5,12 +5,13 @@ require_relative "taski/static_analysis/analyzer"
|
|
|
5
5
|
require_relative "taski/static_analysis/visitor"
|
|
6
6
|
require_relative "taski/static_analysis/dependency_graph"
|
|
7
7
|
require_relative "taski/execution/registry"
|
|
8
|
-
require_relative "taski/execution/
|
|
8
|
+
require_relative "taski/execution/execution_context"
|
|
9
9
|
require_relative "taski/execution/task_wrapper"
|
|
10
|
-
require_relative "taski/execution/
|
|
11
|
-
require_relative "taski/
|
|
12
|
-
require_relative "taski/
|
|
13
|
-
require_relative "taski/
|
|
10
|
+
require_relative "taski/execution/scheduler"
|
|
11
|
+
require_relative "taski/execution/worker_pool"
|
|
12
|
+
require_relative "taski/execution/executor"
|
|
13
|
+
require_relative "taski/execution/tree_progress_display"
|
|
14
|
+
require_relative "taski/args"
|
|
14
15
|
|
|
15
16
|
module Taski
|
|
16
17
|
class TaskAbortException < StandardError
|
|
@@ -28,48 +29,166 @@ module Taski
|
|
|
28
29
|
end
|
|
29
30
|
end
|
|
30
31
|
|
|
31
|
-
|
|
32
|
+
# Represents a single task failure with its context
|
|
33
|
+
class TaskFailure
|
|
34
|
+
attr_reader :task_class, :error
|
|
32
35
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
36
|
+
# @param task_class [Class] The task class that failed
|
|
37
|
+
# @param error [Exception] The exception that was raised
|
|
38
|
+
def initialize(task_class:, error:)
|
|
39
|
+
@task_class = task_class
|
|
40
|
+
@error = error
|
|
41
|
+
end
|
|
37
42
|
end
|
|
38
43
|
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
44
|
+
# Mixin for exception classes to enable transparent rescue matching with AggregateError.
|
|
45
|
+
# When extended by an exception class, `rescue ThatError` will also match
|
|
46
|
+
# an AggregateError that contains ThatError.
|
|
47
|
+
#
|
|
48
|
+
# @note TaskError and all TaskClass::Error classes already extend this module.
|
|
49
|
+
#
|
|
50
|
+
# @example
|
|
51
|
+
# begin
|
|
52
|
+
# MyTask.value # raises AggregateError containing MyTask::Error
|
|
53
|
+
# rescue MyTask::Error => e
|
|
54
|
+
# puts "MyTask failed: #{e.message}"
|
|
55
|
+
# end
|
|
56
|
+
module AggregateAware
|
|
57
|
+
def ===(other)
|
|
58
|
+
return super unless other.is_a?(Taski::AggregateError)
|
|
59
|
+
|
|
60
|
+
other.includes?(self)
|
|
45
61
|
end
|
|
46
62
|
end
|
|
47
63
|
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
|
|
51
|
-
|
|
64
|
+
# Base class for task-specific error wrappers.
|
|
65
|
+
# Each Task subclass automatically gets a ::Error class that inherits from this.
|
|
66
|
+
# This allows rescuing errors by task class: rescue MyTask::Error => e
|
|
67
|
+
#
|
|
68
|
+
# @example Rescuing task-specific errors
|
|
69
|
+
# begin
|
|
70
|
+
# MyTask.value
|
|
71
|
+
# rescue MyTask::Error => e
|
|
72
|
+
# puts "MyTask failed: #{e.message}"
|
|
73
|
+
# end
|
|
74
|
+
class TaskError < StandardError
|
|
75
|
+
extend AggregateAware
|
|
76
|
+
|
|
77
|
+
# @return [Exception] The original error that occurred in the task
|
|
78
|
+
attr_reader :cause
|
|
79
|
+
|
|
80
|
+
# @return [Class] The task class where the error occurred
|
|
81
|
+
attr_reader :task_class
|
|
82
|
+
|
|
83
|
+
# @param cause [Exception] The original error
|
|
84
|
+
# @param task_class [Class] The task class where the error occurred
|
|
85
|
+
def initialize(cause, task_class:)
|
|
86
|
+
@cause = cause
|
|
87
|
+
@task_class = task_class
|
|
88
|
+
super(cause.message)
|
|
89
|
+
set_backtrace(cause.backtrace)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Raised when multiple tasks fail during parallel execution
|
|
94
|
+
class AggregateError < StandardError
|
|
95
|
+
attr_reader :errors
|
|
96
|
+
|
|
97
|
+
# @param errors [Array<TaskFailure>] List of task failures
|
|
98
|
+
def initialize(errors)
|
|
99
|
+
@errors = errors
|
|
100
|
+
super(build_message)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Returns the first error for compatibility with exception chaining
|
|
104
|
+
# @return [Exception, nil] The first error or nil if no errors
|
|
105
|
+
def cause
|
|
106
|
+
errors.first&.error
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Check if this aggregate contains an error of the given type
|
|
110
|
+
# @param exception_class [Class] The exception class to check for
|
|
111
|
+
# @return [Boolean] true if any contained error is of the given type
|
|
112
|
+
def includes?(exception_class)
|
|
113
|
+
errors.any? { |f| f.error.is_a?(exception_class) }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
def build_message
|
|
119
|
+
task_word = (errors.size == 1) ? "task" : "tasks"
|
|
120
|
+
"#{errors.size} #{task_word} failed:\n" +
|
|
121
|
+
errors.map { |f| " - #{f.task_class.name}: #{f.error.message}" }.join("\n")
|
|
122
|
+
end
|
|
52
123
|
end
|
|
53
124
|
|
|
54
|
-
|
|
55
|
-
|
|
125
|
+
@args_monitor = Monitor.new
|
|
126
|
+
|
|
127
|
+
# Get the current runtime arguments
|
|
128
|
+
# @return [Args, nil] The current args or nil if no task is running
|
|
129
|
+
def self.args
|
|
130
|
+
@args_monitor.synchronize { @args }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Start new runtime arguments (internal use only)
|
|
134
|
+
# @api private
|
|
135
|
+
def self.start_args(options:, root_task:)
|
|
136
|
+
@args_monitor.synchronize do
|
|
137
|
+
return if @args
|
|
138
|
+
@args = Args.new(options: options, root_task: root_task)
|
|
139
|
+
end
|
|
56
140
|
end
|
|
57
141
|
|
|
58
|
-
|
|
59
|
-
|
|
142
|
+
# Reset the runtime arguments (internal use only)
|
|
143
|
+
# @api private
|
|
144
|
+
def self.reset_args!
|
|
145
|
+
@args_monitor.synchronize { @args = nil }
|
|
60
146
|
end
|
|
61
147
|
|
|
148
|
+
# Progress display is enabled by default (tree-style).
|
|
149
|
+
# Environment variables:
|
|
150
|
+
# - TASKI_PROGRESS_DISABLE=1: Disable progress display entirely
|
|
62
151
|
def self.progress_display
|
|
63
|
-
return nil
|
|
64
|
-
@progress_display ||= Execution::
|
|
152
|
+
return nil if progress_disabled?
|
|
153
|
+
@progress_display ||= Execution::TreeProgressDisplay.new
|
|
65
154
|
end
|
|
66
155
|
|
|
67
|
-
def self.
|
|
68
|
-
ENV["
|
|
156
|
+
def self.progress_disabled?
|
|
157
|
+
ENV["TASKI_PROGRESS_DISABLE"] == "1"
|
|
69
158
|
end
|
|
70
159
|
|
|
71
160
|
def self.reset_progress_display!
|
|
72
161
|
@progress_display&.stop
|
|
73
162
|
@progress_display = nil
|
|
74
163
|
end
|
|
164
|
+
|
|
165
|
+
# Get the worker count from the current args (set via Task.run(workers: n))
|
|
166
|
+
# @return [Integer, nil] The worker count or nil to use WorkerPool default
|
|
167
|
+
# @api private
|
|
168
|
+
def self.args_worker_count
|
|
169
|
+
args&.fetch(:_workers, nil)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Get the current registry for this thread (used during dependency resolution)
|
|
173
|
+
# @return [Execution::Registry, nil] The current registry or nil
|
|
174
|
+
# @api private
|
|
175
|
+
def self.current_registry
|
|
176
|
+
Thread.current[:taski_current_registry]
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Set the current registry for this thread (internal use only)
|
|
180
|
+
# @api private
|
|
181
|
+
def self.set_current_registry(registry)
|
|
182
|
+
Thread.current[:taski_current_registry] = registry
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Clear the current registry for this thread (internal use only)
|
|
186
|
+
# @api private
|
|
187
|
+
def self.clear_current_registry
|
|
188
|
+
Thread.current[:taski_current_registry] = nil
|
|
189
|
+
end
|
|
75
190
|
end
|
|
191
|
+
|
|
192
|
+
# Load Task and Section after Taski module is defined (they depend on TaskError)
|
|
193
|
+
require_relative "taski/task"
|
|
194
|
+
require_relative "taski/section"
|