1use std::collections::{HashMap, HashSet};
9use std::fmt;
10use std::io::{self, IsTerminal};
11use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering};
12use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender};
13use std::sync::{Arc, Mutex};
14use std::thread::{self, JoinHandle};
15use std::time::Duration;
16
17use console::Term;
18use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle};
19use serde_json::Value;
20
21use super::error::ReportingError;
22use crate::configuration::{ProjectConfig, TaskConfig};
23use crate::project::ScientificProject;
24
25const REFRESH_INTERVAL: Duration = Duration::from_millis(100);
26static TERMINAL_OWNED: AtomicBool = AtomicBool::new(false);
27
28#[derive(Clone, Copy, Debug, Eq, PartialEq)]
30#[non_exhaustive]
31pub enum TaskStatus {
32 Pending,
34 Running,
36 Completed,
38 Failed,
40}
41
42impl TaskStatus {
43 fn encode(self) -> u8 {
44 match self {
45 Self::Pending => 0,
46 Self::Running => 1,
47 Self::Completed => 2,
48 Self::Failed => 3,
49 }
50 }
51
52 fn decode(value: u8) -> Self {
53 match value {
54 0 => Self::Pending,
55 1 => Self::Running,
56 2 => Self::Completed,
57 3 => Self::Failed,
58 _ => unreachable!("task status is written only through TaskStatus::encode"),
59 }
60 }
61
62 fn label(self) -> &'static str {
63 match self {
64 Self::Pending => "pending",
65 Self::Running => "running",
66 Self::Completed => "completed",
67 Self::Failed => "failed",
68 }
69 }
70}
71
72#[derive(Clone, Debug)]
78pub struct TaskIdentity {
79 task: TaskConfig,
80 keys: Arc<[Box<str>]>,
81 label: Arc<str>,
82}
83
84impl TaskIdentity {
85 pub fn label(&self) -> &str {
87 &self.label
88 }
89
90 pub fn len(&self) -> usize {
92 self.keys.len()
93 }
94
95 pub fn is_empty(&self) -> bool {
97 self.keys.is_empty()
98 }
99
100 pub fn value(&self, key: &str) -> Option<&Value> {
102 self.keys.iter().find_map(|name| {
103 (name.as_ref() == key)
104 .then(|| self.task.value(key))
105 .flatten()
106 })
107 }
108
109 pub fn iter(&self) -> impl ExactSizeIterator<Item = (&str, &Value)> {
111 self.keys.iter().map(|name| {
112 (
113 name.as_ref(),
114 self.task
115 .value(name)
116 .expect("validated identity keys resolve for every task"),
117 )
118 })
119 }
120
121 fn matches(&self, task: &TaskConfig) -> bool {
122 self.keys
123 .iter()
124 .all(|key| task.value(key) == self.task.value(key))
125 }
126}
127
128#[derive(Clone, Debug, Eq, PartialEq)]
130pub struct ProgressSummary {
131 total: u64,
132 pending: u64,
133 running: u64,
134 completed: u64,
135 failed: u64,
136}
137
138impl ProgressSummary {
139 pub fn total(&self) -> u64 {
141 self.total
142 }
143
144 pub fn pending(&self) -> u64 {
146 self.pending
147 }
148
149 pub fn running(&self) -> u64 {
151 self.running
152 }
153
154 pub fn completed(&self) -> u64 {
156 self.completed
157 }
158
159 pub fn failed(&self) -> u64 {
161 self.failed
162 }
163
164 pub fn is_success(&self) -> bool {
166 self.completed == self.total && self.pending == 0 && self.running == 0 && self.failed == 0
167 }
168}
169
170#[derive(Clone, Copy, Debug, Eq, PartialEq)]
172enum OutputMode {
173 Auto,
174 Terminal,
175 Plain,
176 Hidden,
177}
178
179pub struct ProgressReporterBuilder {
181 configuration: ProjectConfig,
182 identity_keys: Option<Vec<String>>,
183 output: OutputMode,
184}
185
186impl ProgressReporterBuilder {
187 pub fn identify_tasks_by<I, S>(mut self, keys: I) -> Self
193 where
194 I: IntoIterator<Item = S>,
195 S: Into<String>,
196 {
197 self.identity_keys = Some(keys.into_iter().map(Into::into).collect());
198 self
199 }
200
201 pub fn terminal(mut self) -> Self {
204 self.output = OutputMode::Terminal;
205 self
206 }
207
208 pub fn plain(mut self) -> Self {
210 self.output = OutputMode::Plain;
211 self
212 }
213
214 pub fn hidden(mut self) -> Self {
219 self.output = OutputMode::Hidden;
220 self
221 }
222
223 pub fn start(self) -> Result<ProgressReporter, ReportingError> {
226 let identity_keys: Arc<[Box<str>]> =
227 validate_identity_keys(&self.configuration, self.identity_keys)?
228 .into_iter()
229 .map(String::into_boxed_str)
230 .collect();
231 let slots = build_slots(&self.configuration, Arc::clone(&identity_keys))?;
232 let output = match self.output {
233 OutputMode::Auto if io::stderr().is_terminal() => OutputMode::Terminal,
234 OutputMode::Auto => OutputMode::Plain,
235 explicit => explicit,
236 };
237
238 acquire_terminal()?;
239 let lease = TerminalLease;
240 let (events, receiver) = mpsc::channel();
241 let renderer_slots = Arc::clone(&slots);
242 let renderer = thread::Builder::new()
243 .name("scientific-workflow-progress".to_owned())
244 .spawn(move || render(receiver, renderer_slots, output, lease))
245 .map_err(|source| ReportingError::StartRenderer { source })?;
246
247 Ok(ProgressReporter {
248 inner: Arc::new(ReporterInner {
249 slots,
250 identity_keys,
251 events,
252 }),
253 renderer: Some(renderer),
254 finished: false,
255 })
256 }
257}
258
259impl fmt::Debug for ProgressReporterBuilder {
260 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
261 formatter
262 .debug_struct("ProgressReporterBuilder")
263 .field("tasks", &self.configuration.task_count())
264 .field("identity_keys", &self.identity_keys)
265 .field("output", &self.output)
266 .finish_non_exhaustive()
267 }
268}
269
270pub struct ProgressReporter {
272 inner: Arc<ReporterInner>,
273 renderer: Option<JoinHandle<()>>,
274 finished: bool,
275}
276
277impl ProgressReporter {
278 pub fn for_project(project: &ScientificProject) -> ProgressReporterBuilder {
282 Self::for_configuration(project.configuration())
283 }
284
285 pub fn for_configuration(configuration: &ProjectConfig) -> ProgressReporterBuilder {
287 ProgressReporterBuilder {
288 configuration: configuration.clone(),
289 identity_keys: None,
290 output: OutputMode::Auto,
291 }
292 }
293
294 pub fn start_task(
300 &self,
301 task: &TaskConfig,
302 initial_iteration: u64,
303 target_iteration: Option<u64>,
304 ) -> Result<TaskProgress, ReportingError> {
305 let ordinal = task.task_ordinal();
306 let index = usize::try_from(ordinal)
307 .ok()
308 .filter(|index| *index < self.inner.slots.len())
309 .ok_or(ReportingError::UnknownTaskOrdinal {
310 task_ordinal: ordinal,
311 })?;
312 let slot = Arc::clone(&self.inner.slots[index]);
313 if !slot.identity.matches(task) {
314 return Err(ReportingError::TaskIdentityMismatch {
315 task_ordinal: ordinal,
316 });
317 }
318 if let Some(target) = target_iteration.filter(|target| initial_iteration > *target) {
319 return Err(ReportingError::InitialIterationBeyondTarget {
320 identity: slot.identity.label().to_owned(),
321 initial: initial_iteration,
322 target,
323 });
324 }
325 slot.status
326 .compare_exchange(
327 TaskStatus::Pending.encode(),
328 TaskStatus::Running.encode(),
329 Ordering::AcqRel,
330 Ordering::Acquire,
331 )
332 .map_err(|_| ReportingError::TaskAlreadyStarted {
333 identity: slot.identity.label().to_owned(),
334 })?;
335 slot.current.store(initial_iteration, Ordering::Relaxed);
336 if let Some(target) = target_iteration {
337 slot.target.store(target, Ordering::Relaxed);
338 slot.target_known.store(true, Ordering::Release);
339 } else {
340 slot.target_known.store(false, Ordering::Release);
341 }
342 *lock(&slot.phase) = "running".into();
343
344 Ok(TaskProgress {
345 slot,
346 events: self.inner.events.clone(),
347 active: true,
348 })
349 }
350
351 pub fn report(&self, message: impl Into<String>) -> Result<(), ReportingError> {
353 self.inner
354 .events
355 .send(RenderEvent::Message(message.into()))
356 .map_err(|_| ReportingError::RendererUnavailable)
357 }
358
359 pub fn summary(&self) -> ProgressSummary {
361 summarize(&self.inner.slots)
362 }
363
364 pub fn complete(
369 mut self,
370 message: impl Into<String>,
371 ) -> Result<ProgressSummary, ReportingError> {
372 let summary = self.summary();
373 if !summary.is_success() {
374 self.stop(false, "workflow did not complete".to_owned())?;
375 return Err(ReportingError::IncompleteProgress {
376 pending: summary.pending,
377 running: summary.running,
378 failed: summary.failed,
379 });
380 }
381 self.stop(true, message.into())?;
382 Ok(summary)
383 }
384
385 pub fn fail(mut self, message: impl Into<String>) -> Result<ProgressSummary, ReportingError> {
387 let summary = self.summary();
388 self.stop(false, message.into())?;
389 Ok(summary)
390 }
391
392 pub fn report_error(message: impl fmt::Display) {
397 eprintln!("[error] {message}");
398 }
399
400 fn stop(&mut self, success: bool, message: String) -> Result<(), ReportingError> {
401 self.inner
402 .events
403 .send(RenderEvent::Stop { success, message })
404 .map_err(|_| ReportingError::RendererUnavailable)?;
405 self.finished = true;
406 if self
407 .renderer
408 .take()
409 .expect("an unfinished reporter owns one renderer")
410 .join()
411 .is_err()
412 {
413 return Err(ReportingError::RendererPanicked);
414 }
415 Ok(())
416 }
417}
418
419impl fmt::Debug for ProgressReporter {
420 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
421 formatter
422 .debug_struct("ProgressReporter")
423 .field("tasks", &self.inner.slots.len())
424 .field("identity_keys", &self.inner.identity_keys)
425 .field("summary", &self.summary())
426 .finish_non_exhaustive()
427 }
428}
429
430impl Drop for ProgressReporter {
431 fn drop(&mut self) {
432 if self.finished {
433 return;
434 }
435 let _ = self.inner.events.send(RenderEvent::Stop {
436 success: false,
437 message: "progress reporter dropped before completion".to_owned(),
438 });
439 if let Some(renderer) = self.renderer.take() {
440 let _ = renderer.join();
441 }
442 }
443}
444
445pub struct TaskProgress {
451 slot: Arc<ProgressSlot>,
452 events: Sender<RenderEvent>,
453 active: bool,
454}
455
456impl TaskProgress {
457 pub fn identity(&self) -> &TaskIdentity {
459 &self.slot.identity
460 }
461
462 pub fn current_iteration(&self) -> u64 {
464 self.slot.current.load(Ordering::Relaxed)
465 }
466
467 pub fn target_iteration(&self) -> Option<u64> {
469 if self.slot.target_known.load(Ordering::Acquire) {
470 Some(self.slot.target.load(Ordering::Relaxed))
471 } else {
472 None
473 }
474 }
475
476 pub fn status(&self) -> TaskStatus {
478 TaskStatus::decode(self.slot.status.load(Ordering::Acquire))
479 }
480
481 pub fn set_iteration(&self, iteration: u64) -> Result<(), ReportingError> {
486 if let Some(target) = self.target_iteration().filter(|target| iteration > *target) {
487 return Err(ReportingError::IterationBeyondTarget {
488 identity: self.identity().label().to_owned(),
489 iteration,
490 target,
491 });
492 }
493 let previous = self.slot.current.fetch_max(iteration, Ordering::Relaxed);
494 if iteration < previous {
495 return Err(ReportingError::IterationRegressed {
496 identity: self.identity().label().to_owned(),
497 current: previous,
498 attempted: iteration,
499 });
500 }
501 Ok(())
502 }
503
504 pub fn should_continue(&self, iteration: u64) -> Result<bool, ReportingError> {
511 self.set_iteration(iteration)?;
512 Ok(self
513 .target_iteration()
514 .is_none_or(|target| iteration < target))
515 }
516
517 pub fn set_phase(&self, phase: impl Into<String>) {
520 *lock(&self.slot.phase) = phase.into().into_boxed_str();
521 }
522
523 pub fn report(&self, message: impl Into<String>) -> Result<(), ReportingError> {
525 self.events
526 .send(RenderEvent::TaskMessage {
527 identity: self.identity().label().to_owned(),
528 message: message.into(),
529 })
530 .map_err(|_| ReportingError::RendererUnavailable)
531 }
532
533 pub fn complete(mut self, reason: Option<String>) -> Result<(), ReportingError> {
539 if reason.is_none()
540 && let Some(target) = self.target_iteration()
541 {
542 let current = self.current_iteration();
543 if current != target {
544 *lock(&self.slot.phase) = "target not reached".into();
545 self.slot
546 .status
547 .store(TaskStatus::Failed.encode(), Ordering::Release);
548 self.active = false;
549 return Err(ReportingError::TargetIterationNotReached {
550 identity: self.identity().label().to_owned(),
551 current,
552 target,
553 });
554 }
555 }
556 *lock(&self.slot.phase) = reason
557 .unwrap_or_else(|| "completed".to_owned())
558 .into_boxed_str();
559 self.slot
560 .status
561 .store(TaskStatus::Completed.encode(), Ordering::Release);
562 self.active = false;
563 Ok(())
564 }
565
566 pub fn fail(mut self, reason: impl Into<String>) {
568 *lock(&self.slot.phase) = reason.into().into_boxed_str();
569 self.slot
570 .status
571 .store(TaskStatus::Failed.encode(), Ordering::Release);
572 self.active = false;
573 }
574}
575
576impl fmt::Debug for TaskProgress {
577 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
578 formatter
579 .debug_struct("TaskProgress")
580 .field("identity", &self.identity().label())
581 .field("current_iteration", &self.current_iteration())
582 .field("target_iteration", &self.target_iteration())
583 .field("active", &self.active)
584 .finish_non_exhaustive()
585 }
586}
587
588impl Drop for TaskProgress {
589 fn drop(&mut self) {
590 if self.active {
591 *lock(&self.slot.phase) = "interrupted".into();
592 self.slot
593 .status
594 .store(TaskStatus::Failed.encode(), Ordering::Release);
595 }
596 }
597}
598
599struct ReporterInner {
600 slots: Arc<[Arc<ProgressSlot>]>,
601 identity_keys: Arc<[Box<str>]>,
602 events: Sender<RenderEvent>,
603}
604
605struct ProgressSlot {
606 identity: TaskIdentity,
607 current: AtomicU64,
608 target: AtomicU64,
609 target_known: AtomicBool,
610 status: AtomicU8,
611 phase: Mutex<Box<str>>,
612}
613
614enum RenderEvent {
615 Message(String),
616 TaskMessage { identity: String, message: String },
617 Stop { success: bool, message: String },
618}
619
620struct TerminalLease;
621
622impl Drop for TerminalLease {
623 fn drop(&mut self) {
624 TERMINAL_OWNED.store(false, Ordering::Release);
625 }
626}
627
628fn acquire_terminal() -> Result<(), ReportingError> {
629 TERMINAL_OWNED
630 .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
631 .map(|_| ())
632 .map_err(|_| ReportingError::TerminalAlreadyOwned)
633}
634
635fn validate_identity_keys(
636 configuration: &ProjectConfig,
637 requested: Option<Vec<String>>,
638) -> Result<Vec<String>, ReportingError> {
639 let keys = requested.unwrap_or_else(|| {
640 configuration
641 .parameters()
642 .sweep_keys()
643 .map(str::to_owned)
644 .collect()
645 });
646 let mut seen = HashSet::with_capacity(keys.len());
647 for key in &keys {
648 if !seen.insert(key.as_str()) {
649 return Err(ReportingError::DuplicateIdentityParameter { key: key.clone() });
650 }
651 if !configuration.parameters().contains_parameter(key) {
652 return Err(ReportingError::UnknownIdentityParameter { key: key.clone() });
653 }
654 }
655 Ok(keys)
656}
657
658fn build_slots(
659 configuration: &ProjectConfig,
660 keys: Arc<[Box<str>]>,
661) -> Result<Arc<[Arc<ProgressSlot>]>, ReportingError> {
662 let capacity = usize::try_from(configuration.task_count()).map_err(|_| {
663 ReportingError::TaskCountTooLarge {
664 task_count: configuration.task_count(),
665 }
666 })?;
667 let mut slots = Vec::with_capacity(capacity);
668 let mut identities = HashMap::<String, u64>::with_capacity(capacity);
669 for task in configuration.task_configs() {
670 let label = render_identity(&task, &keys);
671 if let Some(first_ordinal) = identities.insert(label.clone(), task.task_ordinal()) {
672 return Err(ReportingError::NonUniqueTaskIdentity {
673 identity: label,
674 first_ordinal,
675 second_ordinal: task.task_ordinal(),
676 });
677 }
678 slots.push(Arc::new(ProgressSlot {
679 identity: TaskIdentity {
680 task,
681 keys: Arc::clone(&keys),
682 label: label.into(),
683 },
684 current: AtomicU64::new(0),
685 target: AtomicU64::new(0),
686 target_known: AtomicBool::new(false),
687 status: AtomicU8::new(TaskStatus::Pending.encode()),
688 phase: Mutex::new("pending".into()),
689 }));
690 }
691 Ok(slots.into())
692}
693
694fn render_identity(task: &TaskConfig, keys: &[Box<str>]) -> String {
695 if keys.is_empty() {
696 return "task".to_owned();
697 }
698 keys.iter()
699 .map(|key| {
700 let value = task
701 .value(key)
702 .expect("validated identity keys resolve for every task");
703 let value = serde_json::to_string(value)
704 .expect("serde_json::Value always serializes to valid JSON");
705 format!("{key}={value}")
706 })
707 .collect::<Vec<_>>()
708 .join(", ")
709}
710
711fn summarize(slots: &[Arc<ProgressSlot>]) -> ProgressSummary {
712 let mut summary = ProgressSummary {
713 total: u64::try_from(slots.len()).expect("slot count originated from a u64 task count"),
714 pending: 0,
715 running: 0,
716 completed: 0,
717 failed: 0,
718 };
719 for slot in slots {
720 match TaskStatus::decode(slot.status.load(Ordering::Acquire)) {
721 TaskStatus::Pending => summary.pending += 1,
722 TaskStatus::Running => summary.running += 1,
723 TaskStatus::Completed => summary.completed += 1,
724 TaskStatus::Failed => summary.failed += 1,
725 }
726 }
727 summary
728}
729
730fn render(
731 receiver: Receiver<RenderEvent>,
732 slots: Arc<[Arc<ProgressSlot>]>,
733 output: OutputMode,
734 _lease: TerminalLease,
735) {
736 if output == OutputMode::Terminal {
740 let _ = Term::stderr().clear_screen();
741 }
742 let mut terminal = (output == OutputMode::Terminal).then(|| TerminalDisplay::new(&slots));
743 let mut last_statuses = vec![TaskStatus::Pending; slots.len()];
744 loop {
745 if let Some(display) = &mut terminal {
746 display.refresh(&slots);
747 }
748 match receiver.recv_timeout(REFRESH_INTERVAL) {
749 Ok(RenderEvent::Message(message)) => write_message(output, terminal.as_ref(), &message),
750 Ok(RenderEvent::TaskMessage { identity, message }) => {
751 write_message(output, terminal.as_ref(), &format!("{identity}: {message}"));
752 }
753 Ok(RenderEvent::Stop { success, message }) => {
754 if let Some(display) = &mut terminal {
755 display.refresh(&slots);
756 display.finish(&slots);
757 }
758 if output == OutputMode::Plain {
759 write_plain_transitions(&slots, &mut last_statuses);
760 }
761 write_final(output, &slots, success, &message);
762 break;
763 }
764 Err(RecvTimeoutError::Timeout) => {
765 if output == OutputMode::Plain {
766 write_plain_transitions(&slots, &mut last_statuses);
767 }
768 }
769 Err(RecvTimeoutError::Disconnected) => break,
770 }
771 }
772}
773
774struct TerminalDisplay {
775 multi: MultiProgress,
776 bars: Vec<ProgressBar>,
777 statuses: Vec<TaskStatus>,
778 known_style: ProgressStyle,
779 unknown_style: ProgressStyle,
780}
781
782impl TerminalDisplay {
783 fn new(slots: &[Arc<ProgressSlot>]) -> Self {
784 let multi = MultiProgress::with_draw_target(ProgressDrawTarget::stderr());
785 let known_style = ProgressStyle::with_template(
786 "{prefix:.bold} [{msg}] {wide_bar:.cyan/blue} {pos}/{len} elapsed {elapsed_precise} ETA {eta_precise}",
787 )
788 .expect("hard-coded progress template is valid");
789 let unknown_style = ProgressStyle::with_template(
790 "{prefix:.bold} [{msg}] {spinner:.cyan} iteration {pos} elapsed {elapsed_precise} ETA unknown",
791 )
792 .expect("hard-coded spinner template is valid");
793 let bars: Vec<_> = slots
794 .iter()
795 .map(|slot| {
796 let bar = multi.add(ProgressBar::new_spinner());
797 bar.set_prefix(slot.identity.label().to_owned());
798 bar.set_style(unknown_style.clone());
799 bar.set_message("pending");
800 bar
801 })
802 .collect();
803
804 for bar in &bars {
808 bar.force_draw();
809 }
810
811 Self {
812 multi,
813 bars,
814 statuses: vec![TaskStatus::Pending; slots.len()],
815 known_style,
816 unknown_style,
817 }
818 }
819
820 fn refresh(&mut self, slots: &[Arc<ProgressSlot>]) {
821 for ((bar, previous_status), slot) in self.bars.iter().zip(&mut self.statuses).zip(slots) {
822 if !slot.target_known.load(Ordering::Acquire) {
823 bar.set_style(self.unknown_style.clone());
824 } else {
825 let target = slot.target.load(Ordering::Relaxed);
826 bar.set_style(self.known_style.clone());
827 bar.set_length(target);
828 }
829 bar.set_position(slot.current.load(Ordering::Relaxed));
830 let status = TaskStatus::decode(slot.status.load(Ordering::Acquire));
831 if *previous_status == TaskStatus::Pending && status == TaskStatus::Running {
832 bar.reset_elapsed();
835 }
836 let phase = lock(&slot.phase);
837 if phase.is_empty() || phase.as_ref() == status.label() {
838 bar.set_message(status.label());
839 } else {
840 bar.set_message(format!("{}: {}", status.label(), phase.as_ref()));
841 }
842 bar.tick();
843 *previous_status = status;
844 }
845 }
846
847 fn finish(&self, slots: &[Arc<ProgressSlot>]) {
848 for (bar, slot) in self.bars.iter().zip(slots) {
849 let status = TaskStatus::decode(slot.status.load(Ordering::Acquire));
850 bar.finish_with_message(status.label());
851 }
852 let _ = self.multi.clear();
853 }
854}
855
856fn write_message(output: OutputMode, terminal: Option<&TerminalDisplay>, message: &str) {
857 match output {
858 OutputMode::Terminal => {
859 if let Some(display) = terminal {
860 let _ = display.multi.println(message);
861 }
862 }
863 OutputMode::Plain => eprintln!("[progress] {message}"),
864 OutputMode::Hidden | OutputMode::Auto => {}
865 }
866}
867
868fn write_plain_transitions(slots: &[Arc<ProgressSlot>], previous: &mut [TaskStatus]) {
869 for (slot, old) in slots.iter().zip(previous) {
870 let status = TaskStatus::decode(slot.status.load(Ordering::Acquire));
871 if status != *old {
872 let phase = lock(&slot.phase);
873 eprintln!(
874 "[task] identity={} status={} phase={} iteration={} target={}",
875 slot.identity.label(),
876 status.label(),
877 phase.as_ref(),
878 slot.current.load(Ordering::Relaxed),
879 format_target(slot)
880 );
881 *old = status;
882 }
883 }
884}
885
886fn write_final(output: OutputMode, slots: &[Arc<ProgressSlot>], success: bool, message: &str) {
887 if output == OutputMode::Hidden {
888 return;
889 }
890 let summary = summarize(slots);
891 eprintln!(
892 "[workflow] status={} tasks={} completed={} failed={} pending={} message={}",
893 if success { "completed" } else { "failed" },
894 summary.total,
895 summary.completed,
896 summary.failed,
897 summary.pending,
898 message
899 );
900}
901
902fn format_target(slot: &ProgressSlot) -> String {
903 if slot.target_known.load(Ordering::Acquire) {
904 slot.target.load(Ordering::Relaxed).to_string()
905 } else {
906 "unknown".to_owned()
907 }
908}
909
910fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
911 mutex
912 .lock()
913 .unwrap_or_else(std::sync::PoisonError::into_inner)
914}