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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +50 -0
  3. data/README.md +168 -21
  4. data/docs/GUIDE.md +394 -0
  5. data/examples/README.md +65 -17
  6. data/examples/{context_demo.rb → args_demo.rb} +27 -27
  7. data/examples/clean_demo.rb +204 -0
  8. data/examples/data_pipeline_demo.rb +1 -1
  9. data/examples/group_demo.rb +113 -0
  10. data/examples/large_tree_demo.rb +519 -0
  11. data/examples/reexecution_demo.rb +93 -80
  12. data/examples/simple_progress_demo.rb +80 -0
  13. data/examples/system_call_demo.rb +56 -0
  14. data/lib/taski/{context.rb → args.rb} +3 -3
  15. data/lib/taski/execution/base_progress_display.rb +348 -0
  16. data/lib/taski/execution/execution_context.rb +383 -0
  17. data/lib/taski/execution/executor.rb +405 -134
  18. data/lib/taski/execution/plain_progress_display.rb +76 -0
  19. data/lib/taski/execution/registry.rb +17 -1
  20. data/lib/taski/execution/scheduler.rb +308 -0
  21. data/lib/taski/execution/simple_progress_display.rb +173 -0
  22. data/lib/taski/execution/task_output_pipe.rb +42 -0
  23. data/lib/taski/execution/task_output_router.rb +287 -0
  24. data/lib/taski/execution/task_wrapper.rb +215 -52
  25. data/lib/taski/execution/tree_progress_display.rb +349 -212
  26. data/lib/taski/execution/worker_pool.rb +104 -0
  27. data/lib/taski/section.rb +16 -3
  28. data/lib/taski/static_analysis/visitor.rb +3 -0
  29. data/lib/taski/task.rb +218 -37
  30. data/lib/taski/test_helper/errors.rb +13 -0
  31. data/lib/taski/test_helper/minitest.rb +38 -0
  32. data/lib/taski/test_helper/mock_registry.rb +51 -0
  33. data/lib/taski/test_helper/mock_wrapper.rb +46 -0
  34. data/lib/taski/test_helper/rspec.rb +38 -0
  35. data/lib/taski/test_helper.rb +214 -0
  36. data/lib/taski/version.rb +1 -1
  37. data/lib/taski.rb +211 -23
  38. data/sig/taski.rbs +207 -27
  39. metadata +25 -8
  40. data/docs/advanced-features.md +0 -625
  41. data/docs/api-guide.md +0 -509
  42. data/docs/error-handling.md +0 -684
  43. data/examples/section_progress_demo.rb +0 -78
