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 request_cancellation(&self) {
324 self.inner.cancelled.store(true, Ordering::Release);
325 }
326
327 pub(crate) fn is_cancelled(&self) -> bool {
328 self.inner.cancelled.load(Ordering::Acquire)
329 }
330
331 pub fn summary(&self) -> ProgressSummary {
333 summarize(&self.inner.slots)
334 }
335
336 pub fn complete(
341 mut self,
342 message: impl Into<String>,
343 ) -> Result<ProgressSummary, ReportingError> {
344 let summary = self.summary();
345 if !summary.is_success() {
346 self.stop(false, "workflow did not complete".to_owned())?;
347 return Err(ReportingError::IncompleteProgress {
348 pending: summary.pending,
349 running: summary.running,
350 failed: summary.failed,
351 });
352 }
353 self.stop(true, message.into())?;
354 Ok(summary)
355 }
356
357 pub fn fail(mut self, message: impl Into<String>) -> Result<ProgressSummary, ReportingError> {
359 let summary = self.summary();
360 self.stop(false, message.into())?;
361 Ok(summary)
362 }
363
364 fn stop(&mut self, success: bool, message: String) -> Result<(), ReportingError> {
365 self.inner
366 .events
367 .send(RenderEvent::Stop { success, message })
368 .map_err(|_| ReportingError::RendererUnavailable)?;
369 self.finished = true;
370 if self
371 .renderer
372 .take()
373 .expect("an unfinished reporter owns one renderer")
374 .join()
375 .is_err()
376 {
377 return Err(ReportingError::RendererPanicked);
378 }
379 Ok(())
380 }
381}
382
383impl fmt::Debug for RuntimeReporter {
384 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
385 formatter
386 .debug_struct("RuntimeReporter")
387 .field("tasks", &self.inner.slots.len())
388 .field("summary", &self.summary())
389 .finish_non_exhaustive()
390 }
391}
392
393impl Drop for RuntimeReporter {
394 fn drop(&mut self) {
395 if self.finished {
396 return;
397 }
398 let _ = self.inner.events.send(RenderEvent::Stop {
399 success: false,
400 message: "progress reporter dropped before completion".to_owned(),
401 });
402 if let Some(renderer) = self.renderer.take() {
403 let _ = renderer.join();
404 }
405 }
406}
407
408pub struct TaskProgress {
414 slot: Arc<ProgressSlot>,
415 events: SyncSender<RenderEvent>,
416 cancelled: Arc<AtomicBool>,
417 active: bool,
418}
419
420#[derive(Clone, Debug)]
422pub struct CancellationToken(Arc<AtomicBool>);
423
424impl CancellationToken {
425 pub(crate) fn new() -> Self {
426 Self(Arc::new(AtomicBool::new(false)))
427 }
428
429 pub(crate) fn shared(&self) -> Arc<AtomicBool> {
430 Arc::clone(&self.0)
431 }
432
433 pub fn is_cancelled(&self) -> bool {
435 self.0.load(Ordering::Acquire)
436 }
437
438 pub fn cancel(&self) {
440 self.0.store(true, Ordering::Release);
441 }
442}
443
444impl TaskProgress {
445 pub fn is_cancelled(&self) -> bool {
447 self.cancelled.load(Ordering::Acquire)
448 }
449
450 pub fn identity(&self) -> &TaskIdentity {
452 &self.slot.identity
453 }
454
455 pub fn current_iteration(&self) -> u64 {
457 self.slot.current.load(Ordering::Relaxed)
458 }
459
460 pub fn target_iteration(&self) -> Option<u64> {
462 if self.slot.target_known.load(Ordering::Acquire) {
463 Some(self.slot.target.load(Ordering::Relaxed))
464 } else {
465 None
466 }
467 }
468
469 pub fn set_target_iteration(&self, target: u64) -> Result<(), ReportingError> {
471 let current = self.current_iteration();
472 if current > target {
473 return Err(ReportingError::InitialIterationBeyondTarget {
474 identity: self.identity().label().to_owned(),
475 initial: current,
476 target,
477 });
478 }
479 self.slot.target.store(target, Ordering::Relaxed);
480 self.slot.target_known.store(true, Ordering::Release);
481 Ok(())
482 }
483
484 pub fn status(&self) -> TaskStatus {
486 TaskStatus::decode(self.slot.status.load(Ordering::Acquire))
487 }
488
489 pub fn set_iteration(&self, iteration: u64) -> Result<(), ReportingError> {
494 if let Some(target) = self.target_iteration().filter(|target| iteration > *target) {
495 return Err(ReportingError::IterationBeyondTarget {
496 identity: self.identity().label().to_owned(),
497 iteration,
498 target,
499 });
500 }
501 let previous = self.slot.current.fetch_max(iteration, Ordering::Relaxed);
502 if iteration < previous {
503 return Err(ReportingError::IterationRegressed {
504 identity: self.identity().label().to_owned(),
505 current: previous,
506 attempted: iteration,
507 });
508 }
509 Ok(())
510 }
511
512 pub fn should_continue(&self, iteration: u64) -> Result<bool, ReportingError> {
519 self.set_iteration(iteration)?;
520 Ok(!self.is_cancelled()
521 && self
522 .target_iteration()
523 .is_none_or(|target| iteration < target))
524 }
525
526 pub fn set_detail(&self, detail: impl Into<String>) {
529 *lock(&self.slot.detail) = detail.into().into_boxed_str();
530 }
531
532 pub fn report(&self, message: impl Into<String>) -> Result<(), ReportingError> {
534 self.events
535 .send(RenderEvent::TaskMessage {
536 identity: self.identity().label().to_owned(),
537 message: message.into(),
538 })
539 .map_err(|_| ReportingError::RendererUnavailable)
540 }
541
542 pub fn complete(mut self, reason: Option<String>) -> Result<(), ReportingError> {
548 if reason.is_none()
549 && let Some(target) = self.target_iteration()
550 {
551 let current = self.current_iteration();
552 if current != target {
553 *lock(&self.slot.detail) = "target not reached".into();
554 self.slot
555 .status
556 .store(TaskStatus::Failed.encode(), Ordering::Release);
557 self.active = false;
558 return Err(ReportingError::TargetIterationNotReached {
559 identity: self.identity().label().to_owned(),
560 current,
561 target,
562 });
563 }
564 }
565 *lock(&self.slot.detail) = reason
566 .unwrap_or_else(|| "completed".to_owned())
567 .into_boxed_str();
568 self.slot
569 .status
570 .store(TaskStatus::Completed.encode(), Ordering::Release);
571 self.active = false;
572 Ok(())
573 }
574
575 pub fn fail(mut self, reason: impl Into<String>) {
577 *lock(&self.slot.detail) = reason.into().into_boxed_str();
578 self.slot
579 .status
580 .store(TaskStatus::Failed.encode(), Ordering::Release);
581 self.active = false;
582 }
583
584 pub(crate) fn cancel(mut self, reason: impl Into<String>) {
585 *lock(&self.slot.detail) = reason.into().into_boxed_str();
586 self.slot
587 .status
588 .store(TaskStatus::Cancelled.encode(), Ordering::Release);
589 self.active = false;
590 }
591}
592
593impl fmt::Debug for TaskProgress {
594 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
595 formatter
596 .debug_struct("TaskProgress")
597 .field("identity", &self.identity().label())
598 .field("current_iteration", &self.current_iteration())
599 .field("target_iteration", &self.target_iteration())
600 .field("active", &self.active)
601 .finish_non_exhaustive()
602 }
603}
604
605impl Drop for TaskProgress {
606 fn drop(&mut self) {
607 if self.active {
608 *lock(&self.slot.detail) = "interrupted".into();
609 self.slot
610 .status
611 .store(TaskStatus::Failed.encode(), Ordering::Release);
612 }
613 }
614}
615
616pub struct ActivityTask {
621 slot: Arc<ProgressSlot>,
622 events: SyncSender<RenderEvent>,
623 cancelled: Arc<AtomicBool>,
624 active: bool,
625}
626
627impl ActivityTask {
628 pub fn is_cancelled(&self) -> bool {
630 self.cancelled.load(Ordering::Acquire)
631 }
632
633 pub fn identity(&self) -> &TaskIdentity {
635 &self.slot.identity
636 }
637
638 pub fn status(&self) -> TaskStatus {
640 TaskStatus::decode(self.slot.status.load(Ordering::Acquire))
641 }
642
643 pub fn set_detail(&self, detail: impl Into<String>) {
645 *lock(&self.slot.detail) = detail.into().into_boxed_str();
646 }
647
648 pub fn report(&self, message: impl Into<String>) -> Result<(), ReportingError> {
650 self.events
651 .send(RenderEvent::TaskMessage {
652 identity: self.identity().label().to_owned(),
653 message: message.into(),
654 })
655 .map_err(|_| ReportingError::RendererUnavailable)
656 }
657
658 pub fn complete(mut self) {
660 *lock(&self.slot.detail) = "completed".into();
661 self.slot
662 .status
663 .store(TaskStatus::Completed.encode(), Ordering::Release);
664 self.active = false;
665 }
666
667 pub fn fail(mut self, reason: impl Into<String>) {
669 *lock(&self.slot.detail) = reason.into().into_boxed_str();
670 self.slot
671 .status
672 .store(TaskStatus::Failed.encode(), Ordering::Release);
673 self.active = false;
674 }
675
676 pub(crate) fn cancel(mut self, reason: impl Into<String>) {
677 *lock(&self.slot.detail) = reason.into().into_boxed_str();
678 self.slot
679 .status
680 .store(TaskStatus::Cancelled.encode(), Ordering::Release);
681 self.active = false;
682 }
683}
684
685impl fmt::Debug for ActivityTask {
686 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
687 formatter
688 .debug_struct("ActivityTask")
689 .field("identity", &self.identity().label())
690 .field("status", &self.status())
691 .field("active", &self.active)
692 .finish_non_exhaustive()
693 }
694}
695
696impl Drop for ActivityTask {
697 fn drop(&mut self) {
698 if self.active {
699 *lock(&self.slot.detail) = "interrupted".into();
700 self.slot
701 .status
702 .store(TaskStatus::Failed.encode(), Ordering::Release);
703 }
704 }
705}
706
707struct ReporterInner {
708 slots: Arc<[Arc<ProgressSlot>]>,
709 events: SyncSender<RenderEvent>,
710 cancelled: Arc<AtomicBool>,
711}
712
713struct ProgressSlot {
714 identity: TaskIdentity,
715 phase_label: Option<Arc<str>>,
716 display_kind: TaskDisplayKind,
717 current: AtomicU64,
718 target: AtomicU64,
719 target_known: AtomicBool,
720 started: AtomicBool,
721 status: AtomicU8,
722 detail: Mutex<Box<str>>,
723}
724
725enum RenderEvent {
726 TaskMessage { identity: String, message: String },
727 Stop { success: bool, message: String },
728}
729
730struct TerminalLease;
731
732impl Drop for TerminalLease {
733 fn drop(&mut self) {
734 TERMINAL_OWNED.store(false, Ordering::Release);
735 }
736}
737
738fn acquire_terminal() -> Result<(), ReportingError> {
739 TERMINAL_OWNED
740 .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
741 .map(|_| ())
742 .map_err(|_| ReportingError::TerminalAlreadyOwned)
743}
744
745fn resolve_output(output: OutputMode) -> OutputMode {
746 match output {
747 OutputMode::Auto if io::stderr().is_terminal() && io::stdin().is_terminal() => {
748 OutputMode::Terminal
749 }
750 OutputMode::Auto => OutputMode::Plain,
751 explicit => explicit,
752 }
753}
754
755fn start_reporter(
756 slots: Arc<[Arc<ProgressSlot>]>,
757 requested_output: OutputMode,
758 cancellation: Option<CancellationToken>,
759) -> Result<RuntimeReporter, ReportingError> {
760 acquire_terminal()?;
761 let lease = TerminalLease;
762 let cancelled = cancellation
763 .map(|token| token.shared())
764 .unwrap_or_else(|| Arc::new(AtomicBool::new(false)));
765 let mut output = resolve_output(requested_output);
766 let terminal = if output == OutputMode::Terminal {
767 match TerminalSession::enter(Arc::clone(&cancelled)) {
768 Ok(terminal) => Some(terminal),
769 Err(_) if requested_output == OutputMode::Auto => {
770 output = OutputMode::Plain;
771 None
772 }
773 Err(error) => return Err(error),
774 }
775 } else {
776 None
777 };
778 let (events, receiver) = mpsc::sync_channel(MESSAGE_CAPACITY);
779 let renderer_slots = Arc::clone(&slots);
780 let renderer = match thread::Builder::new()
781 .name("scientific-workflow-progress".to_owned())
782 .spawn(move || render(receiver, renderer_slots, output, terminal, lease))
783 {
784 Ok(renderer) => renderer,
785 Err(source) => return Err(ReportingError::StartRenderer { source }),
786 };
787 Ok(RuntimeReporter {
788 inner: Arc::new(ReporterInner {
789 slots,
790 events,
791 cancelled,
792 }),
793 renderer: Some(renderer),
794 finished: false,
795 })
796}
797
798fn managed_slot(
799 slots: &[Arc<ProgressSlot>],
800 key: &TaskKey,
801) -> Result<Arc<ProgressSlot>, ReportingError> {
802 slots
803 .iter()
804 .find(|slot| slot.identity.task_key() == key)
805 .cloned()
806 .ok_or_else(|| ReportingError::UnknownManagedTask {
807 task: key.to_string(),
808 })
809}
810
811fn kind_mismatch(slot: &ProgressSlot, requested: &'static str) -> ReportingError {
812 ReportingError::ManagedTaskKindMismatch {
813 task: slot.identity.task_key().to_string(),
814 requested,
815 actual: match slot.display_kind {
816 TaskDisplayKind::Progress => "progress",
817 TaskDisplayKind::Activity => "activity",
818 },
819 }
820}
821
822fn mark_slot_reused(slot: &ProgressSlot) -> Result<(), ReportingError> {
823 slot.status
824 .compare_exchange(
825 TaskStatus::Pending.encode(),
826 TaskStatus::Reused.encode(),
827 Ordering::AcqRel,
828 Ordering::Acquire,
829 )
830 .map_err(|_| ReportingError::TaskAlreadyStarted {
831 identity: slot.identity.label().to_owned(),
832 })?;
833 *lock(&slot.detail) = "reused".into();
834 Ok(())
835}
836
837fn mark_pending_terminal(
838 slot: &ProgressSlot,
839 status: TaskStatus,
840 detail: &'static str,
841) -> Result<(), ReportingError> {
842 slot.status
843 .compare_exchange(
844 TaskStatus::Pending.encode(),
845 status.encode(),
846 Ordering::AcqRel,
847 Ordering::Acquire,
848 )
849 .map_err(|_| ReportingError::TaskAlreadyStarted {
850 identity: slot.identity.label().to_owned(),
851 })?;
852 *lock(&slot.detail) = detail.into();
853 Ok(())
854}
855
856fn start_slot(
857 inner: &ReporterInner,
858 slot: Arc<ProgressSlot>,
859 initial_iteration: u64,
860 target_iteration: Option<u64>,
861) -> Result<TaskProgress, ReportingError> {
862 if let Some(target) = target_iteration.filter(|target| initial_iteration > *target) {
863 return Err(ReportingError::InitialIterationBeyondTarget {
864 identity: slot.identity.label().to_owned(),
865 initial: initial_iteration,
866 target,
867 });
868 }
869 slot.status
870 .compare_exchange(
871 TaskStatus::Pending.encode(),
872 TaskStatus::Running.encode(),
873 Ordering::AcqRel,
874 Ordering::Acquire,
875 )
876 .map_err(|_| ReportingError::TaskAlreadyStarted {
877 identity: slot.identity.label().to_owned(),
878 })?;
879 slot.started.store(true, Ordering::Release);
880 slot.current.store(initial_iteration, Ordering::Relaxed);
881 if let Some(target) = target_iteration {
882 slot.target.store(target, Ordering::Relaxed);
883 slot.target_known.store(true, Ordering::Release);
884 } else {
885 slot.target_known.store(false, Ordering::Release);
886 }
887 *lock(&slot.detail) = "running".into();
888 Ok(TaskProgress {
889 slot,
890 events: inner.events.clone(),
891 cancelled: Arc::clone(&inner.cancelled),
892 active: true,
893 })
894}
895
896fn start_activity_slot(
897 inner: &ReporterInner,
898 slot: Arc<ProgressSlot>,
899) -> Result<ActivityTask, ReportingError> {
900 slot.status
901 .compare_exchange(
902 TaskStatus::Pending.encode(),
903 TaskStatus::Running.encode(),
904 Ordering::AcqRel,
905 Ordering::Acquire,
906 )
907 .map_err(|_| ReportingError::TaskAlreadyStarted {
908 identity: slot.identity.label().to_owned(),
909 })?;
910 slot.started.store(true, Ordering::Release);
911 slot.target_known.store(false, Ordering::Release);
912 *lock(&slot.detail) = "running".into();
913 Ok(ActivityTask {
914 slot,
915 events: inner.events.clone(),
916 cancelled: Arc::clone(&inner.cancelled),
917 active: true,
918 })
919}
920
921fn build_phase_slots(
922 phases: &[Phase],
923 heading: Option<&str>,
924) -> Result<Arc<[Arc<ProgressSlot>]>, ReportingError> {
925 if phases.is_empty() {
926 return Err(ReportingError::EmptyPhaseSet);
927 }
928 let mut phase_ids = HashSet::with_capacity(phases.len());
929 let capacity = phases.iter().map(|phase| phase.tasks().len()).sum();
930 let mut slots = Vec::with_capacity(capacity);
931 for phase in phases {
932 if !phase_ids.insert(phase.id()) {
933 return Err(ReportingError::DuplicatePhaseId {
934 phase: phase.id().get(),
935 });
936 }
937 let phase_label: Arc<str> = heading.unwrap_or_else(|| phase.label()).into();
938 for task in phase.tasks() {
939 slots.push(Arc::new(ProgressSlot {
940 identity: TaskIdentity {
941 label: task.label().into(),
942 key: task.key().clone(),
943 configuration: task.configuration().clone(),
944 },
945 phase_label: Some(Arc::clone(&phase_label)),
946 display_kind: task.display_kind(),
947 current: AtomicU64::new(0),
948 target: AtomicU64::new(0),
949 target_known: AtomicBool::new(false),
950 started: AtomicBool::new(false),
951 status: AtomicU8::new(TaskStatus::Pending.encode()),
952 detail: Mutex::new("pending".into()),
953 }));
954 }
955 }
956 Ok(slots.into())
957}
958
959fn summarize(slots: &[Arc<ProgressSlot>]) -> ProgressSummary {
960 let mut summary = ProgressSummary {
961 total: u64::try_from(slots.len()).expect("slot count originated from a u64 task count"),
962 pending: 0,
963 running: 0,
964 completed: 0,
965 reused: 0,
966 failed: 0,
967 cancelled: 0,
968 skipped: 0,
969 };
970 for slot in slots {
971 match TaskStatus::decode(slot.status.load(Ordering::Acquire)) {
972 TaskStatus::Pending => summary.pending += 1,
973 TaskStatus::Running => summary.running += 1,
974 TaskStatus::Completed => summary.completed += 1,
975 TaskStatus::Reused => summary.reused += 1,
976 TaskStatus::Failed => summary.failed += 1,
977 TaskStatus::Cancelled => summary.cancelled += 1,
978 TaskStatus::Skipped => summary.skipped += 1,
979 }
980 }
981 summary
982}
983
984struct TerminalSession {
985 stop: Arc<AtomicBool>,
986 input: Option<JoinHandle<()>>,
987}
988
989impl TerminalSession {
990 fn enter(cancelled: Arc<AtomicBool>) -> Result<Self, ReportingError> {
991 enable_raw_mode().map_err(|source| ReportingError::TerminalSetup {
992 operation: "enable raw input mode",
993 source,
994 })?;
995 let mut stderr = io::stderr();
996 if let Err(source) = execute!(
997 stderr,
998 EnterAlternateScreen,
999 Clear(ClearType::All),
1000 MoveTo(0, 0),
1001 Hide,
1002 EnableMouseCapture
1003 ) {
1004 let _ = execute!(stderr, DisableMouseCapture, Show, LeaveAlternateScreen);
1005 let _ = disable_raw_mode();
1006 return Err(ReportingError::TerminalSetup {
1007 operation: "enter the isolated terminal screen",
1008 source,
1009 });
1010 }
1011 let stop = Arc::new(AtomicBool::new(false));
1012 let input_stop = Arc::clone(&stop);
1013 let input = match thread::Builder::new()
1014 .name("scientific-workflow-input".to_owned())
1015 .spawn(move || drain_terminal_input(input_stop, cancelled))
1016 {
1017 Ok(input) => input,
1018 Err(source) => {
1019 let _ = execute!(stderr, DisableMouseCapture, Show, LeaveAlternateScreen);
1020 let _ = disable_raw_mode();
1021 return Err(ReportingError::TerminalSetup {
1022 operation: "start the isolated-screen input drain",
1023 source,
1024 });
1025 }
1026 };
1027 Ok(Self {
1028 stop,
1029 input: Some(input),
1030 })
1031 }
1032}
1033
1034impl Drop for TerminalSession {
1035 fn drop(&mut self) {
1036 self.stop.store(true, Ordering::Release);
1037 if let Some(input) = self.input.take() {
1038 let _ = input.join();
1039 }
1040 let mut stderr = io::stderr();
1041 let _ = execute!(stderr, DisableMouseCapture, Show, LeaveAlternateScreen);
1042 let _ = stderr.flush();
1043 let _ = disable_raw_mode();
1044 }
1045}
1046
1047fn drain_terminal_input(stop: Arc<AtomicBool>, cancelled: Arc<AtomicBool>) {
1048 while !stop.load(Ordering::Acquire) {
1049 match event::poll(Duration::from_millis(50)) {
1050 Ok(true) => match event::read() {
1051 Ok(Event::Key(key))
1052 if key.kind == KeyEventKind::Press
1053 && key.code == KeyCode::Char('c')
1054 && key.modifiers.contains(KeyModifiers::CONTROL) =>
1055 {
1056 cancelled.store(true, Ordering::Release);
1057 }
1058 Ok(_) => {}
1059 Err(_) => {
1060 cancelled.store(true, Ordering::Release);
1061 break;
1062 }
1063 },
1064 Ok(false) => {}
1065 Err(_) => {
1066 cancelled.store(true, Ordering::Release);
1067 break;
1068 }
1069 }
1070 }
1071}
1072
1073fn render(
1074 receiver: Receiver<RenderEvent>,
1075 slots: Arc<[Arc<ProgressSlot>]>,
1076 output: OutputMode,
1077 mut terminal_session: Option<TerminalSession>,
1078 _lease: TerminalLease,
1079) {
1080 let mut terminal = (output == OutputMode::Terminal).then(|| TerminalDisplay::new(&slots));
1081 let mut last_statuses = vec![TaskStatus::Pending; slots.len()];
1082 loop {
1083 if let Some(display) = &mut terminal {
1084 display.refresh(&slots);
1085 }
1086 match receiver.recv_timeout(REFRESH_INTERVAL) {
1087 Ok(RenderEvent::TaskMessage { identity, message }) => {
1088 write_message(output, terminal.as_ref(), &format!("{identity}: {message}"));
1089 }
1090 Ok(RenderEvent::Stop { success, message }) => {
1091 if let Some(display) = &mut terminal {
1092 display.refresh(&slots);
1093 display.finish(&slots);
1094 }
1095 if output == OutputMode::Plain {
1096 write_plain_transitions(&slots, &mut last_statuses);
1097 }
1098 drop(terminal.take());
1099 drop(terminal_session.take());
1100 write_final(output, &slots, success, &message);
1101 break;
1102 }
1103 Err(RecvTimeoutError::Timeout) => {
1104 if output == OutputMode::Plain {
1105 write_plain_transitions(&slots, &mut last_statuses);
1106 }
1107 }
1108 Err(RecvTimeoutError::Disconnected) => break,
1109 }
1110 }
1111}
1112
1113struct TerminalDisplay {
1114 multi: MultiProgress,
1115 headings: Vec<ProgressBar>,
1116 bars: Vec<ProgressBar>,
1117 started: Vec<bool>,
1118 idle_style: ProgressStyle,
1119 activity_style: ProgressStyle,
1120 known_style: ProgressStyle,
1121 unknown_style: ProgressStyle,
1122}
1123
1124impl TerminalDisplay {
1125 fn new(slots: &[Arc<ProgressSlot>]) -> Self {
1126 let multi = MultiProgress::with_draw_target(ProgressDrawTarget::stderr());
1127 let idle_style = ProgressStyle::with_template("{prefix:.bold} [{msg}]")
1128 .expect("hard-coded idle progress template is valid");
1129 let activity_style =
1130 ProgressStyle::with_template("{prefix:.bold} [{msg}] elapsed {elapsed_precise}")
1131 .expect("hard-coded activity template is valid");
1132 let known_style = ProgressStyle::with_template(
1133 "{prefix:.bold} [{msg}] {wide_bar:.cyan/blue} {pos}/{len} elapsed {elapsed_precise} ETA {eta_precise}",
1134 )
1135 .expect("hard-coded progress template is valid");
1136 let unknown_style = ProgressStyle::with_template(
1137 "{prefix:.bold} [{msg}] {spinner:.cyan} iteration {pos} elapsed {elapsed_precise} ETA unknown",
1138 )
1139 .expect("hard-coded spinner template is valid");
1140 let heading_style =
1141 ProgressStyle::with_template("{prefix:.bold} [{msg}] elapsed {elapsed_precise}")
1142 .expect("hard-coded phase-heading template is valid");
1143 let mut headings = Vec::new();
1144 let mut bars = Vec::with_capacity(slots.len());
1145 let mut previous_phase: Option<&str> = None;
1146 for slot in slots {
1147 if let Some(phase) = slot.phase_label.as_deref()
1148 && previous_phase != Some(phase)
1149 {
1150 let heading = multi.add(ProgressBar::new(0));
1151 heading.set_style(heading_style.clone());
1152 heading.set_prefix(format!("── {phase} ──"));
1153 headings.push(heading);
1154 previous_phase = Some(phase);
1155 }
1156 let bar = multi.add(ProgressBar::new_spinner());
1157 bar.set_prefix(slot.identity.label().to_owned());
1158 bar.set_style(idle_style.clone());
1159 bar.set_message(terminal_status(TaskStatus::Pending));
1160 bars.push(bar);
1161 }
1162
1163 for bar in &bars {
1167 bar.force_draw();
1168 }
1169 for heading in &headings {
1170 heading.force_draw();
1171 }
1172
1173 Self {
1174 multi,
1175 headings,
1176 bars,
1177 started: vec![false; slots.len()],
1178 idle_style,
1179 activity_style,
1180 known_style,
1181 unknown_style,
1182 }
1183 }
1184
1185 fn refresh(&mut self, slots: &[Arc<ProgressSlot>]) {
1186 let summary = summarize(slots);
1187 let status = if summary.failed > 0 {
1188 "failed"
1189 } else if summary.running > 0 {
1190 "running"
1191 } else if summary.pending > 0 {
1192 "pending"
1193 } else {
1194 "completed"
1195 };
1196 for heading in &self.headings {
1197 heading.set_message(format!(
1198 "{status} · running={} pending={} completed={} reused={} failed={}",
1199 summary.running, summary.pending, summary.completed, summary.reused, summary.failed,
1200 ));
1201 }
1202 for ((bar, started), slot) in self.bars.iter().zip(&mut self.started).zip(slots) {
1203 let status = TaskStatus::decode(slot.status.load(Ordering::Acquire));
1204 if !*started && slot.started.load(Ordering::Acquire) {
1205 bar.reset_elapsed();
1208 *started = true;
1209 }
1210 if slot.display_kind == TaskDisplayKind::Activity {
1211 bar.set_style(if *started {
1212 self.activity_style.clone()
1213 } else {
1214 self.idle_style.clone()
1215 });
1216 } else if *started {
1217 if !slot.target_known.load(Ordering::Acquire) {
1218 bar.set_style(self.unknown_style.clone());
1219 } else {
1220 let target = slot.target.load(Ordering::Relaxed);
1221 bar.set_style(self.known_style.clone());
1222 bar.set_length(target);
1223 }
1224 bar.set_position(slot.current.load(Ordering::Relaxed));
1225 } else {
1226 bar.set_style(self.idle_style.clone());
1229 }
1230 let detail = lock(&slot.detail);
1231 if detail.is_empty() || detail.as_ref() == status.label() {
1232 bar.set_message(terminal_status(status));
1233 } else {
1234 bar.set_message(format!("{}: {}", terminal_status(status), detail.as_ref()));
1235 }
1236 if *started && status == TaskStatus::Running {
1237 bar.tick();
1238 }
1239 }
1240 }
1241
1242 fn finish(&self, slots: &[Arc<ProgressSlot>]) {
1243 for (bar, slot) in self.bars.iter().zip(slots) {
1244 let status = TaskStatus::decode(slot.status.load(Ordering::Acquire));
1245 bar.finish_with_message(terminal_status(status));
1246 }
1247 for heading in &self.headings {
1248 heading.finish();
1249 }
1250 let _ = self.multi.clear();
1251 }
1252}
1253
1254fn write_message(output: OutputMode, terminal: Option<&TerminalDisplay>, message: &str) {
1255 match output {
1256 OutputMode::Terminal => {
1257 if let Some(display) = terminal {
1258 let _ = display.multi.println(message);
1259 }
1260 }
1261 OutputMode::Plain => eprintln!("[progress] {message}"),
1262 OutputMode::Hidden | OutputMode::Auto => {}
1263 }
1264}
1265
1266fn write_plain_transitions(slots: &[Arc<ProgressSlot>], previous: &mut [TaskStatus]) {
1267 for (slot, old) in slots.iter().zip(previous) {
1268 let status = TaskStatus::decode(slot.status.load(Ordering::Acquire));
1269 if status != *old {
1270 let detail = lock(&slot.detail);
1271 eprintln!(
1272 "[task] identity={} status={} detail={} iteration={} target={}",
1273 slot.identity.task_key(),
1274 status.label(),
1275 detail.as_ref(),
1276 slot.current.load(Ordering::Relaxed),
1277 format_target(slot)
1278 );
1279 *old = status;
1280 }
1281 }
1282}
1283
1284fn write_final(output: OutputMode, slots: &[Arc<ProgressSlot>], success: bool, message: &str) {
1285 if output == OutputMode::Hidden || (output == OutputMode::Terminal && success) {
1286 return;
1287 }
1288 let summary = summarize(slots);
1289 if !success {
1290 for slot in slots {
1291 let detail = lock(&slot.detail);
1292 eprintln!(
1293 "[task-final] task={} status={} detail={}",
1294 slot.identity.task_key(),
1295 TaskStatus::decode(slot.status.load(Ordering::Acquire)).label(),
1296 detail.as_ref(),
1297 );
1298 }
1299 }
1300 eprintln!(
1301 "[workflow] status={} tasks={} completed={} reused={} failed={} cancelled={} skipped={} pending={} message={}",
1302 if success { "completed" } else { "failed" },
1303 summary.total,
1304 summary.completed,
1305 summary.reused,
1306 summary.failed,
1307 summary.cancelled,
1308 summary.skipped,
1309 summary.pending,
1310 message
1311 );
1312}
1313
1314fn format_target(slot: &ProgressSlot) -> String {
1315 if slot.target_known.load(Ordering::Acquire) {
1316 slot.target.load(Ordering::Relaxed).to_string()
1317 } else {
1318 "unknown".to_owned()
1319 }
1320}
1321
1322fn terminal_status(status: TaskStatus) -> String {
1323 let style = match status {
1324 TaskStatus::Pending => console::Style::new().dim(),
1325 TaskStatus::Running => console::Style::new().cyan(),
1326 TaskStatus::Completed => console::Style::new().green(),
1327 TaskStatus::Reused => console::Style::new().blue(),
1328 TaskStatus::Failed => console::Style::new().red(),
1329 TaskStatus::Cancelled => console::Style::new().yellow(),
1330 TaskStatus::Skipped => console::Style::new().dim(),
1331 }
1332 .force_styling(true);
1333 style.apply_to(status.label()).to_string()
1334}
1335
1336fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
1337 mutex
1338 .lock()
1339 .unwrap_or_else(std::sync::PoisonError::into_inner)
1340}