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