1use 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
30#[non_exhaustive]
31pub enum TaskStatus {
32 Pending,
34 Running,
36 Completed,
38 Failed,
40 Cancelled,
42 Skipped,
44}
45
46impl TaskStatus {
47 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#[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 pub fn label(&self) -> &str {
102 &self.label
103 }
104
105 pub fn len(&self) -> usize {
107 self.metadata.len()
108 }
109
110 pub fn is_empty(&self) -> bool {
112 self.metadata.is_empty()
113 }
114
115 pub fn value(&self, key: &str) -> Option<&Value> {
117 self.metadata.get(key)
118 }
119
120 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 pub fn task_key(&self) -> &TaskKey {
130 &self.key
131 }
132}
133
134#[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 pub fn total(&self) -> u64 {
149 self.total
150 }
151
152 pub fn pending(&self) -> u64 {
154 self.pending
155 }
156
157 pub fn running(&self) -> u64 {
159 self.running
160 }
161
162 pub fn completed(&self) -> u64 {
164 self.completed
165 }
166
167 pub fn failed(&self) -> u64 {
169 self.failed
170 }
171
172 pub fn cancelled(&self) -> u64 {
174 self.cancelled
175 }
176
177 pub fn skipped(&self) -> u64 {
179 self.skipped
180 }
181
182 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
195enum OutputMode {
196 Auto,
197 Terminal,
198 Plain,
199 Hidden,
200}
201
202pub(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 pub fn terminal(mut self) -> Self {
216 self.output = OutputMode::Terminal;
217 self
218 }
219
220 pub fn plain(mut self) -> Self {
222 self.output = OutputMode::Plain;
223 self
224 }
225
226 pub fn hidden(mut self) -> Self {
228 self.output = OutputMode::Hidden;
229 self
230 }
231
232 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
248pub(crate) struct StudyRenderer {
250 inner: Arc<RendererInner>,
251 renderer: Option<JoinHandle<()>>,
252 finished: bool,
253}
254
255impl StudyRenderer {
256 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 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 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 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 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 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 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
424pub(crate) struct TaskProgressHandle {
430 slot: Arc<ProgressSlot>,
431 events: SyncSender<RenderEvent>,
432 cancelled: Arc<AtomicBool>,
433 active: bool,
434}
435
436#[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 pub fn is_cancelled(&self) -> bool {
451 self.0.load(Ordering::Acquire)
452 }
453
454 pub fn cancel(&self) {
456 self.0.store(true, Ordering::Release);
457 }
458}
459
460impl TaskProgressHandle {
461 pub fn is_cancelled(&self) -> bool {
463 self.cancelled.load(Ordering::Acquire)
464 }
465
466 pub fn identity(&self) -> &TaskIdentity {
468 &self.slot.identity
469 }
470
471 pub fn current_iteration(&self) -> u64 {
473 self.slot.current.load(Ordering::Relaxed)
474 }
475
476 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 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 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 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 pub fn set_detail(&self, detail: impl Into<String>) {
540 *lock(&self.slot.detail) = detail.into().into_boxed_str();
541 }
542
543 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 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 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
627pub(crate) struct OneShotTaskHandle {
632 slot: Arc<ProgressSlot>,
633 events: SyncSender<RenderEvent>,
634 cancelled: Arc<AtomicBool>,
635 active: bool,
636}
637
638impl OneShotTaskHandle {
639 pub fn is_cancelled(&self) -> bool {
641 self.cancelled.load(Ordering::Acquire)
642 }
643
644 pub fn identity(&self) -> &TaskIdentity {
646 &self.slot.identity
647 }
648
649 pub fn status(&self) -> TaskStatus {
651 TaskStatus::decode(self.slot.status.load(Ordering::Acquire))
652 }
653
654 pub fn set_detail(&self, detail: impl Into<String>) {
656 *lock(&self.slot.detail) = detail.into().into_boxed_str();
657 }
658
659 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 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 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}