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) -> Box<dyn Iterator<Item = (&str, &Value)> + '_> {
122 Box::new(
123 self.metadata
124 .iter()
125 .map(|(key, value)| (key.as_str(), value)),
126 )
127 }
128
129 pub fn task_key(&self) -> &TaskKey {
132 &self.key
133 }
134}
135
136#[derive(Clone, Debug, Eq, PartialEq)]
138pub struct ProgressSummary {
139 total: u64,
140 pending: u64,
141 running: u64,
142 completed: u64,
143 failed: u64,
144 cancelled: u64,
145 skipped: u64,
146}
147
148impl ProgressSummary {
149 pub fn total(&self) -> u64 {
151 self.total
152 }
153
154 pub fn pending(&self) -> u64 {
156 self.pending
157 }
158
159 pub fn running(&self) -> u64 {
161 self.running
162 }
163
164 pub fn completed(&self) -> u64 {
166 self.completed
167 }
168
169 pub fn failed(&self) -> u64 {
171 self.failed
172 }
173
174 pub fn cancelled(&self) -> u64 {
176 self.cancelled
177 }
178
179 pub fn skipped(&self) -> u64 {
181 self.skipped
182 }
183
184 pub fn is_success(&self) -> bool {
186 self.completed == self.total
187 && self.pending == 0
188 && self.running == 0
189 && self.failed == 0
190 && self.cancelled == 0
191 && self.skipped == 0
192 }
193}
194
195#[derive(Clone, Copy, Debug, Eq, PartialEq)]
197enum OutputMode {
198 Auto,
199 Terminal,
200 Plain,
201 Hidden,
202}
203
204pub(crate) struct StudyRendererBuilder {
206 slots: Arc<[Arc<ProgressSlot>]>,
207 output: OutputMode,
208 cancellation: Option<CancellationToken>,
209}
210
211impl StudyRendererBuilder {
212 pub(crate) fn cancellation_token(mut self, cancellation: CancellationToken) -> Self {
213 self.cancellation = Some(cancellation);
214 self
215 }
216 pub fn terminal(mut self) -> Self {
218 self.output = OutputMode::Terminal;
219 self
220 }
221
222 pub fn plain(mut self) -> Self {
224 self.output = OutputMode::Plain;
225 self
226 }
227
228 pub fn hidden(mut self) -> Self {
230 self.output = OutputMode::Hidden;
231 self
232 }
233
234 pub fn start(self) -> Result<StudyRenderer, StudyError> {
236 start_renderer(self.slots, self.output, self.cancellation)
237 }
238}
239
240impl fmt::Debug for StudyRendererBuilder {
241 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
242 formatter
243 .debug_struct("StudyRendererBuilder")
244 .field("tasks", &self.slots.len())
245 .field("output", &self.output)
246 .finish_non_exhaustive()
247 }
248}
249
250pub(crate) struct StudyRenderer {
252 inner: Arc<RendererInner>,
253 renderer: Option<JoinHandle<()>>,
254 finished: bool,
255}
256
257impl StudyRenderer {
258 pub fn for_phase(phase: &Phase, heading: &str) -> Result<StudyRendererBuilder, StudyError> {
260 Ok(StudyRendererBuilder {
261 slots: build_phase_slots(std::slice::from_ref(phase), Some(heading))?,
262 output: OutputMode::Auto,
263 cancellation: None,
264 })
265 }
266
267 pub(crate) fn start_progress(
269 &self,
270 key: &TaskKey,
271 initial_iteration: u64,
272 target_iteration: Option<u64>,
273 ) -> Result<TaskProgressHandle, StudyError> {
274 let slot = managed_slot(&self.inner.slots, key)?;
275 if slot.mode != TaskMode::Progress {
276 return Err(mode_mismatch(&slot, "progress"));
277 }
278 start_slot(&self.inner, slot, initial_iteration, target_iteration)
279 }
280
281 pub(crate) fn start_one_shot(&self, key: &TaskKey) -> Result<OneShotTaskHandle, StudyError> {
283 let slot = managed_slot(&self.inner.slots, key)?;
284 if slot.mode != TaskMode::OneShot {
285 return Err(mode_mismatch(&slot, "one-shot"));
286 }
287 start_one_shot_slot(&self.inner, slot)
288 }
289
290 pub fn mark_completed(&self, key: &TaskKey) -> Result<(), StudyError> {
292 let slot = managed_slot(&self.inner.slots, key)?;
293 mark_slot_completed(&slot)
294 }
295
296 pub(crate) fn mark_skipped(&self, key: &TaskKey) -> Result<(), StudyError> {
297 let slot = managed_slot(&self.inner.slots, key)?;
298 mark_pending_terminal(&slot, TaskStatus::Skipped, "skipped")
299 }
300
301 pub(crate) fn mark_cancelled(&self, key: &TaskKey) -> Result<(), StudyError> {
302 let slot = managed_slot(&self.inner.slots, key)?;
303 mark_pending_terminal(&slot, TaskStatus::Cancelled, "cancelled")
304 }
305
306 pub(crate) fn mark_delayed(&self, key: &TaskKey, rank: usize) -> Result<(), StudyError> {
307 let slot = managed_slot(&self.inner.slots, key)?;
308 if TaskStatus::decode(slot.status.load(Ordering::Acquire)) != TaskStatus::Pending {
309 return Err(StudyError::TaskAlreadyStarted {
310 identity: slot.identity.label().to_owned(),
311 });
312 }
313 *lock(&slot.detail) = format!("delayed start (rank {rank})").into_boxed_str();
314 Ok(())
315 }
316
317 pub(crate) fn request_cancellation(&self) {
318 self.inner.cancelled.store(true, Ordering::Release);
319 }
320
321 pub(crate) fn is_cancelled(&self) -> bool {
322 self.inner.cancelled.load(Ordering::Acquire)
323 }
324
325 pub(crate) fn cancellation_flag(&self) -> Arc<AtomicBool> {
326 Arc::clone(&self.inner.cancelled)
327 }
328
329 pub fn summary(&self) -> ProgressSummary {
331 summarize(&self.inner.slots)
332 }
333
334 pub(crate) fn task_execution_snapshots(&self) -> Vec<TaskExecutionSnapshot> {
335 self.inner
336 .slots
337 .iter()
338 .map(|slot| TaskExecutionSnapshot {
339 key: slot.identity.task_key().clone(),
340 status: TaskStatus::decode(slot.status.load(Ordering::Acquire)),
341 current_iteration: (slot.mode == TaskMode::Progress)
342 .then(|| slot.current.load(Ordering::Relaxed)),
343 target_iteration: (slot.mode == TaskMode::Progress
344 && slot.target_known.load(Ordering::Acquire))
345 .then(|| slot.target.load(Ordering::Relaxed)),
346 })
347 .collect()
348 }
349
350 pub fn complete(mut self, message: impl Into<String>) -> Result<ProgressSummary, StudyError> {
355 let summary = self.summary();
356 if !summary.is_success() {
357 self.stop(false, "study did not complete".to_owned())?;
358 return Err(StudyError::IncompleteProgress {
359 pending: summary.pending,
360 running: summary.running,
361 failed: summary.failed,
362 });
363 }
364 self.stop(true, message.into())?;
365 Ok(summary)
366 }
367
368 pub fn fail(mut self, message: impl Into<String>) -> Result<ProgressSummary, StudyError> {
370 let summary = self.summary();
371 self.stop(false, message.into())?;
372 Ok(summary)
373 }
374
375 fn stop(&mut self, success: bool, message: String) -> Result<(), StudyError> {
376 self.inner
377 .events
378 .send(RenderEvent::Stop { success, message })
379 .map_err(|_| StudyError::RendererUnavailable)?;
380 self.finished = true;
381 if self
382 .renderer
383 .take()
384 .expect("an unfinished study renderer owns one thread")
385 .join()
386 .is_err()
387 {
388 return Err(StudyError::RendererPanicked);
389 }
390 Ok(())
391 }
392}
393
394pub(crate) struct TaskExecutionSnapshot {
395 pub(crate) key: TaskKey,
396 pub(crate) status: TaskStatus,
397 pub(crate) current_iteration: Option<u64>,
398 pub(crate) target_iteration: Option<u64>,
399}
400
401impl fmt::Debug for StudyRenderer {
402 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
403 formatter
404 .debug_struct("StudyRenderer")
405 .field("tasks", &self.inner.slots.len())
406 .field("summary", &self.summary())
407 .finish_non_exhaustive()
408 }
409}
410
411impl Drop for StudyRenderer {
412 fn drop(&mut self) {
413 if self.finished {
414 return;
415 }
416 let _ = self.inner.events.send(RenderEvent::Stop {
417 success: false,
418 message: "study renderer dropped before completion".to_owned(),
419 });
420 if let Some(renderer) = self.renderer.take() {
421 let _ = renderer.join();
422 }
423 }
424}
425
426pub(crate) struct TaskProgressHandle {
432 slot: Arc<ProgressSlot>,
433 events: SyncSender<RenderEvent>,
434 cancelled: Arc<AtomicBool>,
435 active: bool,
436}
437
438#[derive(Clone, Debug)]
440pub struct CancellationToken(Arc<AtomicBool>);
441
442impl CancellationToken {
443 pub(crate) fn new() -> Self {
444 Self(Arc::new(AtomicBool::new(false)))
445 }
446
447 pub(crate) fn shared(&self) -> Arc<AtomicBool> {
448 Arc::clone(&self.0)
449 }
450
451 pub fn is_cancelled(&self) -> bool {
453 self.0.load(Ordering::Acquire)
454 }
455
456 pub fn cancel(&self) {
458 self.0.store(true, Ordering::Release);
459 }
460}
461
462impl TaskProgressHandle {
463 pub fn is_cancelled(&self) -> bool {
465 self.cancelled.load(Ordering::Acquire)
466 }
467
468 pub fn identity(&self) -> &TaskIdentity {
470 &self.slot.identity
471 }
472
473 pub fn current_iteration(&self) -> u64 {
475 self.slot.current.load(Ordering::Relaxed)
476 }
477
478 pub fn target_iteration(&self) -> Option<u64> {
480 if self.slot.target_known.load(Ordering::Acquire) {
481 Some(self.slot.target.load(Ordering::Relaxed))
482 } else {
483 None
484 }
485 }
486
487 pub fn set_target_iteration(&self, target: u64) -> Result<(), StudyError> {
489 let current = self.current_iteration();
490 if current > target {
491 return Err(StudyError::InitialIterationBeyondTarget {
492 identity: self.identity().label().to_owned(),
493 initial: current,
494 target,
495 });
496 }
497 self.slot.target.store(target, Ordering::Relaxed);
498 self.slot.target_known.store(true, Ordering::Release);
499 Ok(())
500 }
501
502 pub fn set_iteration(&self, iteration: u64) -> Result<(), StudyError> {
507 if let Some(target) = self.target_iteration().filter(|target| iteration > *target) {
508 return Err(StudyError::IterationBeyondTarget {
509 identity: self.identity().label().to_owned(),
510 iteration,
511 target,
512 });
513 }
514 let previous = self.slot.current.fetch_max(iteration, Ordering::Relaxed);
515 if iteration < previous {
516 return Err(StudyError::IterationRegressed {
517 identity: self.identity().label().to_owned(),
518 current: previous,
519 attempted: iteration,
520 });
521 }
522 Ok(())
523 }
524
525 pub fn should_continue(&self, iteration: u64) -> Result<bool, StudyError> {
532 self.set_iteration(iteration)?;
533 Ok(!self.is_cancelled()
534 && self
535 .target_iteration()
536 .is_none_or(|target| iteration < target))
537 }
538
539 pub fn set_detail(&self, detail: impl Into<String>) {
542 *lock(&self.slot.detail) = detail.into().into_boxed_str();
543 }
544
545 pub fn report(&self, message: impl Into<String>) -> Result<(), StudyError> {
547 self.events
548 .send(RenderEvent::TaskMessage {
549 identity: self.identity().label().to_owned(),
550 message: message.into(),
551 })
552 .map_err(|_| StudyError::RendererUnavailable)
553 }
554
555 pub fn complete(mut self, reason: Option<String>) -> Result<(), StudyError> {
561 if reason.is_none()
562 && let Some(target) = self.target_iteration()
563 {
564 let current = self.current_iteration();
565 if current != target {
566 *lock(&self.slot.detail) = "target not reached".into();
567 self.slot
568 .status
569 .store(TaskStatus::Failed.encode(), Ordering::Release);
570 self.active = false;
571 return Err(StudyError::TargetIterationNotReached {
572 identity: self.identity().label().to_owned(),
573 current,
574 target,
575 });
576 }
577 }
578 *lock(&self.slot.detail) = reason
579 .unwrap_or_else(|| "completed".to_owned())
580 .into_boxed_str();
581 self.slot
582 .status
583 .store(TaskStatus::Completed.encode(), Ordering::Release);
584 self.active = false;
585 Ok(())
586 }
587
588 pub fn fail(mut self, reason: impl Into<String>) {
590 *lock(&self.slot.detail) = reason.into().into_boxed_str();
591 self.slot
592 .status
593 .store(TaskStatus::Failed.encode(), Ordering::Release);
594 self.active = false;
595 }
596
597 pub(crate) fn cancel(mut self, reason: impl Into<String>) {
598 *lock(&self.slot.detail) = reason.into().into_boxed_str();
599 self.slot
600 .status
601 .store(TaskStatus::Cancelled.encode(), Ordering::Release);
602 self.active = false;
603 }
604}
605
606impl fmt::Debug for TaskProgressHandle {
607 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
608 formatter
609 .debug_struct("TaskProgressHandle")
610 .field("identity", &self.identity().label())
611 .field("current_iteration", &self.current_iteration())
612 .field("target_iteration", &self.target_iteration())
613 .field("active", &self.active)
614 .finish_non_exhaustive()
615 }
616}
617
618impl Drop for TaskProgressHandle {
619 fn drop(&mut self) {
620 if self.active {
621 *lock(&self.slot.detail) = "interrupted".into();
622 self.slot
623 .status
624 .store(TaskStatus::Failed.encode(), Ordering::Release);
625 }
626 }
627}
628
629pub(crate) struct OneShotTaskHandle {
634 slot: Arc<ProgressSlot>,
635 events: SyncSender<RenderEvent>,
636 cancelled: Arc<AtomicBool>,
637 active: bool,
638}
639
640impl OneShotTaskHandle {
641 pub fn is_cancelled(&self) -> bool {
643 self.cancelled.load(Ordering::Acquire)
644 }
645
646 pub fn identity(&self) -> &TaskIdentity {
648 &self.slot.identity
649 }
650
651 pub fn status(&self) -> TaskStatus {
653 TaskStatus::decode(self.slot.status.load(Ordering::Acquire))
654 }
655
656 pub fn set_detail(&self, detail: impl Into<String>) {
658 *lock(&self.slot.detail) = detail.into().into_boxed_str();
659 }
660
661 pub fn report(&self, message: impl Into<String>) -> Result<(), StudyError> {
663 self.events
664 .send(RenderEvent::TaskMessage {
665 identity: self.identity().label().to_owned(),
666 message: message.into(),
667 })
668 .map_err(|_| StudyError::RendererUnavailable)
669 }
670
671 pub fn complete(mut self) {
673 *lock(&self.slot.detail) = "completed".into();
674 self.slot
675 .status
676 .store(TaskStatus::Completed.encode(), Ordering::Release);
677 self.active = false;
678 }
679
680 pub fn fail(mut self, reason: impl Into<String>) {
682 *lock(&self.slot.detail) = reason.into().into_boxed_str();
683 self.slot
684 .status
685 .store(TaskStatus::Failed.encode(), Ordering::Release);
686 self.active = false;
687 }
688
689 pub(crate) fn cancel(mut self, reason: impl Into<String>) {
690 *lock(&self.slot.detail) = reason.into().into_boxed_str();
691 self.slot
692 .status
693 .store(TaskStatus::Cancelled.encode(), Ordering::Release);
694 self.active = false;
695 }
696}
697
698impl fmt::Debug for OneShotTaskHandle {
699 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
700 formatter
701 .debug_struct("OneShotTaskHandle")
702 .field("identity", &self.identity().label())
703 .field("status", &self.status())
704 .field("active", &self.active)
705 .finish_non_exhaustive()
706 }
707}
708
709impl Drop for OneShotTaskHandle {
710 fn drop(&mut self) {
711 if self.active {
712 *lock(&self.slot.detail) = "interrupted".into();
713 self.slot
714 .status
715 .store(TaskStatus::Failed.encode(), Ordering::Release);
716 }
717 }
718}
719
720struct RendererInner {
721 slots: Arc<[Arc<ProgressSlot>]>,
722 events: SyncSender<RenderEvent>,
723 cancelled: Arc<AtomicBool>,
724}
725
726struct ProgressSlot {
727 identity: TaskIdentity,
728 phase_label: Option<Arc<str>>,
729 mode: TaskMode,
730 current: AtomicU64,
731 target: AtomicU64,
732 target_known: AtomicBool,
733 started: AtomicBool,
734 status: AtomicU8,
735 detail: Mutex<Box<str>>,
736}
737
738enum RenderEvent {
739 TaskMessage { identity: String, message: String },
740 Stop { success: bool, message: String },
741}
742
743struct TerminalLease;
744
745impl Drop for TerminalLease {
746 fn drop(&mut self) {
747 TERMINAL_OWNED.store(false, Ordering::Release);
748 }
749}
750
751fn acquire_terminal() -> Result<(), StudyError> {
752 TERMINAL_OWNED
753 .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
754 .map(|_| ())
755 .map_err(|_| StudyError::TerminalAlreadyOwned)
756}
757
758fn resolve_output(output: OutputMode) -> OutputMode {
759 match output {
760 OutputMode::Auto if io::stderr().is_terminal() && io::stdin().is_terminal() => {
761 OutputMode::Terminal
762 }
763 OutputMode::Auto => OutputMode::Plain,
764 explicit => explicit,
765 }
766}
767
768fn start_renderer(
769 slots: Arc<[Arc<ProgressSlot>]>,
770 requested_output: OutputMode,
771 cancellation: Option<CancellationToken>,
772) -> Result<StudyRenderer, StudyError> {
773 acquire_terminal()?;
774 let lease = TerminalLease;
775 let cancelled = cancellation
776 .map(|token| token.shared())
777 .unwrap_or_else(|| Arc::new(AtomicBool::new(false)));
778 let mut output = resolve_output(requested_output);
779 let terminal = if output == OutputMode::Terminal {
780 match TerminalUi::enter(slots.len()) {
781 Ok(terminal) => Some(terminal),
782 Err(_) if requested_output == OutputMode::Auto => {
783 output = OutputMode::Plain;
784 None
785 }
786 Err(error) => {
787 return Err(StudyError::TerminalSetup {
788 operation: error.operation,
789 source: error.source,
790 });
791 }
792 }
793 } else {
794 None
795 };
796 let (events, receiver) = mpsc::sync_channel(MESSAGE_CAPACITY);
797 let renderer_slots = Arc::clone(&slots);
798 let renderer_cancelled = Arc::clone(&cancelled);
799 let renderer = match thread::Builder::new()
800 .name("scientific-workflow-progress".to_owned())
801 .spawn(move || {
802 render(
803 receiver,
804 renderer_slots,
805 output,
806 terminal,
807 renderer_cancelled,
808 lease,
809 )
810 }) {
811 Ok(renderer) => renderer,
812 Err(source) => return Err(StudyError::StartRenderer { source }),
813 };
814 Ok(StudyRenderer {
815 inner: Arc::new(RendererInner {
816 slots,
817 events,
818 cancelled,
819 }),
820 renderer: Some(renderer),
821 finished: false,
822 })
823}
824
825fn managed_slot(
826 slots: &[Arc<ProgressSlot>],
827 key: &TaskKey,
828) -> Result<Arc<ProgressSlot>, StudyError> {
829 slots
830 .iter()
831 .find(|slot| slot.identity.task_key() == key)
832 .cloned()
833 .ok_or_else(|| StudyError::UnknownTask {
834 task: key.to_string(),
835 })
836}
837
838fn mode_mismatch(slot: &ProgressSlot, requested: &'static str) -> StudyError {
839 StudyError::TaskModeMismatch {
840 task: slot.identity.task_key().to_string(),
841 requested,
842 actual: match slot.mode {
843 TaskMode::Progress => "progress",
844 TaskMode::OneShot => "one-shot",
845 },
846 }
847}
848
849fn mark_slot_completed(slot: &ProgressSlot) -> Result<(), StudyError> {
850 slot.status
851 .compare_exchange(
852 TaskStatus::Pending.encode(),
853 TaskStatus::Completed.encode(),
854 Ordering::AcqRel,
855 Ordering::Acquire,
856 )
857 .map_err(|_| StudyError::TaskAlreadyStarted {
858 identity: slot.identity.label().to_owned(),
859 })?;
860 *lock(&slot.detail) = "already completed".into();
861 Ok(())
862}
863
864fn mark_pending_terminal(
865 slot: &ProgressSlot,
866 status: TaskStatus,
867 detail: &'static str,
868) -> Result<(), StudyError> {
869 slot.status
870 .compare_exchange(
871 TaskStatus::Pending.encode(),
872 status.encode(),
873 Ordering::AcqRel,
874 Ordering::Acquire,
875 )
876 .map_err(|_| StudyError::TaskAlreadyStarted {
877 identity: slot.identity.label().to_owned(),
878 })?;
879 *lock(&slot.detail) = detail.into();
880 Ok(())
881}
882
883fn start_slot(
884 inner: &RendererInner,
885 slot: Arc<ProgressSlot>,
886 initial_iteration: u64,
887 target_iteration: Option<u64>,
888) -> Result<TaskProgressHandle, StudyError> {
889 if let Some(target) = target_iteration.filter(|target| initial_iteration > *target) {
890 return Err(StudyError::InitialIterationBeyondTarget {
891 identity: slot.identity.label().to_owned(),
892 initial: initial_iteration,
893 target,
894 });
895 }
896 slot.status
897 .compare_exchange(
898 TaskStatus::Pending.encode(),
899 TaskStatus::Running.encode(),
900 Ordering::AcqRel,
901 Ordering::Acquire,
902 )
903 .map_err(|_| StudyError::TaskAlreadyStarted {
904 identity: slot.identity.label().to_owned(),
905 })?;
906 slot.started.store(true, Ordering::Release);
907 slot.current.store(initial_iteration, Ordering::Relaxed);
908 if let Some(target) = target_iteration {
909 slot.target.store(target, Ordering::Relaxed);
910 slot.target_known.store(true, Ordering::Release);
911 } else {
912 slot.target_known.store(false, Ordering::Release);
913 }
914 *lock(&slot.detail) = "running".into();
915 Ok(TaskProgressHandle {
916 slot,
917 events: inner.events.clone(),
918 cancelled: Arc::clone(&inner.cancelled),
919 active: true,
920 })
921}
922
923fn start_one_shot_slot(
924 inner: &RendererInner,
925 slot: Arc<ProgressSlot>,
926) -> Result<OneShotTaskHandle, StudyError> {
927 slot.status
928 .compare_exchange(
929 TaskStatus::Pending.encode(),
930 TaskStatus::Running.encode(),
931 Ordering::AcqRel,
932 Ordering::Acquire,
933 )
934 .map_err(|_| StudyError::TaskAlreadyStarted {
935 identity: slot.identity.label().to_owned(),
936 })?;
937 slot.started.store(true, Ordering::Release);
938 slot.target_known.store(false, Ordering::Release);
939 *lock(&slot.detail) = "running".into();
940 Ok(OneShotTaskHandle {
941 slot,
942 events: inner.events.clone(),
943 cancelled: Arc::clone(&inner.cancelled),
944 active: true,
945 })
946}
947
948fn build_phase_slots(
949 phases: &[Phase],
950 heading: Option<&str>,
951) -> Result<Arc<[Arc<ProgressSlot>]>, StudyError> {
952 if phases.is_empty() {
953 return Err(StudyError::EmptyPhaseSet);
954 }
955 let mut phase_ids = HashSet::with_capacity(phases.len());
956 let capacity = phases.iter().map(|phase| phase.tasks().len()).sum();
957 let mut slots = Vec::with_capacity(capacity);
958 for phase in phases {
959 if !phase_ids.insert(phase.id()) {
960 return Err(StudyError::DuplicatePhaseId {
961 phase: phase.id().get(),
962 });
963 }
964 let phase_label: Arc<str> = heading.unwrap_or_else(|| phase.label()).into();
965 for task in phase.tasks() {
966 slots.push(Arc::new(ProgressSlot {
967 identity: TaskIdentity {
968 label: task.label().into(),
969 key: task.key().clone(),
970 metadata: task.metadata_map(),
971 },
972 phase_label: Some(Arc::clone(&phase_label)),
973 mode: task.mode(),
974 current: AtomicU64::new(0),
975 target: AtomicU64::new(0),
976 target_known: AtomicBool::new(false),
977 started: AtomicBool::new(false),
978 status: AtomicU8::new(TaskStatus::Pending.encode()),
979 detail: Mutex::new("pending".into()),
980 }));
981 }
982 }
983 Ok(slots.into())
984}
985
986fn summarize(slots: &[Arc<ProgressSlot>]) -> ProgressSummary {
987 let mut summary = ProgressSummary {
988 total: u64::try_from(slots.len()).expect("slot count originated from a u64 task count"),
989 pending: 0,
990 running: 0,
991 completed: 0,
992 failed: 0,
993 cancelled: 0,
994 skipped: 0,
995 };
996 for slot in slots {
997 match TaskStatus::decode(slot.status.load(Ordering::Acquire)) {
998 TaskStatus::Pending => summary.pending += 1,
999 TaskStatus::Running => summary.running += 1,
1000 TaskStatus::Completed => summary.completed += 1,
1001 TaskStatus::Failed => summary.failed += 1,
1002 TaskStatus::Cancelled => summary.cancelled += 1,
1003 TaskStatus::Skipped => summary.skipped += 1,
1004 }
1005 }
1006 summary
1007}
1008
1009fn render(
1010 receiver: Receiver<RenderEvent>,
1011 slots: Arc<[Arc<ProgressSlot>]>,
1012 output: OutputMode,
1013 mut terminal: Option<TerminalUi>,
1014 cancelled: Arc<AtomicBool>,
1015 _lease: TerminalLease,
1016) {
1017 let mut last_statuses = vec![TaskStatus::Pending; slots.len()];
1018 let mut input_failed = false;
1019 loop {
1020 if let Some(display) = &mut terminal {
1021 if !input_failed {
1022 match display.poll_command() {
1023 Ok(Some(StudyCommand::Exit)) => {
1024 cancelled.store(true, Ordering::Release);
1025 display.mark_exit_requested();
1026 }
1027 Ok(None) => {}
1028 Err(error) => {
1029 cancelled.store(true, Ordering::Release);
1030 display.push_message(format!("study: terminal input failed: {error}"));
1031 display.mark_exit_requested();
1032 input_failed = true;
1033 }
1034 }
1035 }
1036 let snapshot = render_snapshot(&slots);
1037 let _ = display.draw(&snapshot);
1038 }
1039 match receiver.recv_timeout(REFRESH_INTERVAL) {
1040 Ok(RenderEvent::TaskMessage { identity, message }) => {
1041 let message = format!("{identity}: {message}");
1042 if let Some(display) = &mut terminal {
1043 display.push_message(message);
1044 } else {
1045 write_message(output, &message);
1046 }
1047 }
1048 Ok(RenderEvent::Stop { success, message }) => {
1049 if let Some(display) = &mut terminal {
1050 let snapshot = render_snapshot(&slots);
1051 let _ = display.draw(&snapshot);
1052 }
1053 if output == OutputMode::Plain {
1054 write_plain_transitions(&slots, &mut last_statuses);
1055 }
1056 drop(terminal.take());
1057 write_final(output, &slots, success, &message);
1058 break;
1059 }
1060 Err(RecvTimeoutError::Timeout) => {
1061 if output == OutputMode::Plain {
1062 write_plain_transitions(&slots, &mut last_statuses);
1063 }
1064 }
1065 Err(RecvTimeoutError::Disconnected) => break,
1066 }
1067 }
1068}
1069
1070fn render_snapshot(slots: &[Arc<ProgressSlot>]) -> RenderSnapshot {
1071 RenderSnapshot {
1072 heading: slots
1073 .first()
1074 .and_then(|slot| slot.phase_label.as_deref())
1075 .unwrap_or("Study")
1076 .to_owned(),
1077 summary: summarize(slots),
1078 tasks: terminal_task_views(slots),
1079 }
1080}
1081
1082fn terminal_task_views(slots: &[Arc<ProgressSlot>]) -> Vec<TaskView> {
1083 slots
1084 .iter()
1085 .map(|slot| TaskView {
1086 label: slot.identity.label().to_owned(),
1087 mode: slot.mode,
1088 current: slot.current.load(Ordering::Relaxed),
1089 target: slot
1090 .target_known
1091 .load(Ordering::Acquire)
1092 .then(|| slot.target.load(Ordering::Relaxed)),
1093 started: slot.started.load(Ordering::Acquire),
1094 status: TaskStatus::decode(slot.status.load(Ordering::Acquire)),
1095 detail: lock(&slot.detail).to_string(),
1096 })
1097 .collect()
1098}
1099
1100fn write_message(output: OutputMode, message: &str) {
1101 match output {
1102 OutputMode::Plain => eprintln!("[progress] {message}"),
1103 OutputMode::Terminal | OutputMode::Hidden | OutputMode::Auto => {}
1104 }
1105}
1106
1107fn write_plain_transitions(slots: &[Arc<ProgressSlot>], previous: &mut [TaskStatus]) {
1108 for (slot, old) in slots.iter().zip(previous) {
1109 let status = TaskStatus::decode(slot.status.load(Ordering::Acquire));
1110 if status != *old {
1111 let detail = lock(&slot.detail);
1112 eprintln!(
1113 "[task] identity={} status={} detail={} iteration={} target={}",
1114 slot.identity.task_key(),
1115 status.label(),
1116 detail.as_ref(),
1117 slot.current.load(Ordering::Relaxed),
1118 format_target(slot)
1119 );
1120 *old = status;
1121 }
1122 }
1123}
1124
1125fn write_final(output: OutputMode, slots: &[Arc<ProgressSlot>], success: bool, message: &str) {
1126 if output == OutputMode::Hidden || (output == OutputMode::Terminal && success) {
1127 return;
1128 }
1129 let summary = summarize(slots);
1130 if !success {
1131 for slot in slots {
1132 let detail = lock(&slot.detail);
1133 eprintln!(
1134 "[task-final] task={} status={} detail={}",
1135 slot.identity.task_key(),
1136 TaskStatus::decode(slot.status.load(Ordering::Acquire)).label(),
1137 detail.as_ref(),
1138 );
1139 }
1140 }
1141 eprintln!(
1142 "[study] status={} tasks={} completed={} failed={} cancelled={} skipped={} pending={} message={}",
1143 if success { "completed" } else { "failed" },
1144 summary.total,
1145 summary.completed,
1146 summary.failed,
1147 summary.cancelled,
1148 summary.skipped,
1149 summary.pending,
1150 message
1151 );
1152}
1153
1154fn format_target(slot: &ProgressSlot) -> String {
1155 if slot.target_known.load(Ordering::Acquire) {
1156 slot.target.load(Ordering::Relaxed).to_string()
1157 } else {
1158 "unknown".to_owned()
1159 }
1160}
1161
1162fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
1163 mutex
1164 .lock()
1165 .unwrap_or_else(std::sync::PoisonError::into_inner)
1166}