taski 0.5.0 → 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.
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/taski"
5
+
6
+ # Demo: Subprocess output capture with system()
7
+ #
8
+ # This example demonstrates how Taski captures output from system() calls
9
+ # and displays them in the progress spinner.
10
+ # Run with: ruby examples/system_call_demo.rb
11
+
12
+ class SlowOutputTask < Taski::Task
13
+ exports :success
14
+
15
+ def run
16
+ puts "Running command with streaming output..."
17
+ # Use a command that produces output over time
18
+ @success = system("for i in 1 2 3 4 5; do echo \"Processing step $i...\"; sleep 0.3; done")
19
+ end
20
+ end
21
+
22
+ class AnotherSlowTask < Taski::Task
23
+ exports :result
24
+
25
+ def run
26
+ puts "Running another slow command..."
27
+ @result = system("for i in A B C; do echo \"Stage $i complete\"; sleep 0.4; done")
28
+ end
29
+ end
30
+
31
+ class MainTask < Taski::Task
32
+ exports :summary
33
+
34
+ def run
35
+ puts "Starting main task..."
36
+ slow1 = SlowOutputTask.success
37
+ slow2 = AnotherSlowTask.result
38
+ @summary = {slow1: slow1, slow2: slow2}
39
+ puts "All done!"
40
+ end
41
+ end
42
+
43
+ puts "=" * 60
44
+ puts "Subprocess Output Capture Demo"
45
+ puts "Watch the spinner show system() output in real-time!"
46
+ puts "=" * 60
47
+ puts
48
+
49
+ Taski.progress_display&.start
50
+ result = MainTask.summary
51
+ Taski.progress_display&.stop
52
+
53
+ puts
54
+ puts "=" * 60
55
+ puts "Result: #{result.inspect}"
56
+ puts "=" * 60
@@ -3,10 +3,10 @@
3
3
  require "monitor"
4
4
 
5
5
  module Taski
6
- # Runtime context accessible from any task.
6
+ # Runtime arguments accessible from any task.
7
7
  # Holds user-defined options and execution metadata.
8
- # Context is immutable after creation - options cannot be modified during task execution.
9
- class Context
8
+ # Args is immutable after creation - options cannot be modified during task execution.
9
+ class Args
10
10
  attr_reader :started_at, :working_directory, :root_task
11
11
 
12
12
  # @param options [Hash] User-defined options (immutable after creation)
