Skip to main content

scientific_workflow/study/
renderer.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};
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 serde_json::Value;
18
19use super::command::StudyCommand;
20use super::error::StudyError;
21use super::phase::{Phase, TaskKey, TaskMode};
22use super::tui::{RenderSnapshot, TaskView, TerminalUi};
23
24const REFRESH_INTERVAL: Duration = Duration::from_millis(100);
25const MESSAGE_CAPACITY: usize = 256;
26static TERMINAL_OWNED: AtomicBool = AtomicBool::new(false);
27
28/// Lifecycle status of one independently executing scientific task.
29#[derive(Clone, Copy, Debug, Eq, PartialEq)]
30#[non_exhaustive]
31pub enum TaskStatus {
32    /// The task is registered but has not started.
33    Pending,
34    /// The task currently owns an active progress-reporting handle.
35    Running,
36    /// Evolution, persistence, and caller-defined validation completed.
37    Completed,
38    /// The task explicitly failed or dropped its active handle prematurely.
39    Failed,
40    /// The task cooperatively stopped after cancellation was requested.
41    Cancelled,
42    /// The task was never started because its phase stopped admitting work.
43    Skipped,
44}
45
46impl TaskStatus {
47    /// Returns the stable uncolored lifecycle label used by logs and APIs.
48    pub const fn as_str(self) -> &'static str {
49        match self {
50            Self::Pending => "pending",
51            Self::Running => "running",
52            Self::Completed => "completed",
53            Self::Failed => "failed",
54            Self::Cancelled => "cancelled",
55            Self::Skipped => "skipped",
56        }
57    }
58
59    fn encode(self) -> u8 {
60        match self {
61            Self::Pending => 0,
62            Self::Running => 1,
63            Self::Completed => 2,
64            Self::Failed => 3,
65            Self::Cancelled => 4,
66            Self::Skipped => 5,
67        }
68    }
69
70    fn decode(value: u8) -> Self {
71        match value {
72            0 => Self::Pending,
73            1 => Self::Running,
74            2 => Self::Completed,
75            3 => Self::Failed,
76            4 => Self::Cancelled,
77            5 => Self::Skipped,
78            _ => unreachable!("task status is written only through TaskStatus::encode"),
79        }
80    }
81
82    fn label(self) -> &'static str {
83        self.as_str()
84    }
85}
86
87/// Exact reporting identity and immutable metadata of one task.
88///
89/// The label and phase-qualified key identify the task in progress output.
90/// Metadata contains the complete application-defined task metadata map,
91/// including configuration provenance when attached by the caller.
92#[derive(Clone, Debug)]
93pub struct TaskIdentity {
94    label: Arc<str>,
95    key: TaskKey,
96    metadata: Arc<std::collections::BTreeMap<String, Value>>,
97}
98
99impl TaskIdentity {
100    /// Returns the declared display label for this task.
101    pub fn label(&self) -> &str {
102        &self.label
103    }
104
105    /// Returns the number of task metadata entries.
106    pub fn len(&self) -> usize {
107        self.metadata.len()
108    }
109
110    /// Reports whether the task contains no metadata.
111    pub fn is_empty(&self) -> bool {
112        self.metadata.is_empty()
113    }
114
115    /// Borrows one exact task metadata value by key.
116    pub fn value(&self, key: &str) -> Option<&Value> {
117        self.metadata.get(key)
118    }
119
120    /// Iterates task metadata in deterministic key order.
121    pub fn iter(&self) -> impl ExactSizeIterator<Item = (&str, &Value)> {
122        self.metadata
123            .iter()
124            .map(|(key, value)| (key.as_str(), value))
125    }
126
127    /// Returns the exact first-class task key when this identity came from a
128    /// phase declaration.
129    pub fn task_key(&self) -> &TaskKey {
130        &self.key
131    }
132}
133
134/// Immutable aggregate captured when centralized reporting ends.
135#[derive(Clone, Debug, Eq, PartialEq)]
136pub struct ProgressSummary {
137    total: u64,
138    pending: u64,
139    running: u64,
140    completed: u64,
141    failed: u64,
142    cancelled: u64,
143    skipped: u64,
144}
145
146impl ProgressSummary {
147    /// Returns the number of registered tasks.
148    pub fn total(&self) -> u64 {
149        self.total
150    }
151
152    /// Returns the number of tasks that never started.
153    pub fn pending(&self) -> u64 {
154        self.pending
155    }
156
157    /// Returns the number of tasks still running at capture time.
158    pub fn running(&self) -> u64 {
159        self.running
160    }
161
162    /// Returns the number of successfully completed tasks.
163    pub fn completed(&self) -> u64 {
164        self.completed
165    }
166
167    /// Returns the number of failed or interrupted tasks.
168    pub fn failed(&self) -> u64 {
169        self.failed
170    }
171
172    /// Returns the number of active tasks that cooperatively cancelled.
173    pub fn cancelled(&self) -> u64 {
174        self.cancelled
175    }
176
177    /// Returns the number of tasks that were never started.
178    pub fn skipped(&self) -> u64 {
179        self.skipped
180    }
181
182    /// Reports whether every registered task completed successfully.
183    pub fn is_success(&self) -> bool {
184        self.completed == self.total
185            && self.pending == 0
186            && self.running == 0
187            && self.failed == 0
188            && self.cancelled == 0
189            && self.skipped == 0
190    }
191}
192
193/// Output policy selected before the renderer starts.
194#[derive(Clone, Copy, Debug, Eq, PartialEq)]
195enum OutputMode {
196    Auto,
197    Terminal,
198    Plain,
199    Hidden,
200}
201
202/// Builder that makes the renderer observe existing first-class phases/tasks.
203pub(crate) struct StudyRendererBuilder {
204    slots: Arc<[Arc<ProgressSlot>]>,
205    output: OutputMode,
206    cancellation: Option<CancellationToken>,
207}
208
209impl StudyRendererBuilder {
210    pub(crate) fn cancellation_token(mut self, cancellation: CancellationToken) -> Self {
211        self.cancellation = Some(cancellation);
212        self
213    }
214    /// Forces cursor-controlled isolated-screen rendering.
215    pub fn terminal(mut self) -> Self {
216        self.output = OutputMode::Terminal;
217        self
218    }
219
220    /// Forces stable line-oriented output.
221    pub fn plain(mut self) -> Self {
222        self.output = OutputMode::Plain;
223        self
224    }
225
226    /// Suppresses rendering while retaining lifecycle validation.
227    pub fn hidden(mut self) -> Self {
228        self.output = OutputMode::Hidden;
229        self
230    }
231
232    /// Validates phase uniqueness and starts one renderer.
233    pub fn start(self) -> Result<StudyRenderer, StudyError> {
234        start_renderer(self.slots, self.output, self.cancellation)
235    }
236}
237
238impl fmt::Debug for StudyRendererBuilder {
239    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
240        formatter
241            .debug_struct("StudyRendererBuilder")
242            .field("tasks", &self.slots.len())
243            .field("output", &self.output)
244            .finish_non_exhaustive()
245    }
246}
247
248/// Central progress registry and exclusive human-facing terminal owner.
249pub(crate) struct StudyRenderer {
250    inner: Arc<RendererInner>,
251    renderer: Option<JoinHandle<()>>,
252    finished: bool,
253}
254
255impl StudyRenderer {
256    /// Observes tasks already owned and identified by first-class phases.
257    pub fn for_phase(phase: &Phase, heading: &str) -> Result<StudyRendererBuilder, StudyError> {
258        Ok(StudyRendererBuilder {
259            slots: build_phase_slots(std::slice::from_ref(phase), Some(heading))?,
260            output: OutputMode::Auto,
261            cancellation: None,
262        })
263    }
264
265    /// Starts one exact first-class iterative task.
266    pub(crate) fn start_progress(
267        &self,
268        key: &TaskKey,
269        initial_iteration: u64,
270        target_iteration: Option<u64>,
271    ) -> Result<TaskProgressHandle, StudyError> {
272        let slot = managed_slot(&self.inner.slots, key)?;
273        if slot.mode != TaskMode::Progress {
274            return Err(mode_mismatch(&slot, "progress"));
275        }
276        start_slot(&self.inner, slot, initial_iteration, target_iteration)
277    }
278
279    /// Starts one exact first-class lifecycle-only one-shot task.
280    pub(crate) fn start_one_shot(&self, key: &TaskKey) -> Result<OneShotTaskHandle, StudyError> {
281        let slot = managed_slot(&self.inner.slots, key)?;
282        if slot.mode != TaskMode::OneShot {
283            return Err(mode_mismatch(&slot, "one-shot"));
284        }
285        start_one_shot_slot(&self.inner, slot)
286    }
287
288    /// Marks one exact first-class task complete through verified reuse.
289    pub fn mark_completed(&self, key: &TaskKey) -> Result<(), StudyError> {
290        let slot = managed_slot(&self.inner.slots, key)?;
291        mark_slot_completed(&slot)
292    }
293
294    pub(crate) fn mark_skipped(&self, key: &TaskKey) -> Result<(), StudyError> {
295        let slot = managed_slot(&self.inner.slots, key)?;
296        mark_pending_terminal(&slot, TaskStatus::Skipped, "skipped")
297    }
298
299    pub(crate) fn mark_cancelled(&self, key: &TaskKey) -> Result<(), StudyError> {
300        let slot = managed_slot(&self.inner.slots, key)?;
301        mark_pending_terminal(&slot, TaskStatus::Cancelled, "cancelled")
302    }
303
304    pub(crate) fn mark_delayed(&self, key: &TaskKey, rank: usize) -> Result<(), StudyError> {
305        let slot = managed_slot(&self.inner.slots, key)?;
306        if TaskStatus::decode(slot.status.load(Ordering::Acquire)) != TaskStatus::Pending {
307            return Err(StudyError::TaskAlreadyStarted {
308                identity: slot.identity.label().to_owned(),
309            });
310        }
311        *lock(&slot.detail) = format!("delayed start (rank {rank})").into_boxed_str();
312        Ok(())
313    }
314
315    pub(crate) fn request_cancellation(&self) {
316        self.inner.cancelled.store(true, Ordering::Release);
317    }
318
319    pub(crate) fn is_cancelled(&self) -> bool {
320        self.inner.cancelled.load(Ordering::Acquire)
321    }
322
323    pub(crate) fn cancellation_flag(&self) -> Arc<AtomicBool> {
324        Arc::clone(&self.inner.cancelled)
325    }
326
327    /// Returns a non-blocking snapshot of all task lifecycle counts.
328    pub fn summary(&self) -> ProgressSummary {
329        summarize(&self.inner.slots)
330    }
331
332    pub(crate) fn task_execution_snapshots(&self) -> Vec<TaskExecutionSnapshot> {
333        self.inner
334            .slots
335            .iter()
336            .map(|slot| TaskExecutionSnapshot {
337                key: slot.identity.task_key().clone(),
338                status: TaskStatus::decode(slot.status.load(Ordering::Acquire)),
339                current_iteration: (slot.mode == TaskMode::Progress)
340                    .then(|| slot.current.load(Ordering::Relaxed)),
341                target_iteration: (slot.mode == TaskMode::Progress
342                    && slot.target_known.load(Ordering::Acquire))
343                .then(|| slot.target.load(Ordering::Relaxed)),
344            })
345            .collect()
346    }
347
348    /// Finishes a successful session and emits its final summary and message.
349    ///
350    /// Every task must already be completed. The method stops and joins the
351    /// renderer and releases exclusive terminal ownership.
352    pub fn complete(mut self, message: impl Into<String>) -> Result<ProgressSummary, StudyError> {
353        let summary = self.summary();
354        if !summary.is_success() {
355            self.stop(false, "study did not complete".to_owned())?;
356            return Err(StudyError::IncompleteProgress {
357                pending: summary.pending,
358                running: summary.running,
359                failed: summary.failed,
360            });
361        }
362        self.stop(true, message.into())?;
363        Ok(summary)
364    }
365
366    /// Finishes an unsuccessful session while preserving all task statuses.
367    pub fn fail(mut self, message: impl Into<String>) -> Result<ProgressSummary, StudyError> {
368        let summary = self.summary();
369        self.stop(false, message.into())?;
370        Ok(summary)
371    }
372
373    fn stop(&mut self, success: bool, message: String) -> Result<(), StudyError> {
374        self.inner
375            .events
376            .send(RenderEvent::Stop { success, message })
377            .map_err(|_| StudyError::RendererUnavailable)?;
378        self.finished = true;
379        if self
380            .renderer
381            .take()
382            .expect("an unfinished study renderer owns one thread")
383            .join()
384            .is_err()
385        {
386            return Err(StudyError::RendererPanicked);
387        }
388        Ok(())
389    }
390}
391
392pub(crate) struct TaskExecutionSnapshot {
393    pub(crate) key: TaskKey,
394    pub(crate) status: TaskStatus,
395    pub(crate) current_iteration: Option<u64>,
396    pub(crate) target_iteration: Option<u64>,
397}
398
399impl fmt::Debug for StudyRenderer {
400    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
401        formatter
402            .debug_struct("StudyRenderer")
403            .field("tasks", &self.inner.slots.len())
404            .field("summary", &self.summary())
405            .finish_non_exhaustive()
406    }
407}
408
409impl Drop for StudyRenderer {
410    fn drop(&mut self) {
411        if self.finished {
412            return;
413        }
414        let _ = self.inner.events.send(RenderEvent::Stop {
415            success: false,
416            message: "study renderer dropped before completion".to_owned(),
417        });
418        if let Some(renderer) = self.renderer.take() {
419            let _ = renderer.join();
420        }
421    }
422}
423
424/// Non-clone task-local progress handle.
425///
426/// Dropping a running handle without calling [`TaskProgressHandle::complete`] or
427/// [`TaskProgressHandle::fail`] marks the task failed, making ordinary `?` returns
428/// safe without a separate cleanup branch.
429pub(crate) struct TaskProgressHandle {
430    slot: Arc<ProgressSlot>,
431    events: SyncSender<RenderEvent>,
432    cancelled: Arc<AtomicBool>,
433    active: bool,
434}
435
436/// Shared, cheap cancellation observation for embedded schedulers and tasks.
437#[derive(Clone, Debug)]
438pub struct CancellationToken(Arc<AtomicBool>);
439
440impl CancellationToken {
441    pub(crate) fn new() -> Self {
442        Self(Arc::new(AtomicBool::new(false)))
443    }
444
445    pub(crate) fn shared(&self) -> Arc<AtomicBool> {
446        Arc::clone(&self.0)
447    }
448
449    /// Reports whether interactive Ctrl-C requested termination.
450    pub fn is_cancelled(&self) -> bool {
451        self.0.load(Ordering::Acquire)
452    }
453
454    /// Requests cooperative termination.
455    pub fn cancel(&self) {
456        self.0.store(true, Ordering::Release);
457    }
458}
459
460impl TaskProgressHandle {
461    /// Reports whether the owning renderer requested cooperative termination.
462    pub fn is_cancelled(&self) -> bool {
463        self.cancelled.load(Ordering::Acquire)
464    }
465
466    /// Borrows the exact parameter-derived task identity.
467    pub fn identity(&self) -> &TaskIdentity {
468        &self.slot.identity
469    }
470
471    /// Returns the latest reported absolute simulation iteration.
472    pub fn current_iteration(&self) -> u64 {
473        self.slot.current.load(Ordering::Relaxed)
474    }
475
476    /// Returns the known absolute target iteration, if one exists.
477    pub fn target_iteration(&self) -> Option<u64> {
478        if self.slot.target_known.load(Ordering::Acquire) {
479            Some(self.slot.target.load(Ordering::Relaxed))
480        } else {
481            None
482        }
483    }
484
485    /// Sets or replaces the absolute target for this running task.
486    pub fn set_target_iteration(&self, target: u64) -> Result<(), StudyError> {
487        let current = self.current_iteration();
488        if current > target {
489            return Err(StudyError::InitialIterationBeyondTarget {
490                identity: self.identity().label().to_owned(),
491                initial: current,
492                target,
493            });
494        }
495        self.slot.target.store(target, Ordering::Relaxed);
496        self.slot.target_known.store(true, Ordering::Release);
497        Ok(())
498    }
499
500    /// Synchronizes progress to an authoritative absolute simulation iteration.
501    ///
502    /// The atomic update never allocates or locks. Regressions and movement
503    /// beyond a known target are rejected without modifying the counter.
504    pub fn set_iteration(&self, iteration: u64) -> Result<(), StudyError> {
505        if let Some(target) = self.target_iteration().filter(|target| iteration > *target) {
506            return Err(StudyError::IterationBeyondTarget {
507                identity: self.identity().label().to_owned(),
508                iteration,
509                target,
510            });
511        }
512        let previous = self.slot.current.fetch_max(iteration, Ordering::Relaxed);
513        if iteration < previous {
514            return Err(StudyError::IterationRegressed {
515                identity: self.identity().label().to_owned(),
516                current: previous,
517                attempted: iteration,
518            });
519        }
520        Ok(())
521    }
522
523    /// Synchronizes the authoritative iteration and applies the configured
524    /// continuation target.
525    ///
526    /// An indeterminate task returns `true`. A task with a target returns
527    /// `true` below it and `false` exactly at it. Movement beyond the target or
528    /// backwards is rejected through the same validation as [`Self::set_iteration`].
529    pub fn should_continue(&self, iteration: u64) -> Result<bool, StudyError> {
530        self.set_iteration(iteration)?;
531        Ok(!self.is_cancelled()
532            && self
533                .target_iteration()
534                .is_none_or(|target| iteration < target))
535    }
536
537    /// Updates one infrequent human-readable detail such as `evolving` or
538    /// `validating`. Detail updates may lock; iteration updates do not.
539    pub fn set_detail(&self, detail: impl Into<String>) {
540        *lock(&self.slot.detail) = detail.into().into_boxed_str();
541    }
542
543    /// Sends one task-scoped message through the sole renderer.
544    pub fn report(&self, message: impl Into<String>) -> Result<(), StudyError> {
545        self.events
546            .send(RenderEvent::TaskMessage {
547                identity: self.identity().label().to_owned(),
548                message: message.into(),
549            })
550            .map_err(|_| StudyError::RendererUnavailable)
551    }
552
553    /// Marks the task successful and consumes this handle.
554    ///
555    /// `reason == None` means the configured target must have been reached.
556    /// `Some(reason)` records an intentional scientific early-completion reason
557    /// and permits completion before that generic target.
558    pub fn complete(mut self, reason: Option<String>) -> Result<(), StudyError> {
559        if reason.is_none()
560            && let Some(target) = self.target_iteration()
561        {
562            let current = self.current_iteration();
563            if current != target {
564                *lock(&self.slot.detail) = "target not reached".into();
565                self.slot
566                    .status
567                    .store(TaskStatus::Failed.encode(), Ordering::Release);
568                self.active = false;
569                return Err(StudyError::TargetIterationNotReached {
570                    identity: self.identity().label().to_owned(),
571                    current,
572                    target,
573                });
574            }
575        }
576        *lock(&self.slot.detail) = reason
577            .unwrap_or_else(|| "completed".to_owned())
578            .into_boxed_str();
579        self.slot
580            .status
581            .store(TaskStatus::Completed.encode(), Ordering::Release);
582        self.active = false;
583        Ok(())
584    }
585
586    /// Marks the task failed, records a concise detail, and consumes the handle.
587    pub fn fail(mut self, reason: impl Into<String>) {
588        *lock(&self.slot.detail) = reason.into().into_boxed_str();
589        self.slot
590            .status
591            .store(TaskStatus::Failed.encode(), Ordering::Release);
592        self.active = false;
593    }
594
595    pub(crate) fn cancel(mut self, reason: impl Into<String>) {
596        *lock(&self.slot.detail) = reason.into().into_boxed_str();
597        self.slot
598            .status
599            .store(TaskStatus::Cancelled.encode(), Ordering::Release);
600        self.active = false;
601    }
602}
603
604impl fmt::Debug for TaskProgressHandle {
605    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
606        formatter
607            .debug_struct("TaskProgressHandle")
608            .field("identity", &self.identity().label())
609            .field("current_iteration", &self.current_iteration())
610            .field("target_iteration", &self.target_iteration())
611            .field("active", &self.active)
612            .finish_non_exhaustive()
613    }
614}
615
616impl Drop for TaskProgressHandle {
617    fn drop(&mut self) {
618        if self.active {
619            *lock(&self.slot.detail) = "interrupted".into();
620            self.slot
621                .status
622                .store(TaskStatus::Failed.encode(), Ordering::Release);
623        }
624    }
625}
626
627/// Non-clone task-local handle for lifecycle-only work.
628///
629/// One-shot tasks deliberately expose no iteration or target operations. Dropping
630/// an active handle marks only its reporting task failed.
631pub(crate) struct OneShotTaskHandle {
632    slot: Arc<ProgressSlot>,
633    events: SyncSender<RenderEvent>,
634    cancelled: Arc<AtomicBool>,
635    active: bool,
636}
637
638impl OneShotTaskHandle {
639    /// Reports whether the owning renderer requested cooperative termination.
640    pub fn is_cancelled(&self) -> bool {
641        self.cancelled.load(Ordering::Acquire)
642    }
643
644    /// Borrows the task identity supplied by the owning phase.
645    pub fn identity(&self) -> &TaskIdentity {
646        &self.slot.identity
647    }
648
649    /// Returns the current lifecycle status.
650    pub fn status(&self) -> TaskStatus {
651        TaskStatus::decode(self.slot.status.load(Ordering::Acquire))
652    }
653
654    /// Updates one infrequent human-readable execution detail.
655    pub fn set_detail(&self, detail: impl Into<String>) {
656        *lock(&self.slot.detail) = detail.into().into_boxed_str();
657    }
658
659    /// Sends one task-scoped message through the sole renderer.
660    pub fn report(&self, message: impl Into<String>) -> Result<(), StudyError> {
661        self.events
662            .send(RenderEvent::TaskMessage {
663                identity: self.identity().label().to_owned(),
664                message: message.into(),
665            })
666            .map_err(|_| StudyError::RendererUnavailable)
667    }
668
669    /// Marks this one-shot successful and consumes its handle.
670    pub fn complete(mut self) {
671        *lock(&self.slot.detail) = "completed".into();
672        self.slot
673            .status
674            .store(TaskStatus::Completed.encode(), Ordering::Release);
675        self.active = false;
676    }
677
678    /// Marks this one-shot failed and consumes its handle.
679    pub fn fail(mut self, reason: impl Into<String>) {
680        *lock(&self.slot.detail) = reason.into().into_boxed_str();
681        self.slot
682            .status
683            .store(TaskStatus::Failed.encode(), Ordering::Release);
684        self.active = false;
685    }
686
687    pub(crate) fn cancel(mut self, reason: impl Into<String>) {
688        *lock(&self.slot.detail) = reason.into().into_boxed_str();
689        self.slot
690            .status
691            .store(TaskStatus::Cancelled.encode(), Ordering::Release);
692        self.active = false;
693    }
694}
695
696impl fmt::Debug for OneShotTaskHandle {
697    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
698        formatter
699            .debug_struct("OneShotTaskHandle")
700            .field("identity", &self.identity().label())
701            .field("status", &self.status())
702            .field("active", &self.active)
703            .finish_non_exhaustive()
704    }
705}
706
707impl Drop for OneShotTaskHandle {
708    fn drop(&mut self) {
709        if self.active {
710            *lock(&self.slot.detail) = "interrupted".into();
711            self.slot
712                .status
713                .store(TaskStatus::Failed.encode(), Ordering::Release);
714        }
715    }
716}
717
718struct RendererInner {
719    slots: Arc<[Arc<ProgressSlot>]>,
720    events: SyncSender<RenderEvent>,
721    cancelled: Arc<AtomicBool>,
722}
723
724struct ProgressSlot {
725    identity: TaskIdentity,
726    phase_label: Option<Arc<str>>,
727    mode: TaskMode,
728    current: AtomicU64,
729    target: AtomicU64,
730    target_known: AtomicBool,
731    started: AtomicBool,
732    status: AtomicU8,
733    detail: Mutex<Box<str>>,
734}
735
736enum RenderEvent {
737    TaskMessage { identity: String, message: String },
738    Stop { success: bool, message: String },
739}
740
741struct TerminalLease;
742
743impl Drop for TerminalLease {
744    fn drop(&mut self) {
745        TERMINAL_OWNED.store(false, Ordering::Release);
746    }
747}
748
749fn acquire_terminal() -> Result<(), StudyError> {
750    TERMINAL_OWNED
751        .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
752        .map(|_| ())
753        .map_err(|_| StudyError::TerminalAlreadyOwned)
754}
755
756fn resolve_output(output: OutputMode) -> OutputMode {
757    match output {
758        OutputMode::Auto if io::stderr().is_terminal() && io::stdin().is_terminal() => {
759            OutputMode::Terminal
760        }
761        OutputMode::Auto => OutputMode::Plain,
762        explicit => explicit,
763    }
764}
765
766fn start_renderer(
767    slots: Arc<[Arc<ProgressSlot>]>,
768    requested_output: OutputMode,
769    cancellation: Option<CancellationToken>,
770) -> Result<StudyRenderer, StudyError> {
771    acquire_terminal()?;
772    let lease = TerminalLease;
773    let cancelled = cancellation
774        .map(|token| token.shared())
775        .unwrap_or_else(|| Arc::new(AtomicBool::new(false)));
776    let mut output = resolve_output(requested_output);
777    let terminal = if output == OutputMode::Terminal {
778        match TerminalUi::enter(slots.len()) {
779            Ok(terminal) => Some(terminal),
780            Err(_) if requested_output == OutputMode::Auto => {
781                output = OutputMode::Plain;
782                None
783            }
784            Err(error) => {
785                return Err(StudyError::TerminalSetup {
786                    operation: error.operation,
787                    source: error.source,
788                });
789            }
790        }
791    } else {
792        None
793    };
794    let (events, receiver) = mpsc::sync_channel(MESSAGE_CAPACITY);
795    let renderer_slots = Arc::clone(&slots);
796    let renderer_cancelled = Arc::clone(&cancelled);
797    let renderer = match thread::Builder::new()
798        .name("scientific-workflow-progress".to_owned())
799        .spawn(move || {
800            render(
801                receiver,
802                renderer_slots,
803                output,
804                terminal,
805                renderer_cancelled,
806                lease,
807            )
808        }) {
809        Ok(renderer) => renderer,
810        Err(source) => return Err(StudyError::StartRenderer { source }),
811    };
812    Ok(StudyRenderer {
813        inner: Arc::new(RendererInner {
814            slots,
815            events,
816            cancelled,
817        }),
818        renderer: Some(renderer),
819        finished: false,
820    })
821}
822
823fn managed_slot(
824    slots: &[Arc<ProgressSlot>],
825    key: &TaskKey,
826) -> Result<Arc<ProgressSlot>, StudyError> {
827    slots
828        .iter()
829        .find(|slot| slot.identity.task_key() == key)
830        .cloned()
831        .ok_or_else(|| StudyError::UnknownTask {
832            task: key.to_string(),
833        })
834}
835
836fn mode_mismatch(slot: &ProgressSlot, requested: &'static str) -> StudyError {
837    StudyError::TaskModeMismatch {
838        task: slot.identity.task_key().to_string(),
839        requested,
840        actual: match slot.mode {
841            TaskMode::Progress => "progress",
842            TaskMode::OneShot => "one-shot",
843        },
844    }
845}
846
847fn mark_slot_completed(slot: &ProgressSlot) -> Result<(), StudyError> {
848    slot.status
849        .compare_exchange(
850            TaskStatus::Pending.encode(),
851            TaskStatus::Completed.encode(),
852            Ordering::AcqRel,
853            Ordering::Acquire,
854        )
855        .map_err(|_| StudyError::TaskAlreadyStarted {
856            identity: slot.identity.label().to_owned(),
857        })?;
858    *lock(&slot.detail) = "already completed".into();
859    Ok(())
860}
861
862fn mark_pending_terminal(
863    slot: &ProgressSlot,
864    status: TaskStatus,
865    detail: &'static str,
866) -> Result<(), StudyError> {
867    slot.status
868        .compare_exchange(
869            TaskStatus::Pending.encode(),
870            status.encode(),
871            Ordering::AcqRel,
872            Ordering::Acquire,
873        )
874        .map_err(|_| StudyError::TaskAlreadyStarted {
875            identity: slot.identity.label().to_owned(),
876        })?;
877    *lock(&slot.detail) = detail.into();
878    Ok(())
879}
880
881fn start_slot(
882    inner: &RendererInner,
883    slot: Arc<ProgressSlot>,
884    initial_iteration: u64,
885    target_iteration: Option<u64>,
886) -> Result<TaskProgressHandle, StudyError> {
887    if let Some(target) = target_iteration.filter(|target| initial_iteration > *target) {
888        return Err(StudyError::InitialIterationBeyondTarget {
889            identity: slot.identity.label().to_owned(),
890            initial: initial_iteration,
891            target,
892        });
893    }
894    slot.status
895        .compare_exchange(
896            TaskStatus::Pending.encode(),
897            TaskStatus::Running.encode(),
898            Ordering::AcqRel,
899            Ordering::Acquire,
900        )
901        .map_err(|_| StudyError::TaskAlreadyStarted {
902            identity: slot.identity.label().to_owned(),
903        })?;
904    slot.started.store(true, Ordering::Release);
905    slot.current.store(initial_iteration, Ordering::Relaxed);
906    if let Some(target) = target_iteration {
907        slot.target.store(target, Ordering::Relaxed);
908        slot.target_known.store(true, Ordering::Release);
909    } else {
910        slot.target_known.store(false, Ordering::Release);
911    }
912    *lock(&slot.detail) = "running".into();
913    Ok(TaskProgressHandle {
914        slot,
915        events: inner.events.clone(),
916        cancelled: Arc::clone(&inner.cancelled),
917        active: true,
918    })
919}
920
921fn start_one_shot_slot(
922    inner: &RendererInner,
923    slot: Arc<ProgressSlot>,
924) -> Result<OneShotTaskHandle, StudyError> {
925    slot.status
926        .compare_exchange(
927            TaskStatus::Pending.encode(),
928            TaskStatus::Running.encode(),
929            Ordering::AcqRel,
930            Ordering::Acquire,
931        )
932        .map_err(|_| StudyError::TaskAlreadyStarted {
933            identity: slot.identity.label().to_owned(),
934        })?;
935    slot.started.store(true, Ordering::Release);
936    slot.target_known.store(false, Ordering::Release);
937    *lock(&slot.detail) = "running".into();
938    Ok(OneShotTaskHandle {
939        slot,
940        events: inner.events.clone(),
941        cancelled: Arc::clone(&inner.cancelled),
942        active: true,
943    })
944}
945
946fn build_phase_slots(
947    phases: &[Phase],
948    heading: Option<&str>,
949) -> Result<Arc<[Arc<ProgressSlot>]>, StudyError> {
950    if phases.is_empty() {
951        return Err(StudyError::EmptyPhaseSet);
952    }
953    let mut phase_ids = HashSet::with_capacity(phases.len());
954    let capacity = phases.iter().map(|phase| phase.tasks().len()).sum();
955    let mut slots = Vec::with_capacity(capacity);
956    for phase in phases {
957        if !phase_ids.insert(phase.id()) {
958            return Err(StudyError::DuplicatePhaseId {
959                phase: phase.id().get(),
960            });
961        }
962        let phase_label: Arc<str> = heading.unwrap_or_else(|| phase.label()).into();
963        for task in phase.tasks() {
964            slots.push(Arc::new(ProgressSlot {
965                identity: TaskIdentity {
966                    label: task.label().into(),
967                    key: task.key().clone(),
968                    metadata: task.metadata_map(),
969                },
970                phase_label: Some(Arc::clone(&phase_label)),
971                mode: task.mode(),
972                current: AtomicU64::new(0),
973                target: AtomicU64::new(0),
974                target_known: AtomicBool::new(false),
975                started: AtomicBool::new(false),
976                status: AtomicU8::new(TaskStatus::Pending.encode()),
977                detail: Mutex::new("pending".into()),
978            }));
979        }
980    }
981    Ok(slots.into())
982}
983
984fn summarize(slots: &[Arc<ProgressSlot>]) -> ProgressSummary {
985    let mut summary = ProgressSummary {
986        total: u64::try_from(slots.len()).expect("slot count originated from a u64 task count"),
987        pending: 0,
988        running: 0,
989        completed: 0,
990        failed: 0,
991        cancelled: 0,
992        skipped: 0,
993    };
994    for slot in slots {
995        match TaskStatus::decode(slot.status.load(Ordering::Acquire)) {
996            TaskStatus::Pending => summary.pending += 1,
997            TaskStatus::Running => summary.running += 1,
998            TaskStatus::Completed => summary.completed += 1,
999            TaskStatus::Failed => summary.failed += 1,
1000            TaskStatus::Cancelled => summary.cancelled += 1,
1001            TaskStatus::Skipped => summary.skipped += 1,
1002        }
1003    }
1004    summary
1005}
1006
1007fn render(
1008    receiver: Receiver<RenderEvent>,
1009    slots: Arc<[Arc<ProgressSlot>]>,
1010    output: OutputMode,
1011    mut terminal: Option<TerminalUi>,
1012    cancelled: Arc<AtomicBool>,
1013    _lease: TerminalLease,
1014) {
1015    let mut last_statuses = vec![TaskStatus::Pending; slots.len()];
1016    let mut input_failed = false;
1017    loop {
1018        if let Some(display) = &mut terminal {
1019            if !input_failed {
1020                match display.poll_command() {
1021                    Ok(Some(StudyCommand::Exit)) => {
1022                        cancelled.store(true, Ordering::Release);
1023                        display.mark_exit_requested();
1024                    }
1025                    Ok(None) => {}
1026                    Err(error) => {
1027                        cancelled.store(true, Ordering::Release);
1028                        display.push_message(format!("study: terminal input failed: {error}"));
1029                        display.mark_exit_requested();
1030                        input_failed = true;
1031                    }
1032                }
1033            }
1034            let snapshot = render_snapshot(&slots);
1035            let _ = display.draw(&snapshot);
1036        }
1037        match receiver.recv_timeout(REFRESH_INTERVAL) {
1038            Ok(RenderEvent::TaskMessage { identity, message }) => {
1039                let message = format!("{identity}: {message}");
1040                if let Some(display) = &mut terminal {
1041                    display.push_message(message);
1042                } else {
1043                    write_message(output, &message);
1044                }
1045            }
1046            Ok(RenderEvent::Stop { success, message }) => {
1047                if let Some(display) = &mut terminal {
1048                    let snapshot = render_snapshot(&slots);
1049                    let _ = display.draw(&snapshot);
1050                }
1051                if output == OutputMode::Plain {
1052                    write_plain_transitions(&slots, &mut last_statuses);
1053                }
1054                drop(terminal.take());
1055                write_final(output, &slots, success, &message);
1056                break;
1057            }
1058            Err(RecvTimeoutError::Timeout) => {
1059                if output == OutputMode::Plain {
1060                    write_plain_transitions(&slots, &mut last_statuses);
1061                }
1062            }
1063            Err(RecvTimeoutError::Disconnected) => break,
1064        }
1065    }
1066}
1067
1068fn render_snapshot(slots: &[Arc<ProgressSlot>]) -> RenderSnapshot {
1069    RenderSnapshot {
1070        heading: slots
1071            .first()
1072            .and_then(|slot| slot.phase_label.as_deref())
1073            .unwrap_or("Study")
1074            .to_owned(),
1075        summary: summarize(slots),
1076        tasks: terminal_task_views(slots),
1077    }
1078}
1079
1080fn terminal_task_views(slots: &[Arc<ProgressSlot>]) -> Vec<TaskView> {
1081    slots
1082        .iter()
1083        .map(|slot| TaskView {
1084            label: slot.identity.label().to_owned(),
1085            mode: slot.mode,
1086            current: slot.current.load(Ordering::Relaxed),
1087            target: slot
1088                .target_known
1089                .load(Ordering::Acquire)
1090                .then(|| slot.target.load(Ordering::Relaxed)),
1091            started: slot.started.load(Ordering::Acquire),
1092            status: TaskStatus::decode(slot.status.load(Ordering::Acquire)),
1093            detail: lock(&slot.detail).to_string(),
1094        })
1095        .collect()
1096}
1097
1098fn write_message(output: OutputMode, message: &str) {
1099    match output {
1100        OutputMode::Plain => eprintln!("[progress] {message}"),
1101        OutputMode::Terminal | OutputMode::Hidden | OutputMode::Auto => {}
1102    }
1103}
1104
1105fn write_plain_transitions(slots: &[Arc<ProgressSlot>], previous: &mut [TaskStatus]) {
1106    for (slot, old) in slots.iter().zip(previous) {
1107        let status = TaskStatus::decode(slot.status.load(Ordering::Acquire));
1108        if status != *old {
1109            let detail = lock(&slot.detail);
1110            eprintln!(
1111                "[task] identity={} status={} detail={} iteration={} target={}",
1112                slot.identity.task_key(),
1113                status.label(),
1114                detail.as_ref(),
1115                slot.current.load(Ordering::Relaxed),
1116                format_target(slot)
1117            );
1118            *old = status;
1119        }
1120    }
1121}
1122
1123fn write_final(output: OutputMode, slots: &[Arc<ProgressSlot>], success: bool, message: &str) {
1124    if output == OutputMode::Hidden || (output == OutputMode::Terminal && success) {
1125        return;
1126    }
1127    let summary = summarize(slots);
1128    if !success {
1129        for slot in slots {
1130            let detail = lock(&slot.detail);
1131            eprintln!(
1132                "[task-final] task={} status={} detail={}",
1133                slot.identity.task_key(),
1134                TaskStatus::decode(slot.status.load(Ordering::Acquire)).label(),
1135                detail.as_ref(),
1136            );
1137        }
1138    }
1139    eprintln!(
1140        "[study] status={} tasks={} completed={} failed={} cancelled={} skipped={} pending={} message={}",
1141        if success { "completed" } else { "failed" },
1142        summary.total,
1143        summary.completed,
1144        summary.failed,
1145        summary.cancelled,
1146        summary.skipped,
1147        summary.pending,
1148        message
1149    );
1150}
1151
1152fn format_target(slot: &ProgressSlot) -> String {
1153    if slot.target_known.load(Ordering::Acquire) {
1154        slot.target.load(Ordering::Relaxed).to_string()
1155    } else {
1156        "unknown".to_owned()
1157    }
1158}
1159
1160fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
1161    mutex
1162        .lock()
1163        .unwrap_or_else(std::sync::PoisonError::into_inner)
1164}