Skip to main content

scientific_workflow/reporting/
progress.rs

1//! Thread-safe task progress state and the sole terminal renderer.
2//!
3//! Workers publish only atomics on the hot path. A single renderer thread polls
4//! those slots at a bounded frequency and owns every human-facing terminal
5//! write for the session. Progress never mutates or replaces scientific time;
6//! callers synchronize it from their authoritative model state.
7
8use 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/// Lifecycle status of one independently executing scientific task.
29#[derive(Clone, Copy, Debug, Eq, PartialEq)]
30#[non_exhaustive]
31pub enum TaskStatus {
32    /// The task is registered but has not started.
33    Pending,
34    /// The task currently owns an active [`TaskProgress`] handle.
35    Running,
36    /// Evolution, persistence, and caller-defined validation completed.
37    Completed,
38    /// The task explicitly failed or dropped its active handle prematurely.
39    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/// Exact parameter-derived identity of one task.
73///
74/// The identity contains only the caller-selected parameter fields. Its label
75/// is deterministic compact JSON text intended for reporting, while equality
76/// validation uses the retained JSON values themselves.
77#[derive(Clone, Debug)]
78pub struct TaskIdentity {
79    task: TaskConfig,
80    keys: Arc<[Box<str>]>,
81    label: Arc<str>,
82}
83
84impl TaskIdentity {
85    /// Returns the terminal label derived from exact parameter key/value pairs.
86    pub fn label(&self) -> &str {
87        &self.label
88    }
89
90    /// Returns the number of parameter fields forming this identity.
91    pub fn len(&self) -> usize {
92        self.keys.len()
93    }
94
95    /// Reports whether the identity contains no parameter fields.
96    pub fn is_empty(&self) -> bool {
97        self.keys.is_empty()
98    }
99
100    /// Borrows one exact identity value by parameter name.
101    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    /// Iterates identity fields in the configured display order.
110    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/// Immutable aggregate captured when centralized reporting ends.
129#[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    /// Returns the number of registered tasks.
140    pub fn total(&self) -> u64 {
141        self.total
142    }
143
144    /// Returns the number of tasks that never started.
145    pub fn pending(&self) -> u64 {
146        self.pending
147    }
148
149    /// Returns the number of tasks still running at capture time.
150    pub fn running(&self) -> u64 {
151        self.running
152    }
153
154    /// Returns the number of successfully completed tasks.
155    pub fn completed(&self) -> u64 {
156        self.completed
157    }
158
159    /// Returns the number of failed or interrupted tasks.
160    pub fn failed(&self) -> u64 {
161        self.failed
162    }
163
164    /// Reports whether every registered task completed successfully.
165    pub fn is_success(&self) -> bool {
166        self.completed == self.total && self.pending == 0 && self.running == 0 && self.failed == 0
167    }
168}
169
170/// Output policy selected before the renderer starts.
171#[derive(Clone, Copy, Debug, Eq, PartialEq)]
172enum OutputMode {
173    Auto,
174    Terminal,
175    Plain,
176    Hidden,
177}
178
179/// Builder for one process-wide progress-reporting session.
180pub struct ProgressReporterBuilder {
181    configuration: ProjectConfig,
182    identity_keys: Option<Vec<String>>,
183    output: OutputMode,
184}
185
186impl ProgressReporterBuilder {
187    /// Selects the exact parameter combination used to identify every task.
188    ///
189    /// Keys may refer to fixed or swept parameters, but the complete selected
190    /// tuple must be unique for every generated task. Calling this method with
191    /// no keys is valid only for a one-task project.
192    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    /// Forces cursor-controlled terminal rendering even when stderr is not a
202    /// terminal. Most applications should retain automatic output selection.
203    pub fn terminal(mut self) -> Self {
204        self.output = OutputMode::Terminal;
205        self
206    }
207
208    /// Forces stable line-oriented output suitable for logs and CI systems.
209    pub fn plain(mut self) -> Self {
210        self.output = OutputMode::Plain;
211        self
212    }
213
214    /// Suppresses rendering while retaining counters and lifecycle validation.
215    ///
216    /// This is useful for tests and embedding applications with their own
217    /// non-terminal presentation layer. It does not disable progress tracking.
218    pub fn hidden(mut self) -> Self {
219        self.output = OutputMode::Hidden;
220        self
221    }
222
223    /// Validates identities, acquires exclusive terminal ownership, and starts
224    /// the centralized renderer.
225    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
270/// Central progress registry and exclusive human-facing terminal owner.
271pub struct ProgressReporter {
272    inner: Arc<ReporterInner>,
273    renderer: Option<JoinHandle<()>>,
274    finished: bool,
275}
276
277impl ProgressReporter {
278    /// Creates a builder from a conventional four-file scientific project.
279    ///
280    /// By default all sweep keys, in declaration order, form task identity.
281    pub fn for_project(project: &ScientificProject) -> ProgressReporterBuilder {
282        Self::for_configuration(project.configuration())
283    }
284
285    /// Creates a builder from the lower-level three-file project configuration.
286    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    /// Starts tracking one task using identity and ordering from `TaskConfig`.
295    ///
296    /// `initial_iteration` and `target_iteration` are absolute scientific
297    /// coordinates. A missing target creates an indeterminate progress display.
298    /// Callers never supply an ordinal or terminal label.
299    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    /// Sends one application-wide message through the sole renderer.
352    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    /// Returns a non-blocking snapshot of all task lifecycle counts.
360    pub fn summary(&self) -> ProgressSummary {
361        summarize(&self.inner.slots)
362    }
363
364    /// Finishes a successful session and emits its final summary and message.
365    ///
366    /// Every task must already be completed. The method stops and joins the
367    /// renderer and releases exclusive terminal ownership.
368    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    /// Finishes an unsuccessful session while preserving all task statuses.
386    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    /// Emits one terminal error through the reporting subsystem.
393    ///
394    /// This helper is intended for failures occurring before a reporting
395    /// session starts or after it has released the terminal lease.
396    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
445/// Non-clone task-local progress handle.
446///
447/// Dropping a running handle without calling [`TaskProgress::complete`] or
448/// [`TaskProgress::fail`] marks the task failed, making ordinary `?` returns
449/// safe without a separate cleanup branch.
450pub struct TaskProgress {
451    slot: Arc<ProgressSlot>,
452    events: Sender<RenderEvent>,
453    active: bool,
454}
455
456impl TaskProgress {
457    /// Borrows the exact parameter-derived task identity.
458    pub fn identity(&self) -> &TaskIdentity {
459        &self.slot.identity
460    }
461
462    /// Returns the latest reported absolute simulation iteration.
463    pub fn current_iteration(&self) -> u64 {
464        self.slot.current.load(Ordering::Relaxed)
465    }
466
467    /// Returns the known absolute target iteration, if one exists.
468    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    /// Returns this task's current lifecycle status.
477    pub fn status(&self) -> TaskStatus {
478        TaskStatus::decode(self.slot.status.load(Ordering::Acquire))
479    }
480
481    /// Synchronizes progress to an authoritative absolute simulation iteration.
482    ///
483    /// The atomic update never allocates or locks. Regressions and movement
484    /// beyond a known target are rejected without modifying the counter.
485    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    /// Synchronizes the authoritative iteration and applies the configured
505    /// continuation target.
506    ///
507    /// An indeterminate task returns `true`. A task with a target returns
508    /// `true` below it and `false` exactly at it. Movement beyond the target or
509    /// backwards is rejected through the same validation as [`Self::set_iteration`].
510    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    /// Updates one infrequent human-readable phase such as `evolving` or
518    /// `validating`. Phase updates may lock; iteration updates do not.
519    pub fn set_phase(&self, phase: impl Into<String>) {
520        *lock(&self.slot.phase) = phase.into().into_boxed_str();
521    }
522
523    /// Sends one task-scoped message through the sole renderer.
524    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    /// Marks the complete task workflow successful and consumes this handle.
534    ///
535    /// `reason == None` means the configured target must have been reached.
536    /// `Some(reason)` records an intentional scientific early-completion reason
537    /// and permits completion before that generic target.
538    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    /// Marks the task failed, records a concise phase, and consumes the handle.
567    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    // Clearing happens inside the renderer after the exclusive lease has been
737    // transferred to it. No application or worker thread writes to the
738    // terminal during the reporting session.
739    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        // MultiProgress throttles draws globally. Force each bar's initial
805        // state once so pending tasks are materialized instead of being
806        // starved by earlier rows updated in the same renderer pass.
807        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                // Pending time is queueing time, not task execution time. Start
833                // elapsed and ETA measurement from the first running state.
834                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}