1use std::collections::HashSet;
9use std::fmt;
10use std::io::{self, IsTerminal, Write};
11use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering};
12use std::sync::mpsc::{self, Receiver, RecvTimeoutError, SyncSender};
13use std::sync::{Arc, Mutex};
14use std::thread::{self, JoinHandle};
15use std::time::Duration;
16
17use crossterm::cursor::{Hide, MoveTo, Show};
18use crossterm::event::{
19 self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers,
20};
21use crossterm::execute;
22use crossterm::terminal::{
23 Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
24};
25use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle};
26use serde_json::Value;
27
28use super::error::ReportingError;
29use super::phase::{Phase, TaskDisplayKind, TaskKey};
30
31const REFRESH_INTERVAL: Duration = Duration::from_millis(100);
32const MESSAGE_CAPACITY: usize = 256;
33static TERMINAL_OWNED: AtomicBool = AtomicBool::new(false);
34
35#[derive(Clone, Copy, Debug, Eq, PartialEq)]
37#[non_exhaustive]
38pub enum TaskStatus {
39 Pending,
41 Running,
43 Completed,
45 Reused,
47 Failed,
49 Cancelled,
51 Skipped,
53}
54
55impl TaskStatus {
56 pub const fn as_str(self) -> &'static str {
58 match self {
59 Self::Pending => "pending",
60 Self::Running => "running",
61 Self::Completed => "completed",
62 Self::Failed => "failed",
63 Self::Reused => "reused",
64 Self::Cancelled => "cancelled",
65 Self::Skipped => "skipped",
66 }
67 }
68
69 fn encode(self) -> u8 {
70 match self {
71 Self::Pending => 0,
72 Self::Running => 1,
73 Self::Completed => 2,
74 Self::Failed => 3,
75 Self::Reused => 4,
76 Self::Cancelled => 5,
77 Self::Skipped => 6,
78 }
79 }
80
81 fn decode(value: u8) -> Self {
82 match value {
83 0 => Self::Pending,
84 1 => Self::Running,
85 2 => Self::Completed,
86 3 => Self::Failed,
87 4 => Self::Reused,
88 5 => Self::Cancelled,
89 6 => Self::Skipped,
90 _ => unreachable!("task status is written only through TaskStatus::encode"),
91 }
92 }
93
94 fn label(self) -> &'static str {
95 self.as_str()
96 }
97}
98
99#[derive(Clone, Debug)]
105pub struct TaskIdentity {
106 label: Arc<str>,
107 key: TaskKey,
108 configuration: crate::configuration::TaskConfig,
109}
110
111impl TaskIdentity {
112 pub fn label(&self) -> &str {
114 &self.label
115 }
116
117 pub fn len(&self) -> usize {
119 self.configuration.parameters().len()
120 }
121
122 pub fn is_empty(&self) -> bool {
124 self.configuration.parameters().is_empty()
125 }
126
127 pub fn value(&self, key: &str) -> Option<&Value> {
129 self.configuration.value(key)
130 }
131
132 pub fn iter(&self) -> Box<dyn Iterator<Item = (&str, &Value)> + '_> {
134 Box::new(self.configuration.parameters().iter())
135 }
136
137 pub fn task_key(&self) -> &TaskKey {
140 &self.key
141 }
142}
143
144#[derive(Clone, Debug, Eq, PartialEq)]
146pub struct ProgressSummary {
147 total: u64,
148 pending: u64,
149 running: u64,
150 completed: u64,
151 reused: u64,
152 failed: u64,
153 cancelled: u64,
154 skipped: u64,
155}
156
157impl ProgressSummary {
158 pub fn total(&self) -> u64 {
160 self.total
161 }
162
163 pub fn pending(&self) -> u64 {
165 self.pending
166 }
167
168 pub fn running(&self) -> u64 {
170 self.running
171 }
172
173 pub fn completed(&self) -> u64 {
175 self.completed
176 }
177
178 pub fn reused(&self) -> u64 {
180 self.reused
181 }
182
183 pub fn failed(&self) -> u64 {
185 self.failed
186 }
187
188 pub fn cancelled(&self) -> u64 {
190 self.cancelled
191 }
192
193 pub fn skipped(&self) -> u64 {
195 self.skipped
196 }
197
198 pub fn is_success(&self) -> bool {
200 self.completed + self.reused == self.total
201 && self.pending == 0
202 && self.running == 0
203 && self.failed == 0
204 && self.cancelled == 0
205 && self.skipped == 0
206 }
207}
208
209#[derive(Clone, Copy, Debug, Eq, PartialEq)]
211enum OutputMode {
212 Auto,
213 Terminal,
214 Plain,
215 Hidden,
216}
217
218pub(crate) struct RuntimeReporterBuilder {
220 slots: Arc<[Arc<ProgressSlot>]>,
221 output: OutputMode,
222 cancellation: Option<CancellationToken>,
223}
224
225impl RuntimeReporterBuilder {
226 pub(crate) fn cancellation_token(mut self, cancellation: CancellationToken) -> Self {
227 self.cancellation = Some(cancellation);
228 self
229 }
230 pub fn terminal(mut self) -> Self {
232 self.output = OutputMode::Terminal;
233 self
234 }
235
236 pub fn plain(mut self) -> Self {
238 self.output = OutputMode::Plain;
239 self
240 }
241
242 pub fn hidden(mut self) -> Self {
244 self.output = OutputMode::Hidden;
245 self
246 }
247
248 pub fn start(self) -> Result<RuntimeReporter, ReportingError> {
250 start_reporter(self.slots, self.output, self.cancellation)
251 }
252}
253
254impl fmt::Debug for RuntimeReporterBuilder {
255 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
256 formatter
257 .debug_struct("RuntimeReporterBuilder")
258 .field("tasks", &self.slots.len())
259 .field("output", &self.output)
260 .finish_non_exhaustive()
261 }
262}
263
264pub(crate) struct RuntimeReporter {
266 inner: Arc<ReporterInner>,
267 renderer: Option<JoinHandle<()>>,
268 finished: bool,
269}
270
271impl RuntimeReporter {
272 pub fn for_phase(
274 phase: &Phase,
275 heading: &str,
276 ) -> Result<RuntimeReporterBuilder, ReportingError> {
277 Ok(RuntimeReporterBuilder {
278 slots: build_phase_slots(std::slice::from_ref(phase), Some(heading))?,
279 output: OutputMode::Auto,
280 cancellation: None,
281 })
282 }
283
284 pub fn start_progress(
286 &self,
287 key: &TaskKey,
288 initial_iteration: u64,
289 target_iteration: Option<u64>,
290 ) -> Result<TaskProgress, ReportingError> {
291 let slot = managed_slot(&self.inner.slots, key)?;
292 if slot.display_kind != TaskDisplayKind::Progress {
293 return Err(kind_mismatch(&slot, "progress"));
294 }
295 start_slot(&self.inner, slot, initial_iteration, target_iteration)
296 }
297
298 pub fn start_activity(&self, key: &TaskKey) -> Result<ActivityTask, ReportingError> {
300 let slot = managed_slot(&self.inner.slots, key)?;
301 if slot.display_kind != TaskDisplayKind::Activity {
302 return Err(kind_mismatch(&slot, "activity"));
303 }
304 start_activity_slot(&self.inner, slot)
305 }
306
307 pub fn mark_reused(&self, key: &TaskKey) -> Result<(), ReportingError> {
309 let slot = managed_slot(&self.inner.slots, key)?;
310 mark_slot_reused(&slot)
311 }
312
313 pub(crate) fn mark_skipped(&self, key: &TaskKey) -> Result<(), ReportingError> {
314 let slot = managed_slot(&self.inner.slots, key)?;
315 mark_pending_terminal(&slot, TaskStatus::Skipped, "skipped")
316 }
317
318 pub(crate) fn mark_cancelled(&self, key: &TaskKey) -> Result<(), ReportingError> {
319 let slot = managed_slot(&self.inner.slots, key)?;
320 mark_pending_terminal(&slot, TaskStatus::Cancelled, "cancelled")
321 }
322
323 pub(crate) fn mark_delayed(&self, key: &TaskKey, rank: usize) -> Result<(), ReportingError> {
324 let slot = managed_slot(&self.inner.slots, key)?;
325 if TaskStatus::decode(slot.status.load(Ordering::Acquire)) != TaskStatus::Pending {
326 return Err(ReportingError::TaskAlreadyStarted {
327 identity: slot.identity.label().to_owned(),
328 });
329 }
330 *lock(&slot.detail) = format!("delayed start (rank {rank})").into_boxed_str();
331 Ok(())
332 }
333
334 pub(crate) fn request_cancellation(&self) {
335 self.inner.cancelled.store(true, Ordering::Release);
336 }
337
338 pub(crate) fn is_cancelled(&self) -> bool {
339 self.inner.cancelled.load(Ordering::Acquire)
340 }
341
342 pub(crate) fn cancellation_flag(&self) -> Arc<AtomicBool> {
343 Arc::clone(&self.inner.cancelled)
344 }
345
346 pub fn summary(&self) -> ProgressSummary {
348 summarize(&self.inner.slots)
349 }
350
351 pub(crate) fn task_execution_snapshots(&self) -> Vec<TaskExecutionSnapshot> {
352 self.inner
353 .slots
354 .iter()
355 .map(|slot| TaskExecutionSnapshot {
356 key: slot.identity.task_key().clone(),
357 status: TaskStatus::decode(slot.status.load(Ordering::Acquire)),
358 current_iteration: (slot.display_kind == TaskDisplayKind::Progress)
359 .then(|| slot.current.load(Ordering::Relaxed)),
360 target_iteration: (slot.display_kind == TaskDisplayKind::Progress
361 && slot.target_known.load(Ordering::Acquire))
362 .then(|| slot.target.load(Ordering::Relaxed)),
363 })
364 .collect()
365 }
366
367 pub fn complete(
372 mut self,
373 message: impl Into<String>,
374 ) -> Result<ProgressSummary, ReportingError> {
375 let summary = self.summary();
376 if !summary.is_success() {
377 self.stop(false, "workflow did not complete".to_owned())?;
378 return Err(ReportingError::IncompleteProgress {
379 pending: summary.pending,
380 running: summary.running,
381 failed: summary.failed,
382 });
383 }
384 self.stop(true, message.into())?;
385 Ok(summary)
386 }
387
388 pub fn fail(mut self, message: impl Into<String>) -> Result<ProgressSummary, ReportingError> {
390 let summary = self.summary();
391 self.stop(false, message.into())?;
392 Ok(summary)
393 }
394
395 fn stop(&mut self, success: bool, message: String) -> Result<(), ReportingError> {
396 self.inner
397 .events
398 .send(RenderEvent::Stop { success, message })
399 .map_err(|_| ReportingError::RendererUnavailable)?;
400 self.finished = true;
401 if self
402 .renderer
403 .take()
404 .expect("an unfinished reporter owns one renderer")
405 .join()
406 .is_err()
407 {
408 return Err(ReportingError::RendererPanicked);
409 }
410 Ok(())
411 }
412}
413
414pub(crate) struct TaskExecutionSnapshot {
415 pub(crate) key: TaskKey,
416 pub(crate) status: TaskStatus,
417 pub(crate) current_iteration: Option<u64>,
418 pub(crate) target_iteration: Option<u64>,
419}
420
421impl fmt::Debug for RuntimeReporter {
422 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
423 formatter
424 .debug_struct("RuntimeReporter")
425 .field("tasks", &self.inner.slots.len())
426 .field("summary", &self.summary())
427 .finish_non_exhaustive()
428 }
429}
430
431impl Drop for RuntimeReporter {
432 fn drop(&mut self) {
433 if self.finished {
434 return;
435 }
436 let _ = self.inner.events.send(RenderEvent::Stop {
437 success: false,
438 message: "progress reporter dropped before completion".to_owned(),
439 });
440 if let Some(renderer) = self.renderer.take() {
441 let _ = renderer.join();
442 }
443 }
444}
445
446pub struct TaskProgress {
452 slot: Arc<ProgressSlot>,
453 events: SyncSender<RenderEvent>,
454 cancelled: Arc<AtomicBool>,
455 active: bool,
456}
457
458#[derive(Clone, Debug)]
460pub struct CancellationToken(Arc<AtomicBool>);
461
462impl CancellationToken {
463 pub(crate) fn new() -> Self {
464 Self(Arc::new(AtomicBool::new(false)))
465 }
466
467 pub(crate) fn shared(&self) -> Arc<AtomicBool> {
468 Arc::clone(&self.0)
469 }
470
471 pub fn is_cancelled(&self) -> bool {
473 self.0.load(Ordering::Acquire)
474 }
475
476 pub fn cancel(&self) {
478 self.0.store(true, Ordering::Release);
479 }
480}
481
482impl TaskProgress {
483 pub fn is_cancelled(&self) -> bool {
485 self.cancelled.load(Ordering::Acquire)
486 }
487
488 pub fn identity(&self) -> &TaskIdentity {
490 &self.slot.identity
491 }
492
493 pub fn current_iteration(&self) -> u64 {
495 self.slot.current.load(Ordering::Relaxed)
496 }
497
498 pub fn target_iteration(&self) -> Option<u64> {
500 if self.slot.target_known.load(Ordering::Acquire) {
501 Some(self.slot.target.load(Ordering::Relaxed))
502 } else {
503 None
504 }
505 }
506
507 pub fn set_target_iteration(&self, target: u64) -> Result<(), ReportingError> {
509 let current = self.current_iteration();
510 if current > target {
511 return Err(ReportingError::InitialIterationBeyondTarget {
512 identity: self.identity().label().to_owned(),
513 initial: current,
514 target,
515 });
516 }
517 self.slot.target.store(target, Ordering::Relaxed);
518 self.slot.target_known.store(true, Ordering::Release);
519 Ok(())
520 }
521
522 pub fn status(&self) -> TaskStatus {
524 TaskStatus::decode(self.slot.status.load(Ordering::Acquire))
525 }
526
527 pub fn set_iteration(&self, iteration: u64) -> Result<(), ReportingError> {
532 if let Some(target) = self.target_iteration().filter(|target| iteration > *target) {
533 return Err(ReportingError::IterationBeyondTarget {
534 identity: self.identity().label().to_owned(),
535 iteration,
536 target,
537 });
538 }
539 let previous = self.slot.current.fetch_max(iteration, Ordering::Relaxed);
540 if iteration < previous {
541 return Err(ReportingError::IterationRegressed {
542 identity: self.identity().label().to_owned(),
543 current: previous,
544 attempted: iteration,
545 });
546 }
547 Ok(())
548 }
549
550 pub fn should_continue(&self, iteration: u64) -> Result<bool, ReportingError> {
557 self.set_iteration(iteration)?;
558 Ok(!self.is_cancelled()
559 && self
560 .target_iteration()
561 .is_none_or(|target| iteration < target))
562 }
563
564 pub fn set_detail(&self, detail: impl Into<String>) {
567 *lock(&self.slot.detail) = detail.into().into_boxed_str();
568 }
569
570 pub fn report(&self, message: impl Into<String>) -> Result<(), ReportingError> {
572 self.events
573 .send(RenderEvent::TaskMessage {
574 identity: self.identity().label().to_owned(),
575 message: message.into(),
576 })
577 .map_err(|_| ReportingError::RendererUnavailable)
578 }
579
580 pub fn complete(mut self, reason: Option<String>) -> Result<(), ReportingError> {
586 if reason.is_none()
587 && let Some(target) = self.target_iteration()
588 {
589 let current = self.current_iteration();
590 if current != target {
591 *lock(&self.slot.detail) = "target not reached".into();
592 self.slot
593 .status
594 .store(TaskStatus::Failed.encode(), Ordering::Release);
595 self.active = false;
596 return Err(ReportingError::TargetIterationNotReached {
597 identity: self.identity().label().to_owned(),
598 current,
599 target,
600 });
601 }
602 }
603 *lock(&self.slot.detail) = reason
604 .unwrap_or_else(|| "completed".to_owned())
605 .into_boxed_str();
606 self.slot
607 .status
608 .store(TaskStatus::Completed.encode(), Ordering::Release);
609 self.active = false;
610 Ok(())
611 }
612
613 pub fn fail(mut self, reason: impl Into<String>) {
615 *lock(&self.slot.detail) = reason.into().into_boxed_str();
616 self.slot
617 .status
618 .store(TaskStatus::Failed.encode(), Ordering::Release);
619 self.active = false;
620 }
621
622 pub(crate) fn cancel(mut self, reason: impl Into<String>) {
623 *lock(&self.slot.detail) = reason.into().into_boxed_str();
624 self.slot
625 .status
626 .store(TaskStatus::Cancelled.encode(), Ordering::Release);
627 self.active = false;
628 }
629}
630
631impl fmt::Debug for TaskProgress {
632 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
633 formatter
634 .debug_struct("TaskProgress")
635 .field("identity", &self.identity().label())
636 .field("current_iteration", &self.current_iteration())
637 .field("target_iteration", &self.target_iteration())
638 .field("active", &self.active)
639 .finish_non_exhaustive()
640 }
641}
642
643impl Drop for TaskProgress {
644 fn drop(&mut self) {
645 if self.active {
646 *lock(&self.slot.detail) = "interrupted".into();
647 self.slot
648 .status
649 .store(TaskStatus::Failed.encode(), Ordering::Release);
650 }
651 }
652}
653
654pub struct ActivityTask {
659 slot: Arc<ProgressSlot>,
660 events: SyncSender<RenderEvent>,
661 cancelled: Arc<AtomicBool>,
662 active: bool,
663}
664
665impl ActivityTask {
666 pub fn is_cancelled(&self) -> bool {
668 self.cancelled.load(Ordering::Acquire)
669 }
670
671 pub fn identity(&self) -> &TaskIdentity {
673 &self.slot.identity
674 }
675
676 pub fn status(&self) -> TaskStatus {
678 TaskStatus::decode(self.slot.status.load(Ordering::Acquire))
679 }
680
681 pub fn set_detail(&self, detail: impl Into<String>) {
683 *lock(&self.slot.detail) = detail.into().into_boxed_str();
684 }
685
686 pub fn report(&self, message: impl Into<String>) -> Result<(), ReportingError> {
688 self.events
689 .send(RenderEvent::TaskMessage {
690 identity: self.identity().label().to_owned(),
691 message: message.into(),
692 })
693 .map_err(|_| ReportingError::RendererUnavailable)
694 }
695
696 pub fn complete(mut self) {
698 *lock(&self.slot.detail) = "completed".into();
699 self.slot
700 .status
701 .store(TaskStatus::Completed.encode(), Ordering::Release);
702 self.active = false;
703 }
704
705 pub fn fail(mut self, reason: impl Into<String>) {
707 *lock(&self.slot.detail) = reason.into().into_boxed_str();
708 self.slot
709 .status
710 .store(TaskStatus::Failed.encode(), Ordering::Release);
711 self.active = false;
712 }
713
714 pub(crate) fn cancel(mut self, reason: impl Into<String>) {
715 *lock(&self.slot.detail) = reason.into().into_boxed_str();
716 self.slot
717 .status
718 .store(TaskStatus::Cancelled.encode(), Ordering::Release);
719 self.active = false;
720 }
721}
722
723impl fmt::Debug for ActivityTask {
724 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
725 formatter
726 .debug_struct("ActivityTask")
727 .field("identity", &self.identity().label())
728 .field("status", &self.status())
729 .field("active", &self.active)
730 .finish_non_exhaustive()
731 }
732}
733
734impl Drop for ActivityTask {
735 fn drop(&mut self) {
736 if self.active {
737 *lock(&self.slot.detail) = "interrupted".into();
738 self.slot
739 .status
740 .store(TaskStatus::Failed.encode(), Ordering::Release);
741 }
742 }
743}
744
745struct ReporterInner {
746 slots: Arc<[Arc<ProgressSlot>]>,
747 events: SyncSender<RenderEvent>,
748 cancelled: Arc<AtomicBool>,
749}
750
751struct ProgressSlot {
752 identity: TaskIdentity,
753 phase_label: Option<Arc<str>>,
754 display_kind: TaskDisplayKind,
755 current: AtomicU64,
756 target: AtomicU64,
757 target_known: AtomicBool,
758 started: AtomicBool,
759 status: AtomicU8,
760 detail: Mutex<Box<str>>,
761}
762
763enum RenderEvent {
764 TaskMessage { identity: String, message: String },
765 Stop { success: bool, message: String },
766}
767
768struct TerminalLease;
769
770impl Drop for TerminalLease {
771 fn drop(&mut self) {
772 TERMINAL_OWNED.store(false, Ordering::Release);
773 }
774}
775
776fn acquire_terminal() -> Result<(), ReportingError> {
777 TERMINAL_OWNED
778 .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
779 .map(|_| ())
780 .map_err(|_| ReportingError::TerminalAlreadyOwned)
781}
782
783fn resolve_output(output: OutputMode) -> OutputMode {
784 match output {
785 OutputMode::Auto if io::stderr().is_terminal() && io::stdin().is_terminal() => {
786 OutputMode::Terminal
787 }
788 OutputMode::Auto => OutputMode::Plain,
789 explicit => explicit,
790 }
791}
792
793fn start_reporter(
794 slots: Arc<[Arc<ProgressSlot>]>,
795 requested_output: OutputMode,
796 cancellation: Option<CancellationToken>,
797) -> Result<RuntimeReporter, ReportingError> {
798 acquire_terminal()?;
799 let lease = TerminalLease;
800 let cancelled = cancellation
801 .map(|token| token.shared())
802 .unwrap_or_else(|| Arc::new(AtomicBool::new(false)));
803 let mut output = resolve_output(requested_output);
804 let terminal = if output == OutputMode::Terminal {
805 match TerminalSession::enter(Arc::clone(&cancelled)) {
806 Ok(terminal) => Some(terminal),
807 Err(_) if requested_output == OutputMode::Auto => {
808 output = OutputMode::Plain;
809 None
810 }
811 Err(error) => return Err(error),
812 }
813 } else {
814 None
815 };
816 let (events, receiver) = mpsc::sync_channel(MESSAGE_CAPACITY);
817 let renderer_slots = Arc::clone(&slots);
818 let renderer = match thread::Builder::new()
819 .name("scientific-workflow-progress".to_owned())
820 .spawn(move || render(receiver, renderer_slots, output, terminal, lease))
821 {
822 Ok(renderer) => renderer,
823 Err(source) => return Err(ReportingError::StartRenderer { source }),
824 };
825 Ok(RuntimeReporter {
826 inner: Arc::new(ReporterInner {
827 slots,
828 events,
829 cancelled,
830 }),
831 renderer: Some(renderer),
832 finished: false,
833 })
834}
835
836fn managed_slot(
837 slots: &[Arc<ProgressSlot>],
838 key: &TaskKey,
839) -> Result<Arc<ProgressSlot>, ReportingError> {
840 slots
841 .iter()
842 .find(|slot| slot.identity.task_key() == key)
843 .cloned()
844 .ok_or_else(|| ReportingError::UnknownManagedTask {
845 task: key.to_string(),
846 })
847}
848
849fn kind_mismatch(slot: &ProgressSlot, requested: &'static str) -> ReportingError {
850 ReportingError::ManagedTaskKindMismatch {
851 task: slot.identity.task_key().to_string(),
852 requested,
853 actual: match slot.display_kind {
854 TaskDisplayKind::Progress => "progress",
855 TaskDisplayKind::Activity => "activity",
856 },
857 }
858}
859
860fn mark_slot_reused(slot: &ProgressSlot) -> Result<(), ReportingError> {
861 slot.status
862 .compare_exchange(
863 TaskStatus::Pending.encode(),
864 TaskStatus::Reused.encode(),
865 Ordering::AcqRel,
866 Ordering::Acquire,
867 )
868 .map_err(|_| ReportingError::TaskAlreadyStarted {
869 identity: slot.identity.label().to_owned(),
870 })?;
871 *lock(&slot.detail) = "reused".into();
872 Ok(())
873}
874
875fn mark_pending_terminal(
876 slot: &ProgressSlot,
877 status: TaskStatus,
878 detail: &'static str,
879) -> Result<(), ReportingError> {
880 slot.status
881 .compare_exchange(
882 TaskStatus::Pending.encode(),
883 status.encode(),
884 Ordering::AcqRel,
885 Ordering::Acquire,
886 )
887 .map_err(|_| ReportingError::TaskAlreadyStarted {
888 identity: slot.identity.label().to_owned(),
889 })?;
890 *lock(&slot.detail) = detail.into();
891 Ok(())
892}
893
894fn start_slot(
895 inner: &ReporterInner,
896 slot: Arc<ProgressSlot>,
897 initial_iteration: u64,
898 target_iteration: Option<u64>,
899) -> Result<TaskProgress, ReportingError> {
900 if let Some(target) = target_iteration.filter(|target| initial_iteration > *target) {
901 return Err(ReportingError::InitialIterationBeyondTarget {
902 identity: slot.identity.label().to_owned(),
903 initial: initial_iteration,
904 target,
905 });
906 }
907 slot.status
908 .compare_exchange(
909 TaskStatus::Pending.encode(),
910 TaskStatus::Running.encode(),
911 Ordering::AcqRel,
912 Ordering::Acquire,
913 )
914 .map_err(|_| ReportingError::TaskAlreadyStarted {
915 identity: slot.identity.label().to_owned(),
916 })?;
917 slot.started.store(true, Ordering::Release);
918 slot.current.store(initial_iteration, Ordering::Relaxed);
919 if let Some(target) = target_iteration {
920 slot.target.store(target, Ordering::Relaxed);
921 slot.target_known.store(true, Ordering::Release);
922 } else {
923 slot.target_known.store(false, Ordering::Release);
924 }
925 *lock(&slot.detail) = "running".into();
926 Ok(TaskProgress {
927 slot,
928 events: inner.events.clone(),
929 cancelled: Arc::clone(&inner.cancelled),
930 active: true,
931 })
932}
933
934fn start_activity_slot(
935 inner: &ReporterInner,
936 slot: Arc<ProgressSlot>,
937) -> Result<ActivityTask, ReportingError> {
938 slot.status
939 .compare_exchange(
940 TaskStatus::Pending.encode(),
941 TaskStatus::Running.encode(),
942 Ordering::AcqRel,
943 Ordering::Acquire,
944 )
945 .map_err(|_| ReportingError::TaskAlreadyStarted {
946 identity: slot.identity.label().to_owned(),
947 })?;
948 slot.started.store(true, Ordering::Release);
949 slot.target_known.store(false, Ordering::Release);
950 *lock(&slot.detail) = "running".into();
951 Ok(ActivityTask {
952 slot,
953 events: inner.events.clone(),
954 cancelled: Arc::clone(&inner.cancelled),
955 active: true,
956 })
957}
958
959fn build_phase_slots(
960 phases: &[Phase],
961 heading: Option<&str>,
962) -> Result<Arc<[Arc<ProgressSlot>]>, ReportingError> {
963 if phases.is_empty() {
964 return Err(ReportingError::EmptyPhaseSet);
965 }
966 let mut phase_ids = HashSet::with_capacity(phases.len());
967 let capacity = phases.iter().map(|phase| phase.tasks().len()).sum();
968 let mut slots = Vec::with_capacity(capacity);
969 for phase in phases {
970 if !phase_ids.insert(phase.id()) {
971 return Err(ReportingError::DuplicatePhaseId {
972 phase: phase.id().get(),
973 });
974 }
975 let phase_label: Arc<str> = heading.unwrap_or_else(|| phase.label()).into();
976 for task in phase.tasks() {
977 slots.push(Arc::new(ProgressSlot {
978 identity: TaskIdentity {
979 label: task.label().into(),
980 key: task.key().clone(),
981 configuration: task.configuration().clone(),
982 },
983 phase_label: Some(Arc::clone(&phase_label)),
984 display_kind: task.display_kind(),
985 current: AtomicU64::new(0),
986 target: AtomicU64::new(0),
987 target_known: AtomicBool::new(false),
988 started: AtomicBool::new(false),
989 status: AtomicU8::new(TaskStatus::Pending.encode()),
990 detail: Mutex::new("pending".into()),
991 }));
992 }
993 }
994 Ok(slots.into())
995}
996
997fn summarize(slots: &[Arc<ProgressSlot>]) -> ProgressSummary {
998 let mut summary = ProgressSummary {
999 total: u64::try_from(slots.len()).expect("slot count originated from a u64 task count"),
1000 pending: 0,
1001 running: 0,
1002 completed: 0,
1003 reused: 0,
1004 failed: 0,
1005 cancelled: 0,
1006 skipped: 0,
1007 };
1008 for slot in slots {
1009 match TaskStatus::decode(slot.status.load(Ordering::Acquire)) {
1010 TaskStatus::Pending => summary.pending += 1,
1011 TaskStatus::Running => summary.running += 1,
1012 TaskStatus::Completed => summary.completed += 1,
1013 TaskStatus::Reused => summary.reused += 1,
1014 TaskStatus::Failed => summary.failed += 1,
1015 TaskStatus::Cancelled => summary.cancelled += 1,
1016 TaskStatus::Skipped => summary.skipped += 1,
1017 }
1018 }
1019 summary
1020}
1021
1022struct TerminalSession {
1023 stop: Arc<AtomicBool>,
1024 input: Option<JoinHandle<()>>,
1025}
1026
1027impl TerminalSession {
1028 fn enter(cancelled: Arc<AtomicBool>) -> Result<Self, ReportingError> {
1029 enable_raw_mode().map_err(|source| ReportingError::TerminalSetup {
1030 operation: "enable raw input mode",
1031 source,
1032 })?;
1033 let mut stderr = io::stderr();
1034 if let Err(source) = execute!(
1035 stderr,
1036 EnterAlternateScreen,
1037 Clear(ClearType::All),
1038 MoveTo(0, 0),
1039 Hide,
1040 EnableMouseCapture
1041 ) {
1042 let _ = execute!(stderr, DisableMouseCapture, Show, LeaveAlternateScreen);
1043 let _ = disable_raw_mode();
1044 return Err(ReportingError::TerminalSetup {
1045 operation: "enter the isolated terminal screen",
1046 source,
1047 });
1048 }
1049 let stop = Arc::new(AtomicBool::new(false));
1050 let input_stop = Arc::clone(&stop);
1051 let input = match thread::Builder::new()
1052 .name("scientific-workflow-input".to_owned())
1053 .spawn(move || drain_terminal_input(input_stop, cancelled))
1054 {
1055 Ok(input) => input,
1056 Err(source) => {
1057 let _ = execute!(stderr, DisableMouseCapture, Show, LeaveAlternateScreen);
1058 let _ = disable_raw_mode();
1059 return Err(ReportingError::TerminalSetup {
1060 operation: "start the isolated-screen input drain",
1061 source,
1062 });
1063 }
1064 };
1065 Ok(Self {
1066 stop,
1067 input: Some(input),
1068 })
1069 }
1070}
1071
1072impl Drop for TerminalSession {
1073 fn drop(&mut self) {
1074 self.stop.store(true, Ordering::Release);
1075 if let Some(input) = self.input.take() {
1076 let _ = input.join();
1077 }
1078 let mut stderr = io::stderr();
1079 let _ = execute!(stderr, DisableMouseCapture, Show, LeaveAlternateScreen);
1080 let _ = stderr.flush();
1081 let _ = disable_raw_mode();
1082 }
1083}
1084
1085fn drain_terminal_input(stop: Arc<AtomicBool>, cancelled: Arc<AtomicBool>) {
1086 while !stop.load(Ordering::Acquire) {
1087 match event::poll(Duration::from_millis(50)) {
1088 Ok(true) => match event::read() {
1089 Ok(Event::Key(key))
1090 if key.kind == KeyEventKind::Press
1091 && key.code == KeyCode::Char('c')
1092 && key.modifiers.contains(KeyModifiers::CONTROL) =>
1093 {
1094 cancelled.store(true, Ordering::Release);
1095 }
1096 Ok(_) => {}
1097 Err(_) => {
1098 cancelled.store(true, Ordering::Release);
1099 break;
1100 }
1101 },
1102 Ok(false) => {}
1103 Err(_) => {
1104 cancelled.store(true, Ordering::Release);
1105 break;
1106 }
1107 }
1108 }
1109}
1110
1111fn render(
1112 receiver: Receiver<RenderEvent>,
1113 slots: Arc<[Arc<ProgressSlot>]>,
1114 output: OutputMode,
1115 mut terminal_session: Option<TerminalSession>,
1116 _lease: TerminalLease,
1117) {
1118 let mut terminal = (output == OutputMode::Terminal).then(|| TerminalDisplay::new(&slots));
1119 let mut last_statuses = vec![TaskStatus::Pending; slots.len()];
1120 loop {
1121 if let Some(display) = &mut terminal {
1122 display.refresh(&slots);
1123 }
1124 match receiver.recv_timeout(REFRESH_INTERVAL) {
1125 Ok(RenderEvent::TaskMessage { identity, message }) => {
1126 write_message(output, terminal.as_ref(), &format!("{identity}: {message}"));
1127 }
1128 Ok(RenderEvent::Stop { success, message }) => {
1129 if let Some(display) = &mut terminal {
1130 display.refresh(&slots);
1131 display.finish(&slots);
1132 }
1133 if output == OutputMode::Plain {
1134 write_plain_transitions(&slots, &mut last_statuses);
1135 }
1136 drop(terminal.take());
1137 drop(terminal_session.take());
1138 write_final(output, &slots, success, &message);
1139 break;
1140 }
1141 Err(RecvTimeoutError::Timeout) => {
1142 if output == OutputMode::Plain {
1143 write_plain_transitions(&slots, &mut last_statuses);
1144 }
1145 }
1146 Err(RecvTimeoutError::Disconnected) => break,
1147 }
1148 }
1149}
1150
1151struct TerminalDisplay {
1152 multi: MultiProgress,
1153 headings: Vec<ProgressBar>,
1154 bars: Vec<ProgressBar>,
1155 started: Vec<bool>,
1156 idle_style: ProgressStyle,
1157 activity_style: ProgressStyle,
1158 known_style: ProgressStyle,
1159 unknown_style: ProgressStyle,
1160}
1161
1162impl TerminalDisplay {
1163 fn new(slots: &[Arc<ProgressSlot>]) -> Self {
1164 let multi = MultiProgress::with_draw_target(ProgressDrawTarget::stderr());
1165 let idle_style = ProgressStyle::with_template("{prefix:.bold} [{msg}]")
1166 .expect("hard-coded idle progress template is valid");
1167 let activity_style =
1168 ProgressStyle::with_template("{prefix:.bold} [{msg}] elapsed {elapsed_precise}")
1169 .expect("hard-coded activity template is valid");
1170 let known_style = ProgressStyle::with_template(
1171 "{prefix:.bold} [{msg}] {wide_bar:.cyan/blue} {pos}/{len} elapsed {elapsed_precise} ETA {eta_precise}",
1172 )
1173 .expect("hard-coded progress template is valid");
1174 let unknown_style = ProgressStyle::with_template(
1175 "{prefix:.bold} [{msg}] {spinner:.cyan} iteration {pos} elapsed {elapsed_precise} ETA unknown",
1176 )
1177 .expect("hard-coded spinner template is valid");
1178 let heading_style =
1179 ProgressStyle::with_template("{prefix:.bold} [{msg}] elapsed {elapsed_precise}")
1180 .expect("hard-coded phase-heading template is valid");
1181 let mut headings = Vec::new();
1182 let mut bars = Vec::with_capacity(slots.len());
1183 let mut previous_phase: Option<&str> = None;
1184 for slot in slots {
1185 if let Some(phase) = slot.phase_label.as_deref()
1186 && previous_phase != Some(phase)
1187 {
1188 let heading = multi.add(ProgressBar::new(0));
1189 heading.set_style(heading_style.clone());
1190 heading.set_prefix(format!("── {phase} ──"));
1191 headings.push(heading);
1192 previous_phase = Some(phase);
1193 }
1194 let bar = multi.add(ProgressBar::new_spinner());
1195 bar.set_prefix(slot.identity.label().to_owned());
1196 bar.set_style(idle_style.clone());
1197 bar.set_message(terminal_status(TaskStatus::Pending));
1198 bars.push(bar);
1199 }
1200
1201 for bar in &bars {
1205 bar.force_draw();
1206 }
1207 for heading in &headings {
1208 heading.force_draw();
1209 }
1210
1211 Self {
1212 multi,
1213 headings,
1214 bars,
1215 started: vec![false; slots.len()],
1216 idle_style,
1217 activity_style,
1218 known_style,
1219 unknown_style,
1220 }
1221 }
1222
1223 fn refresh(&mut self, slots: &[Arc<ProgressSlot>]) {
1224 let summary = summarize(slots);
1225 let status = if summary.failed > 0 {
1226 "failed"
1227 } else if summary.running > 0 {
1228 "running"
1229 } else if summary.pending > 0 {
1230 "pending"
1231 } else {
1232 "completed"
1233 };
1234 for heading in &self.headings {
1235 heading.set_message(format!(
1236 "{status} · running={} pending={} completed={} reused={} failed={}",
1237 summary.running, summary.pending, summary.completed, summary.reused, summary.failed,
1238 ));
1239 }
1240 for ((bar, started), slot) in self.bars.iter().zip(&mut self.started).zip(slots) {
1241 let status = TaskStatus::decode(slot.status.load(Ordering::Acquire));
1242 if !*started && slot.started.load(Ordering::Acquire) {
1243 bar.reset_elapsed();
1246 *started = true;
1247 }
1248 if slot.display_kind == TaskDisplayKind::Activity {
1249 bar.set_style(if *started {
1250 self.activity_style.clone()
1251 } else {
1252 self.idle_style.clone()
1253 });
1254 } else if *started {
1255 if !slot.target_known.load(Ordering::Acquire) {
1256 bar.set_style(self.unknown_style.clone());
1257 } else {
1258 let target = slot.target.load(Ordering::Relaxed);
1259 bar.set_style(self.known_style.clone());
1260 bar.set_length(target);
1261 }
1262 bar.set_position(slot.current.load(Ordering::Relaxed));
1263 } else {
1264 bar.set_style(self.idle_style.clone());
1267 }
1268 let detail = lock(&slot.detail);
1269 if detail.is_empty() || detail.as_ref() == status.label() {
1270 bar.set_message(terminal_status(status));
1271 } else {
1272 bar.set_message(format!("{}: {}", terminal_status(status), detail.as_ref()));
1273 }
1274 if *started && status == TaskStatus::Running {
1275 bar.tick();
1276 }
1277 }
1278 }
1279
1280 fn finish(&self, slots: &[Arc<ProgressSlot>]) {
1281 for (bar, slot) in self.bars.iter().zip(slots) {
1282 let status = TaskStatus::decode(slot.status.load(Ordering::Acquire));
1283 bar.finish_with_message(terminal_status(status));
1284 }
1285 for heading in &self.headings {
1286 heading.finish();
1287 }
1288 let _ = self.multi.clear();
1289 }
1290}
1291
1292fn write_message(output: OutputMode, terminal: Option<&TerminalDisplay>, message: &str) {
1293 match output {
1294 OutputMode::Terminal => {
1295 if let Some(display) = terminal {
1296 let _ = display.multi.println(message);
1297 }
1298 }
1299 OutputMode::Plain => eprintln!("[progress] {message}"),
1300 OutputMode::Hidden | OutputMode::Auto => {}
1301 }
1302}
1303
1304fn write_plain_transitions(slots: &[Arc<ProgressSlot>], previous: &mut [TaskStatus]) {
1305 for (slot, old) in slots.iter().zip(previous) {
1306 let status = TaskStatus::decode(slot.status.load(Ordering::Acquire));
1307 if status != *old {
1308 let detail = lock(&slot.detail);
1309 eprintln!(
1310 "[task] identity={} status={} detail={} iteration={} target={}",
1311 slot.identity.task_key(),
1312 status.label(),
1313 detail.as_ref(),
1314 slot.current.load(Ordering::Relaxed),
1315 format_target(slot)
1316 );
1317 *old = status;
1318 }
1319 }
1320}
1321
1322fn write_final(output: OutputMode, slots: &[Arc<ProgressSlot>], success: bool, message: &str) {
1323 if output == OutputMode::Hidden || (output == OutputMode::Terminal && success) {
1324 return;
1325 }
1326 let summary = summarize(slots);
1327 if !success {
1328 for slot in slots {
1329 let detail = lock(&slot.detail);
1330 eprintln!(
1331 "[task-final] task={} status={} detail={}",
1332 slot.identity.task_key(),
1333 TaskStatus::decode(slot.status.load(Ordering::Acquire)).label(),
1334 detail.as_ref(),
1335 );
1336 }
1337 }
1338 eprintln!(
1339 "[workflow] status={} tasks={} completed={} reused={} failed={} cancelled={} skipped={} pending={} message={}",
1340 if success { "completed" } else { "failed" },
1341 summary.total,
1342 summary.completed,
1343 summary.reused,
1344 summary.failed,
1345 summary.cancelled,
1346 summary.skipped,
1347 summary.pending,
1348 message
1349 );
1350}
1351
1352fn format_target(slot: &ProgressSlot) -> String {
1353 if slot.target_known.load(Ordering::Acquire) {
1354 slot.target.load(Ordering::Relaxed).to_string()
1355 } else {
1356 "unknown".to_owned()
1357 }
1358}
1359
1360fn terminal_status(status: TaskStatus) -> String {
1361 let style = match status {
1362 TaskStatus::Pending => console::Style::new().dim(),
1363 TaskStatus::Running => console::Style::new().cyan(),
1364 TaskStatus::Completed => console::Style::new().green(),
1365 TaskStatus::Reused => console::Style::new().blue(),
1366 TaskStatus::Failed => console::Style::new().red(),
1367 TaskStatus::Cancelled => console::Style::new().yellow(),
1368 TaskStatus::Skipped => console::Style::new().dim(),
1369 }
1370 .force_styling(true);
1371 style.apply_to(status.label()).to_string()
1372}
1373
1374fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
1375 mutex
1376 .lock()
1377 .unwrap_or_else(std::sync::PoisonError::into_inner)
1378}