@@ -0,0 +1,379 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+ require_relative "task_output_router"
5
+
6
+ module Taski
7
+ module Execution
8
+ # ExecutionContext manages execution state and notifies observers about execution events.
9
+ # It decouples progress display from Executor using the observer pattern.
10
+ #
11
+ # == Architecture
12
+ #
13
+ # ExecutionContext is the central hub for execution events in the Taski framework:
14
+ #
15
+ # Executor → Scheduler/WorkerPool/ExecutionContext → Observers
16
+ #
17
+ # - Executor coordinates the overall execution flow
18
+ # - Scheduler manages dependency state and determines execution order
19
+ # - WorkerPool manages worker threads that execute tasks
20
+ # - ExecutionContext notifies observers about execution events
21
+ #
22
+ # == Observer Pattern
23
+ #
24
+ # Observers are registered using {#add_observer} and receive notifications
25
+ # via duck-typed method dispatch. Observers should implement any subset of:
26
+ #
27
+ # - register_task(task_class) - Called when a task is registered
28
+ # - update_task(task_class, state:, duration:, error:) - Called on state changes
29
+ # State values for run: :pending, :running, :completed, :failed
30
+ # State values for clean: :cleaning, :clean_completed, :clean_failed
31
+ # - register_section_impl(section_class, impl_class) - Called on section impl selection
32
+ # - set_root_task(task_class) - Called when root task is set
33
+ # - set_output_capture(output_capture) - Called when output capture is configured
34
+ # - start - Called when execution starts
35
+ # - stop - Called when execution ends
36
+ #
37
+ # == Thread Safety
38
+ #
39
+ # All observer operations are synchronized using Monitor. The output capture
40
+ # getter is also thread-safe for access from worker threads.
41
+ #
42
+ # == Backward Compatibility
43
+ #
44
+ # TreeProgressDisplay works as an observer without any API changes.
45
+ # Existing task code works unchanged.
46
+ #
47
+ # @example Registering an observer
48
+ # context = ExecutionContext.new
49
+ # context.add_observer(TreeProgressDisplay.new)
50
+ #
51
+ # @example Sending notifications
52
+ # context.notify_task_registered(MyTask)
53
+ # context.notify_task_started(MyTask)
54
+ # context.notify_task_completed(MyTask, duration: 1.5)
55
+ class ExecutionContext
56
+ # Thread-local key for storing the current execution context
57
+ THREAD_LOCAL_KEY = :taski_execution_context
58
+
59
+ # Get the current execution context for this thread.
60
+ # @return [ExecutionContext, nil] The current context or nil if not set
61
+ def self.current
62
+ Thread.current[THREAD_LOCAL_KEY]
63
+ end
64
+
65
+ # Set the current execution context for this thread.
66
+ # @param context [ExecutionContext, nil] The context to set
67
+ def self.current=(context)
68
+ Thread.current[THREAD_LOCAL_KEY] = context
69
+ end
70
+
71
+ ##
72
+ # Creates a new ExecutionContext and initializes its internal synchronization and state.
73
+ #
74
+ # Initializes a monitor for thread-safe operations and sets up empty observer storage
75
+ # and nil defaults for execution/clean triggers and output capture related fields.
76
+ def initialize
77
+ @monitor = Monitor.new
78
+ @observers = []
79
+ @execution_trigger = nil
80
+ @clean_trigger = nil
81
+ @output_capture = nil
82
+ @original_stdout = nil
83
+ @runtime_dependencies = {}
84
+ end
85
+
86
+ # Check if output capture is already active.
87
+ # @return [Boolean] true if capture is active
88
+ def output_capture_active?
89
+ @monitor.synchronize { !@output_capture.nil? }
90
+ end
91
+
92
+ # Set up output capture for inline progress display.
93
+ # Creates TaskOutputRouter and replaces $stdout.
94
+ # Should only be called when progress display is active and not already set up.
95
+ #
96
+ # @param output_io [IO] The original output IO (usually $stdout)
97
+ def setup_output_capture(output_io)
98
+ @monitor.synchronize do
99
+ @original_stdout = output_io
100
+ @output_capture = TaskOutputRouter.new(@original_stdout)
101
+ $stdout = @output_capture
102
+ end
103
+
104
+ notify_set_output_capture(@output_capture)
105
+ end
106
+
107
+ # Tear down output capture and restore original $stdout.
108
+ def teardown_output_capture
109
+ @monitor.synchronize do
110
+ return unless @original_stdout
111
+
112
+ $stdout = @original_stdout
113
+ @output_capture = nil
114
+ @original_stdout = nil
115
+ end
116
+ end
117
+
118
+ # Get the current output capture instance.
119
+ # Thread-safe accessor for worker threads.
120
+ #
121
+ # @return [TaskOutputRouter, nil] The output capture or nil if not set
122
+ def output_capture
123
+ @monitor.synchronize { @output_capture }
124
+ end
125
+
126
+ # Set the execution trigger callback.
127
+ # This is used to break the circular dependency between TaskWrapper and Executor.
128
+ # The trigger is a callable that takes (task_class, registry) and executes the task.
129
+ #
130
+ ##
131
+ # Sets the execution trigger used to run tasks.
132
+ # This stores a Proc that will be invoked with (task_class, registry) when a task is executed; setting to `nil` clears the custom trigger and restores the default execution behavior.
133
+ # @param [Proc, nil] trigger - A callback receiving `(task_class, registry)`, or `nil` to unset the custom trigger.
134
+ def execution_trigger=(trigger)
135
+ @monitor.synchronize { @execution_trigger = trigger }
136
+ end
137
+
138
+ # Set the clean trigger callback.
139
+ # This is used to break the circular dependency between TaskWrapper and Executor.
140
+ # The trigger is a callable that takes (task_class, registry) and cleans the task.
141
+ #
142
+ ##
143
+ # Sets the clean trigger callback used to run task cleaning operations.
144
+ # @param [Proc, nil] trigger - The callback invoked by `trigger_clean`; `nil` clears the custom clean trigger.
145
+ def clean_trigger=(trigger)
146
+ @monitor.synchronize { @clean_trigger = trigger }
147
+ end
148
+
149
+ # Trigger execution of a task.
150
+ # Falls back to Executor.execute if no custom trigger is set.
151
+ #
152
+ # @param task_class [Class] The task class to execute
153
+ ##
154
+ # Executes the given task class using the configured execution trigger or falls back to Executor.execute.
155
+ # @param task_class [Class] The task class to execute.
156
+ # @param registry [Registry] The task registry used during execution.
157
+ def trigger_execution(task_class, registry:)
158
+ trigger = @monitor.synchronize { @execution_trigger }
159
+ if trigger
160
+ trigger.call(task_class, registry)
161
+ else
162
+ # Fallback for backward compatibility
163
+ Executor.execute(task_class, registry: registry, execution_context: self)
164
+ end
165
+ end
166
+
167
+ # Trigger clean of a task.
168
+ # Falls back to Executor.execute_clean if no custom trigger is set.
169
+ #
170
+ # @param task_class [Class] The task class to clean
171
+ ##
172
+ # Triggers a clean operation for the specified task class using the configured clean trigger or a backward-compatible fallback.
173
+ # @param [Class] task_class - The task class to clean.
174
+ # @param [Registry] registry - The task registry providing task lookup and metadata.
175
+ # @return [Object] The result returned by the clean operation (implementation-dependent).
176
+ def trigger_clean(task_class, registry:)
177
+ trigger = @monitor.synchronize { @clean_trigger }
178
+ if trigger
179
+ trigger.call(task_class, registry)
180
+ else
181
+ # Fallback for backward compatibility
182
+ Executor.execute_clean(task_class, registry: registry, execution_context: self)
183
+ end
184
+ end
185
+
186
+ # ========================================
187
+ # Runtime Dependency Tracking
188
+ # ========================================
189
+
190
+ # Register a runtime dependency between task classes.
191
+ # Used by Section to track dynamically selected implementations.
192
+ # Thread-safe for access from worker threads.
193
+ #
194
+ # @param from_class [Class] The task class that depends on to_class
195
+ # @param to_class [Class] The dependency task class
196
+ def register_runtime_dependency(from_class, to_class)
197
+ @monitor.synchronize do
198
+ @runtime_dependencies[from_class] ||= Set.new
199
+ @runtime_dependencies[from_class].add(to_class)
200
+ end
201
+ end
202
+
203
+ # Get a copy of the runtime dependencies.
204
+ # Returns a hash mapping from_class to Set of to_classes.
205
+ # Thread-safe accessor.
206
+ #
207
+ # @return [Hash{Class => Set<Class>}] Copy of runtime dependencies
208
+ def runtime_dependencies
209
+ @monitor.synchronize do
210
+ @runtime_dependencies.transform_values(&:dup)
211
+ end
212
+ end
213
+
214
+ # Add an observer to receive execution notifications.
215
+ # Observers should implement the following methods (all optional):
216
+ # - register_task(task_class)
217
+ # - update_task(task_class, state:, duration:, error:)
218
+ # - register_section_impl(section_class, impl_class)
219
+ # - set_root_task(task_class)
220
+ # - set_output_capture(output_capture)
221
+ # - start
222
+ # - stop
223
+ #
224
+ # @param observer [Object] The observer to add
225
+ def add_observer(observer)
226
+ @monitor.synchronize { @observers << observer }
227
+ end
228
+
229
+ # Remove an observer.
230
+ #
231
+ # @param observer [Object] The observer to remove
232
+ def remove_observer(observer)
233
+ @monitor.synchronize { @observers.delete(observer) }
234
+ end
235
+
236
+ # Returns a copy of the current observers list.
237
+ #
238
+ # @return [Array<Object>] Copy of observers array
239
+ def observers
240
+ @monitor.synchronize { @observers.dup }
241
+ end
242
+
243
+ # Notify observers that a task has been registered.
244
+ #
245
+ # @param task_class [Class] The task class that was registered
246
+ def notify_task_registered(task_class)
247
+ dispatch(:register_task, task_class)
248
+ end
249
+
250
+ # Notify observers that a task has started execution.
251
+ #
252
+ # @param task_class [Class] The task class that started
253
+ def notify_task_started(task_class)
254
+ dispatch(:update_task, task_class, state: :running)
255
+ end
256
+
257
+ # Notify observers that a task has completed.
258
+ #
259
+ # @param task_class [Class] The task class that completed
260
+ # @param duration [Float, nil] The execution duration in seconds
261
+ # @param error [Exception, nil] The error if the task failed
262
+ def notify_task_completed(task_class, duration: nil, error: nil)
263
+ state = error ? :failed : :completed
264
+ dispatch(:update_task, task_class, state: state, duration: duration, error: error)
265
+ end
266
+
267
+ # Notify observers that a section implementation has been selected.
268
+ #
269
+ # @param section_class [Class] The section class
270
+ # @param impl_class [Class] The selected implementation class
271
+ def notify_section_impl_selected(section_class, impl_class)
272
+ dispatch(:register_section_impl, section_class, impl_class)
273
+ end
274
+
275
+ # Notify observers to set the root task.
276
+ #
277
+ # @param task_class [Class] The root task class
278
+ def notify_set_root_task(task_class)
279
+ dispatch(:set_root_task, task_class)
280
+ end
281
+
282
+ # Notify observers to set the output capture.
283
+ #
284
+ # @param output_capture [TaskOutputRouter] The output capture instance
285
+ def notify_set_output_capture(output_capture)
286
+ dispatch(:set_output_capture, output_capture)
287
+ end
288
+
289
+ # Notify observers to start.
290
+ def notify_start
291
+ dispatch(:start)
292
+ end
293
+
294
+ ##
295
+ # Notify registered observers that execution has stopped.
296
+ def notify_stop
297
+ dispatch(:stop)
298
+ end
299
+
300
+ # ========================================
301
+ # Clean Lifecycle Notifications
302
+ # ========================================
303
+
304
+ # Notify observers that a task's clean has started.
305
+ #
306
+ ##
307
+ # Notifies observers that cleaning of the given task has started.
308
+ # Dispatches an `:update_task` notification with `state: :cleaning`.
309
+ # @param [Class] task_class The task class that started cleaning.
310
+ def notify_clean_started(task_class)
311
+ dispatch(:update_task, task_class, state: :cleaning)
312
+ end
313
+
314
+ # Notify observers that a task's clean has completed.
315
+ #
316
+ # @param task_class [Class] The task class that completed cleaning
317
+ # @param duration [Float, nil] The clean duration in milliseconds
318
+ ##
319
+ # Notifies observers that a task's clean has completed, including duration and any error.
320
+ # Observers receive an `:update_task` notification with `state` set to `:clean_completed` when
321
+ # `error` is nil, or `:clean_failed` when `error` is provided.
322
+ # @param [Class] task_class - The task class that finished cleaning.
323
+ # @param [Numeric, nil] duration - The duration of the clean operation in milliseconds, or `nil` if unknown.
324
+ # @param [Exception, nil] error - The error raised during cleaning, or `nil` if the clean succeeded.
325
+ def notify_clean_completed(task_class, duration: nil, error: nil)
326
+ state = error ? :clean_failed : :clean_completed
327
+ dispatch(:update_task, task_class, state: state, duration: duration, error: error)
328
+ end
329
+
330
+ # ========================================
331
+ # Group Lifecycle Notifications
332
+ # ========================================
333
+
334
+ # Notify observers that a group has started within a task.
335
+ #
336
+ # @param task_class [Class] The task class containing the group
337
+ # @param group_name [String] The name of the group
338
+ def notify_group_started(task_class, group_name)
339
+ dispatch(:update_group, task_class, group_name, state: :running)
340
+ end
341
+
342
+ # Notify observers that a group has completed within a task.
343
+ #
344
+ # @param task_class [Class] The task class containing the group
345
+ # @param group_name [String] The name of the group
346
+ # @param duration [Float, nil] The group duration in milliseconds
347
+ # @param error [Exception, nil] The error if the group failed
348
+ def notify_group_completed(task_class, group_name, duration: nil, error: nil)
349
+ state = error ? :failed : :completed
350
+ dispatch(:update_group, task_class, group_name, state: state, duration: duration, error: error)
351
+ end
352
+
353
+ private
354
+
355
+ # Dispatch a method call to all observers that respond to the method.
356
+ # Uses duck typing: observers only receive calls for methods they implement.
357
+ #
358
+ # @param method_name [Symbol] The method to call on observers
359
+ # @param args [Array] Arguments to pass to the method
360
+ # @param kwargs [Hash] Keyword arguments to pass to the method
361
+ def dispatch(method_name, *args, **kwargs)
362
+ current_observers = @monitor.synchronize { @observers.dup }
363
+ current_observers.each do |observer|
364
+ next unless observer.respond_to?(method_name)
365
+
366
+ begin
367
+ if kwargs.empty?
368
+ observer.public_send(method_name, *args)
369
+ else
370
+ observer.public_send(method_name, *args, **kwargs)
371
+ end
372
+ rescue => e
373
+ warn "[ExecutionContext] Observer #{observer.class} raised error in #{method_name}: #{e.message}"
374
+ end
375
+ end
376
+ end
377
+ end
378
+ end
379
+ end