Skip to main content

scientific_workflow/
runtime.rs

1//! Phase-based scheduling and centralized runtime display.
2//!
3//! Applications declare the complete `config -> tasks -> phases -> runtime`
4//! structure before execution. [`WorkflowRuntime`] schedules only those tasks,
5//! displays their lifecycle, and provides cooperative cancellation. Each task
6//! workload owns all scientific I/O, artifacts, recordings, and subprocesses.
7//!
8//! ```no_run
9//! use scientific_workflow::prelude::basics::*;
10//! use scientific_workflow::prelude::runtime::*;
11//!
12//! # fn main() -> Result<(), RuntimeError> {
13//! let project = ScientificProject::load("my-project")
14//!     .map_err(|error| RuntimeError::TaskWorkload {
15//!         task: "load-project".to_owned(),
16//!         source: Box::new(error),
17//!     })?;
18//! let phase = Phase::builder(2, "simulation")
19//!     .activity_tasks_from_project(&project, "prepare", |context| {
20//!         context.set_detail("ready");
21//!         Ok(())
22//!     })
23//!     .max_concurrent_workloads(1)
24//!     .queue_capacity(1)
25//!     .build()?;
26//! let summary = WorkflowRuntime::builder()
27//!     .phase(phase)
28//!     .hidden()
29//!     .build()?
30//!     .run_phases([2])?;
31//! assert!(summary.is_success());
32//! # Ok(())
33//! # }
34//! ```
35
36use std::collections::{HashMap, HashSet};
37use std::fmt;
38use std::sync::Arc;
39use std::sync::atomic::{AtomicBool, Ordering};
40
41mod error;
42mod phase;
43mod renderer;
44mod reporting;
45mod scheduler;
46mod task;
47
48pub use error::RuntimeError;
49pub use phase::{
50    Phase, PhaseBuilder, PhaseFailurePolicy, PhaseId, Task, TaskDisplayKind, TaskId, TaskKey,
51    TaskSelector,
52};
53pub use reporting::{
54    ActivityTask, CancellationToken, ProgressSummary, TaskIdentity, TaskProgress, TaskStatus,
55};
56pub use task::{TaskContext, TaskResult};
57
58use renderer::RuntimeOutput;
59use reporting::RuntimeReporter;
60
61static RUNTIME_OWNED: AtomicBool = AtomicBool::new(false);
62
63type SatisfiedPhaseVerifier = Arc<dyn Fn(PhaseId) -> bool + Send + Sync + 'static>;
64
65/// Builder for one immutable runtime plan.
66pub struct WorkflowRuntimeBuilder {
67    phases: Vec<Phase>,
68    output: RuntimeOutput,
69    satisfied_phase: Option<SatisfiedPhaseVerifier>,
70}
71
72impl WorkflowRuntimeBuilder {
73    /// Adds one already validated nonempty phase.
74    pub fn phase(mut self, phase: Phase) -> Self {
75        self.phases.push(phase);
76        self
77    }
78
79    /// Adds phases in deterministic declaration order.
80    pub fn phases<I>(mut self, phases: I) -> Self
81    where
82        I: IntoIterator<Item = Phase>,
83    {
84        self.phases.extend(phases);
85        self
86    }
87
88    /// Supplies application verification for an omitted completed dependency.
89    pub fn satisfied_phase_verifier<F>(mut self, verifier: F) -> Self
90    where
91        F: Fn(PhaseId) -> bool + Send + Sync + 'static,
92    {
93        self.satisfied_phase = Some(Arc::new(verifier));
94        self
95    }
96
97    /// Selects automatic terminal/plain output detection.
98    pub fn automatic(mut self) -> Self {
99        self.output = RuntimeOutput::Auto;
100        self
101    }
102
103    /// Forces cursor-controlled interactive display.
104    pub fn terminal(mut self) -> Self {
105        self.output = RuntimeOutput::Terminal;
106        self
107    }
108
109    /// Forces append-only uncolored line output.
110    pub fn plain(mut self) -> Self {
111        self.output = RuntimeOutput::Plain;
112        self
113    }
114
115    /// Suppresses display while preserving scheduling and lifecycle checks.
116    pub fn hidden(mut self) -> Self {
117        self.output = RuntimeOutput::Hidden;
118        self
119    }
120
121    /// Validates the complete phase/task/dependency plan.
122    pub fn build(self) -> Result<WorkflowRuntime, RuntimeError> {
123        validate_plan(&self.phases)?;
124        Ok(WorkflowRuntime {
125            phases: self.phases,
126            output: self.output,
127            satisfied_phase: self.satisfied_phase,
128            cancellation: CancellationToken::new(),
129        })
130    }
131}
132
133impl fmt::Debug for WorkflowRuntimeBuilder {
134    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
135        formatter
136            .debug_struct("WorkflowRuntimeBuilder")
137            .field("phases", &self.phases.len())
138            .field("output", &self.output)
139            .field(
140                "has_satisfied_phase_verifier",
141                &self.satisfied_phase.is_some(),
142            )
143            .finish_non_exhaustive()
144    }
145}
146
147/// Non-clone scheduler and display owner for one declared workflow plan.
148pub struct WorkflowRuntime {
149    phases: Vec<Phase>,
150    output: RuntimeOutput,
151    satisfied_phase: Option<SatisfiedPhaseVerifier>,
152    cancellation: CancellationToken,
153}
154
155impl WorkflowRuntime {
156    /// Starts an empty builder. At least one phase is mandatory.
157    pub fn builder() -> WorkflowRuntimeBuilder {
158        WorkflowRuntimeBuilder {
159            phases: Vec::new(),
160            output: RuntimeOutput::Auto,
161            satisfied_phase: None,
162        }
163    }
164
165    /// Borrows all registered phases in deterministic declaration order.
166    pub fn phases(&self) -> &[Phase] {
167        &self.phases
168    }
169
170    /// Borrows one registered phase by stable ID.
171    pub fn phase(&self, id: impl Into<PhaseId>) -> Option<&Phase> {
172        let id = id.into();
173        self.phases.iter().find(|phase| phase.id() == id)
174    }
175
176    /// Returns a cheap token that can request or observe runtime cancellation.
177    pub fn cancellation_token(&self) -> CancellationToken {
178        self.cancellation.clone()
179    }
180
181    /// Returns the unique task matching an exact partial selector.
182    pub fn unique_task_matching(&self, selector: &TaskSelector) -> Result<&Task, RuntimeError> {
183        let mut matches = self
184            .phases
185            .iter()
186            .flat_map(|phase| phase.tasks())
187            .filter(|task| selector.matches(task));
188        let first = matches
189            .next()
190            .ok_or_else(|| RuntimeError::ManagedTaskNotFound {
191                selector: selector.to_string(),
192            })?;
193        if let Some(second) = matches.next() {
194            return Err(RuntimeError::ManagedTaskSelectorAmbiguous {
195                selector: selector.to_string(),
196                first: first.key().to_string(),
197                second: second.key().to_string(),
198            });
199        }
200        Ok(first)
201    }
202
203    /// Runs exactly the selected phases; omitted unsatisfied dependencies fail.
204    pub fn run_phases<I, P>(self, phases: I) -> Result<RuntimeSummary, RuntimeError>
205    where
206        I: IntoIterator<Item = P>,
207        P: Into<PhaseId>,
208    {
209        self.run_phases_exact(phases)
210    }
211
212    /// Runs exactly the selected phases; omitted unsatisfied dependencies fail.
213    pub fn run_phases_exact<I, P>(self, phases: I) -> Result<RuntimeSummary, RuntimeError>
214    where
215        I: IntoIterator<Item = P>,
216        P: Into<PhaseId>,
217    {
218        let selected = self.select_exact(phases)?;
219        self.execute(selected)
220    }
221
222    /// Adds unsatisfied dependencies and runs the deterministic closure.
223    pub fn run_phases_with_dependencies<I, P>(
224        self,
225        phases: I,
226    ) -> Result<RuntimeSummary, RuntimeError>
227    where
228        I: IntoIterator<Item = P>,
229        P: Into<PhaseId>,
230    {
231        let selected = self.select_with_dependencies(phases)?;
232        self.execute(selected)
233    }
234
235    fn select_exact<I, P>(&self, phases: I) -> Result<Vec<usize>, RuntimeError>
236    where
237        I: IntoIterator<Item = P>,
238        P: Into<PhaseId>,
239    {
240        let requested = selected_ids(&self.phases, phases)?;
241        for phase in self
242            .phases
243            .iter()
244            .filter(|phase| requested.contains(&phase.id()))
245        {
246            for dependency in phase.dependencies() {
247                if !requested.contains(dependency) && !self.is_satisfied(*dependency) {
248                    return Err(RuntimeError::UnsatisfiedPhaseDependency {
249                        phase: phase.id().get(),
250                        dependency: dependency.get(),
251                    });
252                }
253            }
254        }
255        Ok(topological_positions(&self.phases)?
256            .into_iter()
257            .filter(|position| requested.contains(&self.phases[*position].id()))
258            .collect())
259    }
260
261    fn select_with_dependencies<I, P>(&self, phases: I) -> Result<Vec<usize>, RuntimeError>
262    where
263        I: IntoIterator<Item = P>,
264        P: Into<PhaseId>,
265    {
266        let mut selected = selected_ids(&self.phases, phases)?;
267        let positions: HashMap<_, _> = self
268            .phases
269            .iter()
270            .enumerate()
271            .map(|(position, phase)| (phase.id(), position))
272            .collect();
273        let mut pending: Vec<_> = selected.iter().copied().collect();
274        while let Some(id) = pending.pop() {
275            let phase = &self.phases[positions[&id]];
276            for dependency in phase.dependencies() {
277                if !self.is_satisfied(*dependency) && selected.insert(*dependency) {
278                    pending.push(*dependency);
279                }
280            }
281        }
282        Ok(topological_positions(&self.phases)?
283            .into_iter()
284            .filter(|position| selected.contains(&self.phases[*position].id()))
285            .collect())
286    }
287
288    fn is_satisfied(&self, phase: PhaseId) -> bool {
289        self.satisfied_phase
290            .as_ref()
291            .is_some_and(|verify| verify(phase))
292    }
293
294    fn execute(self, selected: Vec<usize>) -> Result<RuntimeSummary, RuntimeError> {
295        let _lease = RuntimeLease::acquire()?;
296        let total_phases = selected.len();
297        let total_tasks = selected
298            .iter()
299            .map(|position| self.phases[*position].tasks().len())
300            .sum();
301        let mut summaries = Vec::with_capacity(total_phases);
302        let mut phases = self.phases.into_iter().map(Some).collect::<Vec<_>>();
303
304        for (selection_position, phase_position) in selected.iter().copied().enumerate() {
305            let phase = phases[phase_position]
306                .take()
307                .expect("selected phase positions are unique");
308            renderer::phase_start(self.output, &phase, selection_position + 1, total_phases);
309            let heading = renderer::phase_heading(&phase, selection_position + 1, total_phases);
310            let builder = RuntimeReporter::for_phase(&phase, &heading)?
311                .cancellation_token(self.cancellation.clone());
312            let reporter = match self.output {
313                RuntimeOutput::Auto => builder,
314                RuntimeOutput::Terminal => builder.terminal(),
315                RuntimeOutput::Plain => builder.plain(),
316                RuntimeOutput::Hidden => builder.hidden(),
317            }
318            .start()?;
319            let phase_id = phase.id();
320            let phase_label: Arc<str> = phase.label().into();
321            let require_confirm = phase.requires_confirmation();
322            let result = scheduler::execute_phase(phase, &reporter);
323            let progress = if result.is_ok() {
324                reporter.complete(format!("phase {phase_id} completed"))?
325            } else {
326                reporter.fail(format!("phase {phase_id} failed"))?
327            };
328            let success = result.is_ok() && progress.is_success();
329            renderer::phase_complete(self.output, phase_id, &phase_label, success);
330            summaries.push(PhaseSummary {
331                id: phase_id,
332                label: phase_label,
333                progress,
334            });
335            if let Err(error) = result {
336                renderer::runtime_complete(self.output, summaries.len(), total_tasks, false);
337                return Err(RuntimeError::PhaseExecutionFailed {
338                    summary: RuntimeSummary {
339                        phases: summaries.into(),
340                    },
341                    source: Box::new(error),
342                });
343            }
344            if require_confirm && selection_position + 1 < total_phases {
345                let next = phases[selected[selection_position + 1]]
346                    .as_ref()
347                    .expect("the next selected phase has not executed");
348                let confirmed = match renderer::confirm_transition(phase_id, next) {
349                    Ok(confirmed) => confirmed,
350                    Err(source) => {
351                        renderer::runtime_complete(
352                            self.output,
353                            summaries.len(),
354                            total_tasks,
355                            false,
356                        );
357                        return Err(RuntimeError::PhaseExecutionFailed {
358                            summary: RuntimeSummary {
359                                phases: summaries.into(),
360                            },
361                            source: Box::new(RuntimeError::PhaseConfirmationInput {
362                                phase: phase_id.get(),
363                                source,
364                            }),
365                        });
366                    }
367                };
368                if !confirmed {
369                    renderer::runtime_complete(self.output, summaries.len(), total_tasks, false);
370                    return Err(RuntimeError::PhaseExecutionFailed {
371                        summary: RuntimeSummary {
372                            phases: summaries.into(),
373                        },
374                        source: Box::new(RuntimeError::PhaseConfirmationEof {
375                            phase: phase_id.get(),
376                        }),
377                    });
378                }
379            }
380        }
381
382        renderer::runtime_complete(self.output, summaries.len(), total_tasks, true);
383        Ok(RuntimeSummary {
384            phases: summaries.into(),
385        })
386    }
387}
388
389impl fmt::Debug for WorkflowRuntime {
390    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
391        formatter
392            .debug_struct("WorkflowRuntime")
393            .field("phases", &self.phases.len())
394            .field(
395                "tasks",
396                &self.phases.iter().map(|p| p.tasks().len()).sum::<usize>(),
397            )
398            .field("output", &self.output)
399            .finish_non_exhaustive()
400    }
401}
402
403/// Terminal summary for one selected phase.
404#[derive(Clone, Debug, Eq, PartialEq)]
405pub struct PhaseSummary {
406    id: PhaseId,
407    label: Arc<str>,
408    progress: ProgressSummary,
409}
410
411impl PhaseSummary {
412    pub const fn id(&self) -> PhaseId {
413        self.id
414    }
415
416    pub fn label(&self) -> &str {
417        &self.label
418    }
419
420    pub fn progress(&self) -> &ProgressSummary {
421        &self.progress
422    }
423
424    pub fn is_success(&self) -> bool {
425        self.progress.is_success()
426    }
427}
428
429/// Immutable aggregate for a completed selected runtime plan.
430#[derive(Clone, Debug, Eq, PartialEq)]
431pub struct RuntimeSummary {
432    phases: Arc<[PhaseSummary]>,
433}
434
435impl RuntimeSummary {
436    pub fn phases(&self) -> &[PhaseSummary] {
437        &self.phases
438    }
439
440    pub fn total_tasks(&self) -> u64 {
441        self.phases.iter().map(|phase| phase.progress.total()).sum()
442    }
443
444    pub fn is_success(&self) -> bool {
445        !self.phases.is_empty() && self.phases.iter().all(PhaseSummary::is_success)
446    }
447}
448
449struct RuntimeLease;
450
451impl RuntimeLease {
452    fn acquire() -> Result<Self, RuntimeError> {
453        RUNTIME_OWNED
454            .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
455            .map(|_| Self)
456            .map_err(|_| RuntimeError::TerminalAlreadyOwned)
457    }
458}
459
460impl Drop for RuntimeLease {
461    fn drop(&mut self) {
462        RUNTIME_OWNED.store(false, Ordering::Release);
463    }
464}
465
466fn selected_ids<I, P>(phases: &[Phase], requested: I) -> Result<HashSet<PhaseId>, RuntimeError>
467where
468    I: IntoIterator<Item = P>,
469    P: Into<PhaseId>,
470{
471    let known: HashSet<_> = phases.iter().map(Phase::id).collect();
472    let selected: HashSet<_> = requested.into_iter().map(Into::into).collect();
473    if selected.is_empty() {
474        return Err(RuntimeError::EmptyPhaseSet);
475    }
476    if let Some(unknown) = selected.iter().find(|id| !known.contains(id)) {
477        return Err(RuntimeError::UnknownSelectedPhase {
478            phase: unknown.get(),
479        });
480    }
481    Ok(selected)
482}
483
484fn validate_plan(phases: &[Phase]) -> Result<(), RuntimeError> {
485    if phases.is_empty() {
486        return Err(RuntimeError::EmptyPhaseSet);
487    }
488    let mut ids = HashSet::with_capacity(phases.len());
489    for phase in phases {
490        if !ids.insert(phase.id()) {
491            return Err(RuntimeError::DuplicatePhaseId {
492                phase: phase.id().get(),
493            });
494        }
495    }
496    for phase in phases {
497        for dependency in phase.dependencies() {
498            if !ids.contains(dependency) {
499                return Err(RuntimeError::UnknownPhaseDependency {
500                    phase: phase.id().get(),
501                    dependency: dependency.get(),
502                });
503            }
504        }
505        for task in phase.tasks() {
506            if !task.has_workload() && !task.is_reused() {
507                return Err(RuntimeError::MissingTaskWorkload {
508                    task: task.key().to_string(),
509                });
510            }
511        }
512    }
513    topological_positions(phases).map(|_| ())
514}
515
516fn topological_positions(phases: &[Phase]) -> Result<Vec<usize>, RuntimeError> {
517    let positions: HashMap<_, _> = phases
518        .iter()
519        .enumerate()
520        .map(|(position, phase)| (phase.id(), position))
521        .collect();
522    let mut states = vec![0_u8; phases.len()];
523    let mut ordered = Vec::with_capacity(phases.len());
524    fn visit(
525        position: usize,
526        phases: &[Phase],
527        positions: &HashMap<PhaseId, usize>,
528        states: &mut [u8],
529        ordered: &mut Vec<usize>,
530    ) -> Result<(), RuntimeError> {
531        match states[position] {
532            2 => return Ok(()),
533            1 => {
534                return Err(RuntimeError::PhaseDependencyCycle {
535                    phase: phases[position].id().get(),
536                });
537            }
538            _ => {}
539        }
540        states[position] = 1;
541        for dependency in phases[position].dependencies() {
542            visit(positions[dependency], phases, positions, states, ordered)?;
543        }
544        states[position] = 2;
545        ordered.push(position);
546        Ok(())
547    }
548    for position in 0..phases.len() {
549        visit(position, phases, &positions, &mut states, &mut ordered)?;
550    }
551    Ok(ordered)
552}