Skip to main content

scientific_workflow/runtime/
reporting.rs

1//! Private thread-safe task progress state and 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::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/// Lifecycle status of one independently executing scientific task.
36#[derive(Clone, Copy, Debug, Eq, PartialEq)]
37#[non_exhaustive]
38pub enum TaskStatus {
39    /// The task is registered but has not started.
40    Pending,
41    /// The task currently owns an active [`TaskProgress`] handle.
42    Running,
43    /// Evolution, persistence, and caller-defined validation completed.
44    Completed,
45    /// Previously verified work was reused without executing again.
46    Reused,
47    /// The task explicitly failed or dropped its active handle prematurely.
48    Failed,
49    /// The task cooperatively stopped after cancellation was requested.
50    Cancelled,
51    /// The task was never started because its phase stopped admitting work.
52    Skipped,
53}
54
55impl TaskStatus {
56    /// Returns the stable uncolored lifecycle label used by logs and APIs.
57    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/// Exact parameter-derived identity of one task.
100///
101/// The identity contains only the caller-selected parameter fields. Its label
102/// is deterministic compact JSON text intended for reporting, while equality
103/// validation uses the retained JSON values themselves.
104#[derive(Clone, Debug)]
105pub struct TaskIdentity {
106    label: Arc<str>,
107    key: TaskKey,
108    configuration: crate::configuration::TaskConfig,
109}
110
111impl TaskIdentity {
112    /// Returns the terminal label derived from exact parameter key/value pairs.
113    pub fn label(&self) -> &str {
114        &self.label
115    }
116
117    /// Returns the number of parameter fields forming this identity.
118    pub fn len(&self) -> usize {
119        self.configuration.parameters().len()
120    }
121
122    /// Reports whether the identity contains no parameter fields.
123    pub fn is_empty(&self) -> bool {
124        self.configuration.parameters().is_empty()
125    }
126
127    /// Borrows one exact identity value by parameter name.
128    pub fn value(&self, key: &str) -> Option<&Value> {
129        self.configuration.value(key)
130    }
131
132    /// Iterates identity fields in the configured display order.
133    pub fn iter(&self) -> Box<dyn Iterator<Item = (&str, &Value)> + '_> {
134        Box::new(self.configuration.parameters().iter())
135    }
136
137    /// Returns the exact first-class task key when this identity came from a
138    /// phase declaration.
139    pub fn task_key(&self) -> &TaskKey {
140        &self.key
141    }
142}
143
144/// Immutable aggregate captured when centralized reporting ends.
145#[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    /// Returns the number of registered tasks.
159    pub fn total(&self) -> u64 {
160        self.total
161    }
162
163    /// Returns the number of tasks that never started.
164    pub fn pending(&self) -> u64 {
165        self.pending
166    }
167
168    /// Returns the number of tasks still running at capture time.
169    pub fn running(&self) -> u64 {
170        self.running
171    }
172
173    /// Returns the number of successfully completed tasks.
174    pub fn completed(&self) -> u64 {
175        self.completed
176    }
177
178    /// Returns the number of application-verified reused tasks.
179    pub fn reused(&self) -> u64 {
180        self.reused
181    }
182
183    /// Returns the number of failed or interrupted tasks.
184    pub fn failed(&self) -> u64 {
185        self.failed
186    }
187
188    /// Returns the number of active tasks that cooperatively cancelled.
189    pub fn cancelled(&self) -> u64 {
190        self.cancelled
191    }
192
193    /// Returns the number of tasks that were never started.
194    pub fn skipped(&self) -> u64 {
195        self.skipped
196    }
197
198    /// Reports whether every registered task completed successfully.
199    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/// Output policy selected before the renderer starts.
210#[derive(Clone, Copy, Debug, Eq, PartialEq)]
211enum OutputMode {
212    Auto,
213    Terminal,
214    Plain,
215    Hidden,
216}
217
218/// Builder that makes the reporter observe existing first-class phases/tasks.
219pub(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    /// Forces cursor-controlled isolated-screen rendering.
231    pub fn terminal(mut self) -> Self {
232        self.output = OutputMode::Terminal;
233        self
234    }
235
236    /// Forces stable line-oriented output.
237    pub fn plain(mut self) -> Self {
238        self.output = OutputMode::Plain;
239        self
240    }
241
242    /// Suppresses rendering while retaining lifecycle validation.
243    pub fn hidden(mut self) -> Self {
244        self.output = OutputMode::Hidden;
245        self
246    }
247
248    /// Validates phase uniqueness and starts one observing reporter.
249    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
264/// Central progress registry and exclusive human-facing terminal owner.
265pub(crate) struct RuntimeReporter {
266    inner: Arc<ReporterInner>,
267    renderer: Option<JoinHandle<()>>,
268    finished: bool,
269}
270
271impl RuntimeReporter {
272    /// Observes tasks already owned and identified by first-class phases.
273    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    /// Starts one exact first-class iterative task.
285    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    /// Starts one exact first-class lifecycle-only activity task.
299    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    /// Marks one exact first-class task complete through verified reuse.
308    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    /// Returns a non-blocking snapshot of all task lifecycle counts.
332    pub fn summary(&self) -> ProgressSummary {
333        summarize(&self.inner.slots)
334    }
335
336    /// Finishes a successful session and emits its final summary and message.
337    ///
338    /// Every task must already be completed. The method stops and joins the
339    /// renderer and releases exclusive terminal ownership.
340    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    /// Finishes an unsuccessful session while preserving all task statuses.
358    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
408/// Non-clone task-local progress handle.
409///
410/// Dropping a running handle without calling [`TaskProgress::complete`] or
411/// [`TaskProgress::fail`] marks the task failed, making ordinary `?` returns
412/// safe without a separate cleanup branch.
413pub struct TaskProgress {
414    slot: Arc<ProgressSlot>,
415    events: SyncSender<RenderEvent>,
416    cancelled: Arc<AtomicBool>,
417    active: bool,
418}
419
420/// Shared, cheap cancellation observation for embedded schedulers and tasks.
421#[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    /// Reports whether interactive Ctrl-C requested termination.
434    pub fn is_cancelled(&self) -> bool {
435        self.0.load(Ordering::Acquire)
436    }
437
438    /// Requests cooperative termination.
439    pub fn cancel(&self) {
440        self.0.store(true, Ordering::Release);
441    }
442}
443
444impl TaskProgress {
445    /// Reports whether the owning reporter requested cooperative termination.
446    pub fn is_cancelled(&self) -> bool {
447        self.cancelled.load(Ordering::Acquire)
448    }
449
450    /// Borrows the exact parameter-derived task identity.
451    pub fn identity(&self) -> &TaskIdentity {
452        &self.slot.identity
453    }
454
455    /// Returns the latest reported absolute simulation iteration.
456    pub fn current_iteration(&self) -> u64 {
457        self.slot.current.load(Ordering::Relaxed)
458    }
459
460    /// Returns the known absolute target iteration, if one exists.
461    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    /// Sets or replaces the absolute target for this running task.
470    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    /// Returns this task's current lifecycle status.
485    pub fn status(&self) -> TaskStatus {
486        TaskStatus::decode(self.slot.status.load(Ordering::Acquire))
487    }
488
489    /// Synchronizes progress to an authoritative absolute simulation iteration.
490    ///
491    /// The atomic update never allocates or locks. Regressions and movement
492    /// beyond a known target are rejected without modifying the counter.
493    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    /// Synchronizes the authoritative iteration and applies the configured
513    /// continuation target.
514    ///
515    /// An indeterminate task returns `true`. A task with a target returns
516    /// `true` below it and `false` exactly at it. Movement beyond the target or
517    /// backwards is rejected through the same validation as [`Self::set_iteration`].
518    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    /// Updates one infrequent human-readable detail such as `evolving` or
527    /// `validating`. Detail updates may lock; iteration updates do not.
528    pub fn set_detail(&self, detail: impl Into<String>) {
529        *lock(&self.slot.detail) = detail.into().into_boxed_str();
530    }
531
532    /// Sends one task-scoped message through the sole renderer.
533    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    /// Marks the complete task workflow successful and consumes this handle.
543    ///
544    /// `reason == None` means the configured target must have been reached.
545    /// `Some(reason)` records an intentional scientific early-completion reason
546    /// and permits completion before that generic target.
547    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    /// Marks the task failed, records a concise detail, and consumes the handle.
576    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
616/// Non-clone task-local handle for lifecycle-only work.
617///
618/// Activities deliberately expose no iteration or target operations. Dropping
619/// an active handle marks only its reporting task failed.
620pub struct ActivityTask {
621    slot: Arc<ProgressSlot>,
622    events: SyncSender<RenderEvent>,
623    cancelled: Arc<AtomicBool>,
624    active: bool,
625}
626
627impl ActivityTask {
628    /// Reports whether the owning reporter requested cooperative termination.
629    pub fn is_cancelled(&self) -> bool {
630        self.cancelled.load(Ordering::Acquire)
631    }
632
633    /// Borrows the task identity supplied by the owning phase.
634    pub fn identity(&self) -> &TaskIdentity {
635        &self.slot.identity
636    }
637
638    /// Returns the current lifecycle status.
639    pub fn status(&self) -> TaskStatus {
640        TaskStatus::decode(self.slot.status.load(Ordering::Acquire))
641    }
642
643    /// Updates one infrequent human-readable execution detail.
644    pub fn set_detail(&self, detail: impl Into<String>) {
645        *lock(&self.slot.detail) = detail.into().into_boxed_str();
646    }
647
648    /// Sends one task-scoped message through the sole renderer.
649    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    /// Marks this activity successful and consumes its handle.
659    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    /// Marks this activity failed and consumes its handle.
668    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        // MultiProgress throttles draws globally. Force each bar's initial
1164        // state once so pending tasks are materialized instead of being
1165        // starved by earlier rows updated in the same renderer pass.
1166        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                // Pending time is queueing time, not task execution time. Start
1206                // elapsed and ETA measurement only after execution begins.
1207                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                // Pending and reused tasks have no execution interval, so do
1227                // not render a clock that measures time spent in the queue.
1228                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}