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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +52 -0
  3. data/README.md +108 -50
  4. data/docs/GUIDE.md +79 -55
  5. data/examples/README.md +10 -29
  6. data/examples/clean_demo.rb +25 -65
  7. data/examples/large_tree_demo.rb +356 -0
  8. data/examples/message_demo.rb +0 -1
  9. data/examples/progress_demo.rb +13 -24
  10. data/examples/reexecution_demo.rb +8 -44
  11. data/lib/taski/execution/execution_facade.rb +150 -0
  12. data/lib/taski/execution/executor.rb +167 -359
  13. data/lib/taski/execution/fiber_protocol.rb +27 -0
  14. data/lib/taski/execution/registry.rb +15 -19
  15. data/lib/taski/execution/scheduler.rb +161 -140
  16. data/lib/taski/execution/task_observer.rb +41 -0
  17. data/lib/taski/execution/task_output_router.rb +41 -58
  18. data/lib/taski/execution/task_wrapper.rb +123 -219
  19. data/lib/taski/execution/worker_pool.rb +279 -64
  20. data/lib/taski/logging.rb +105 -0
  21. data/lib/taski/progress/layout/base.rb +600 -0
  22. data/lib/taski/progress/layout/filters.rb +126 -0
  23. data/lib/taski/progress/layout/log.rb +27 -0
  24. data/lib/taski/progress/layout/simple.rb +166 -0
  25. data/lib/taski/progress/layout/tags.rb +76 -0
  26. data/lib/taski/progress/layout/theme_drop.rb +84 -0
  27. data/lib/taski/progress/layout/tree.rb +300 -0
  28. data/lib/taski/progress/theme/base.rb +224 -0
  29. data/lib/taski/progress/theme/compact.rb +58 -0
  30. data/lib/taski/progress/theme/default.rb +25 -0
  31. data/lib/taski/progress/theme/detail.rb +48 -0
  32. data/lib/taski/progress/theme/plain.rb +40 -0
  33. data/lib/taski/static_analysis/analyzer.rb +5 -17
  34. data/lib/taski/static_analysis/dependency_graph.rb +19 -1
  35. data/lib/taski/static_analysis/start_dep_analyzer.rb +400 -0
  36. data/lib/taski/static_analysis/visitor.rb +1 -39
  37. data/lib/taski/task.rb +49 -58
  38. data/lib/taski/task_proxy.rb +59 -0
  39. data/lib/taski/test_helper/errors.rb +1 -1
  40. data/lib/taski/test_helper.rb +22 -36
  41. data/lib/taski/version.rb +1 -1
  42. data/lib/taski.rb +62 -61
  43. data/sig/taski.rbs +194 -203
  44. metadata +34 -8
  45. data/examples/section_demo.rb +0 -195
  46. data/lib/taski/execution/base_progress_display.rb +0 -393
  47. data/lib/taski/execution/execution_context.rb +0 -390
  48. data/lib/taski/execution/plain_progress_display.rb +0 -76
  49. data/lib/taski/execution/simple_progress_display.rb +0 -247
  50. data/lib/taski/execution/tree_progress_display.rb +0 -643
  51. 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