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