@@ -0,0 +1,383 @@
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
+ @output_capture.start_polling
102
+ $stdout = @output_capture
103
+ end
104
+
105
+ notify_set_output_capture(@output_capture)
106
+ end
107
+
108
+ # Tear down output capture and restore original $stdout.
109
+ def teardown_output_capture
110
+ capture = nil
111
+ @monitor.synchronize do
112
+ return unless @original_stdout
113
+
114
+ capture = @output_capture
115
+ $stdout = @original_stdout
116
+ @output_capture = nil
117
+ @original_stdout = nil
118
+ end
119
+ capture&.stop_polling
120
+ end
121
+
122
+ # Get the current output capture instance.
123
+ # Thread-safe accessor for worker threads.
124
+ #
125
+ # @return [TaskOutputRouter, nil] The output capture or nil if not set
126
+ def output_capture
127
+ @monitor.synchronize { @output_capture }
128
+ end
129
+
130
+ # Set the execution trigger callback.
131
+ # This is used to break the circular dependency between TaskWrapper and Executor.
132
+ # The trigger is a callable that takes (task_class, registry) and executes the task.
133
+ #
134
+ ##
135
+ # Sets the execution trigger used to run tasks.
136
+ # 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.
137
+ # @param [Proc, nil] trigger - A callback receiving `(task_class, registry)`, or `nil` to unset the custom trigger.
138
+ def execution_trigger=(trigger)
139
+ @monitor.synchronize { @execution_trigger = trigger }
140
+ end
141
+
142
+ # Set the clean trigger callback.
143
+ # This is used to break the circular dependency between TaskWrapper and Executor.
144
+ # The trigger is a callable that takes (task_class, registry) and cleans the task.
145
+ #
146
+ ##
147
+ # Sets the clean trigger callback used to run task cleaning operations.
148
+ # @param [Proc, nil] trigger - The callback invoked by `trigger_clean`; `nil` clears the custom clean trigger.
149
+ def clean_trigger=(trigger)
150
+ @monitor.synchronize { @clean_trigger = trigger }
151
+ end
152
+
153
+ # Trigger execution of a task.
154
+ # Falls back to Executor.execute if no custom trigger is set.
155
+ #
156
+ # @param task_class [Class] The task class to execute
157
+ ##
158
+ # Executes the given task class using the configured execution trigger or falls back to Executor.execute.
159
+ # @param task_class [Class] The task class to execute.
160
+ # @param registry [Registry] The task registry used during execution.
161
+ def trigger_execution(task_class, registry:)
162
+ trigger = @monitor.synchronize { @execution_trigger }
163
+ if trigger
164
+ trigger.call(task_class, registry)
165
+ else
166
+ # Fallback for backward compatibility
167
+ Executor.execute(task_class, registry: registry, execution_context: self)
168
+ end
169
+ end
170
+
171
+ # Trigger clean of a task.
172
+ # Falls back to Executor.execute_clean if no custom trigger is set.
173
+ #
174
+ # @param task_class [Class] The task class to clean
175
+ ##
176
+ # Triggers a clean operation for the specified task class using the configured clean trigger or a backward-compatible fallback.
177
+ # @param [Class] task_class - The task class to clean.
178
+ # @param [Registry] registry - The task registry providing task lookup and metadata.
179
+ # @return [Object] The result returned by the clean operation (implementation-dependent).
180
+ def trigger_clean(task_class, registry:)
181
+ trigger = @monitor.synchronize { @clean_trigger }
182
+ if trigger
183
+ trigger.call(task_class, registry)
184
+ else
185
+ # Fallback for backward compatibility
186
+ Executor.execute_clean(task_class, registry: registry, execution_context: self)
187
+ end
188
+ end
189
+
190
+ # ========================================
191
+ # Runtime Dependency Tracking
192
+ # ========================================
193
+
194
+ # Register a runtime dependency between task classes.
195
+ # Used by Section to track dynamically selected implementations.
196
+ # Thread-safe for access from worker threads.
197
+ #
198
+ # @param from_class [Class] The task class that depends on to_class
199
+ # @param to_class [Class] The dependency task class
200
+ def register_runtime_dependency(from_class, to_class)
201
+ @monitor.synchronize do
202
+ @runtime_dependencies[from_class] ||= Set.new
203
+ @runtime_dependencies[from_class].add(to_class)
204
+ end
205
+ end
206
+
207
+ # Get a copy of the runtime dependencies.
208
+ # Returns a hash mapping from_class to Set of to_classes.
209
+ # Thread-safe accessor.
210
+ #
211
+ # @return [Hash{Class => Set<Class>}] Copy of runtime dependencies
212
+ def runtime_dependencies
213
+ @monitor.synchronize do
214
+ @runtime_dependencies.transform_values(&:dup)
215
+ end
216
+ end
217
+
218
+ # Add an observer to receive execution notifications.
219
+ # Observers should implement the following methods (all optional):
220
+ # - register_task(task_class)
221
+ # - update_task(task_class, state:, duration:, error:)
222
+ # - register_section_impl(section_class, impl_class)
223
+ # - set_root_task(task_class)
224
+ # - set_output_capture(output_capture)
225
+ # - start
226
+ # - stop
227
+ #
228
+ # @param observer [Object] The observer to add
229
+ def add_observer(observer)
230
+ @monitor.synchronize { @observers << observer }
231
+ end
232
+
233
+ # Remove an observer.
234
+ #
235
+ # @param observer [Object] The observer to remove
236
+ def remove_observer(observer)
237
+ @monitor.synchronize { @observers.delete(observer) }
238
+ end
239
+
240
+ # Returns a copy of the current observers list.
241
+ #
242
+ # @return [Array<Object>] Copy of observers array
243
+ def observers
244
+ @monitor.synchronize { @observers.dup }
245
+ end
246
+
247
+ # Notify observers that a task has been registered.
248
+ #
249
+ # @param task_class [Class] The task class that was registered
250
+ def notify_task_registered(task_class)
251
+ dispatch(:register_task, task_class)
252
+ end
253
+
254
+ # Notify observers that a task has started execution.
255
+ #
256
+ # @param task_class [Class] The task class that started
257
+ def notify_task_started(task_class)
258
+ dispatch(:update_task, task_class, state: :running)
259
+ end
260
+
261
+ # Notify observers that a task has completed.
262
+ #
263
+ # @param task_class [Class] The task class that completed
264
+ # @param duration [Float, nil] The execution duration in seconds
265
+ # @param error [Exception, nil] The error if the task failed
266
+ def notify_task_completed(task_class, duration: nil, error: nil)
267
+ state = error ? :failed : :completed
268
+ dispatch(:update_task, task_class, state: state, duration: duration, error: error)
269
+ end
270
+
271
+ # Notify observers that a section implementation has been selected.
272
+ #
273
+ # @param section_class [Class] The section class
274
+ # @param impl_class [Class] The selected implementation class
275
+ def notify_section_impl_selected(section_class, impl_class)
276
+ dispatch(:register_section_impl, section_class, impl_class)
277
+ end
278
+
279
+ # Notify observers to set the root task.
280
+ #
281
+ # @param task_class [Class] The root task class
282
+ def notify_set_root_task(task_class)
283
+ dispatch(:set_root_task, task_class)
284
+ end
285
+
286
+ # Notify observers to set the output capture.
287
+ #
288
+ # @param output_capture [TaskOutputRouter] The output capture instance
289
+ def notify_set_output_capture(output_capture)
290
+ dispatch(:set_output_capture, output_capture)
291
+ end
292
+
293
+ # Notify observers to start.
294
+ def notify_start
295
+ dispatch(:start)
296
+ end
297
+
298
+ ##
299
+ # Notify registered observers that execution has stopped.
300
+ def notify_stop
301
+ dispatch(:stop)
302
+ end
303
+
304
+ # ========================================
305
+ # Clean Lifecycle Notifications
306
+ # ========================================
307
+
308
+ # Notify observers that a task's clean has started.
309
+ #
310
+ ##
311
+ # Notifies observers that cleaning of the given task has started.
312
+ # Dispatches an `:update_task` notification with `state: :cleaning`.
313
+ # @param [Class] task_class The task class that started cleaning.
314
+ def notify_clean_started(task_class)
315
+ dispatch(:update_task, task_class, state: :cleaning)
316
+ end
317
+
318
+ # Notify observers that a task's clean has completed.
319
+ #
320
+ # @param task_class [Class] The task class that completed cleaning
321
+ # @param duration [Float, nil] The clean duration in milliseconds
322
+ ##
323
+ # Notifies observers that a task's clean has completed, including duration and any error.
324
+ # Observers receive an `:update_task` notification with `state` set to `:clean_completed` when
325
+ # `error` is nil, or `:clean_failed` when `error` is provided.
326
+ # @param [Class] task_class - The task class that finished cleaning.
327
+ # @param [Numeric, nil] duration - The duration of the clean operation in milliseconds, or `nil` if unknown.
328
+ # @param [Exception, nil] error - The error raised during cleaning, or `nil` if the clean succeeded.
329
+ def notify_clean_completed(task_class, duration: nil, error: nil)
330
+ state = error ? :clean_failed : :clean_completed
331
+ dispatch(:update_task, task_class, state: state, duration: duration, error: error)
332
+ end
333
+
334
+ # ========================================
335
+ # Group Lifecycle Notifications
336
+ # ========================================
337
+
338
+ # Notify observers that a group has started within a task.
339
+ #
340
+ # @param task_class [Class] The task class containing the group
341
+ # @param group_name [String] The name of the group
342
+ def notify_group_started(task_class, group_name)
343
+ dispatch(:update_group, task_class, group_name, state: :running)
344
+ end
345
+
346
+ # Notify observers that a group has completed within a task.
347
+ #
348
+ # @param task_class [Class] The task class containing the group
349
+ # @param group_name [String] The name of the group
350
+ # @param duration [Float, nil] The group duration in milliseconds
351
+ # @param error [Exception, nil] The error if the group failed
352
+ def notify_group_completed(task_class, group_name, duration: nil, error: nil)
353
+ state = error ? :failed : :completed
354
+ dispatch(:update_group, task_class, group_name, state: state, duration: duration, error: error)
355
+ end
356
+
357
+ private
358
+
359
+ # Dispatch a method call to all observers that respond to the method.
360
+ # Uses duck typing: observers only receive calls for methods they implement.
361
+ #
362
+ # @param method_name [Symbol] The method to call on observers
363
+ # @param args [Array] Arguments to pass to the method
364
+ # @param kwargs [Hash] Keyword arguments to pass to the method
365
+ def dispatch(method_name, *args, **kwargs)
366
+ current_observers = @monitor.synchronize { @observers.dup }
367
+ current_observers.each do |observer|
368
+ next unless observer.respond_to?(method_name)
369
+
370
+ begin
371
+ if kwargs.empty?
372
+ observer.public_send(method_name, *args)
373
+ else
374
+ observer.public_send(method_name, *args, **kwargs)
375
+ end
376
+ rescue => e
377
+ warn "[ExecutionContext] Observer #{observer.class} raised error in #{method_name}: #{e.message}"
378
+ end
379
+ end
380
+ end
381
+ end
382
+ end
383
+ end