use std::collections::{HashMap, HashSet, VecDeque};
use std::path::{Path, PathBuf};
use std::sync::mpsc::{channel, Receiver};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use crate::assist::Suggestion;
use crate::cache::Cache;
use crate::core::config::{format_duration, Step, CONDITION_VARS};
use crate::core::logging as log;
use crate::core::runner::fmt_duration;
use crate::runners::shell::{LineSink, Stream};
use crate::runners::{containers, shell};
#[derive(Debug)]
struct Node {
step: Step,
deps: Vec<usize>,
dependents: Vec<usize>,
}
#[derive(Debug)]
pub struct Graph {
nodes: Vec<Node>,
explicit: bool,
}
#[derive(Debug)]
pub struct GraphError(pub String);
impl std::fmt::Display for GraphError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl std::error::Error for GraphError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NodeStatus {
Ok,
Cached,
Hook,
Failed,
TimedOut,
Skipped,
Conditional,
Errored,
}
impl NodeStatus {
fn is_blocking(self) -> bool {
matches!(
self,
NodeStatus::Failed | NodeStatus::TimedOut | NodeStatus::Skipped | NodeStatus::Errored
)
}
pub fn code(self) -> &'static str {
match self {
NodeStatus::Ok => "ok",
NodeStatus::Cached => "cached",
NodeStatus::Hook => "hook",
NodeStatus::Failed => "failed",
NodeStatus::TimedOut => "timeout",
NodeStatus::Skipped => "skipped",
NodeStatus::Conditional => "conditional",
NodeStatus::Errored => "errored",
}
}
}
struct NodeResult {
idx: usize,
status: NodeStatus,
duration: Duration,
}
#[derive(Debug, Clone)]
pub struct StepRecord {
pub name: String,
pub status: NodeStatus,
pub duration: Duration,
}
pub struct GraphOutcome {
pub records: Vec<StepRecord>,
pub success: bool,
pub total: Duration,
}
impl GraphOutcome {
pub fn ran(&self) -> usize {
self.records
.iter()
.filter(|r| matches!(r.status, NodeStatus::Ok | NodeStatus::Failed))
.count()
}
}
pub struct ExecCtx {
pub project_root: PathBuf,
pub use_cache: bool,
pub vars: HashMap<String, String>,
pub secrets: HashMap<String, String>,
pub max_parallel: usize,
pub timeout: Option<Duration>,
pub container_image: Option<String>,
}
impl ExecCtx {
pub fn new(project_root: impl Into<PathBuf>) -> Self {
ExecCtx {
project_root: project_root.into(),
use_cache: true,
vars: HashMap::new(),
secrets: HashMap::new(),
max_parallel: default_parallelism(),
timeout: Some(DEFAULT_STEP_TIMEOUT),
container_image: None,
}
}
}
pub const DEFAULT_STEP_TIMEOUT: Duration = Duration::from_secs(30 * 60);
pub fn default_parallelism() -> usize {
std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(4)
.clamp(1, 16)
}
pub fn select_with_deps(steps: &[Step], targets: &[&str]) -> Vec<Step> {
let index: HashMap<&str, usize> = steps
.iter()
.enumerate()
.map(|(i, s)| (s.name.as_str(), i))
.collect();
let mut keep: HashSet<usize> = HashSet::new();
let mut stack: Vec<usize> = targets
.iter()
.filter_map(|t| index.get(t).copied())
.collect();
while let Some(i) = stack.pop() {
if !keep.insert(i) {
continue;
}
for need in &steps[i].needs {
if let Some(&d) = index.get(need.as_str()) {
stack.push(d);
}
}
}
steps
.iter()
.enumerate()
.filter(|(i, _)| keep.contains(i))
.map(|(_, s)| s.clone())
.collect()
}
pub fn build_vars(root: &Path) -> HashMap<String, String> {
let mut vars = HashMap::new();
vars.insert(
"branch".to_string(),
git_out(root, &["branch", "--show-current"]).unwrap_or_default(),
);
vars.insert(
"tag".to_string(),
git_out(root, &["describe", "--tags", "--exact-match"]).unwrap_or_default(),
);
vars.insert(
"flux_env".to_string(),
std::env::var("FLUX_ENV").unwrap_or_else(|_| "default".to_string()),
);
debug_assert!(
vars.len() == CONDITION_VARS.len() && CONDITION_VARS.iter().all(|v| vars.contains_key(*v)),
"build_vars must bind exactly the documented only_if variables"
);
vars
}
fn git_out(root: &Path, args: &[&str]) -> Option<String> {
let out = std::process::Command::new("git")
.args(args)
.current_dir(root)
.output()
.ok()?;
if !out.status.success() {
return None;
}
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
if s.is_empty() {
None
} else {
Some(s)
}
}
impl Graph {
pub fn build(steps: &[Step]) -> Result<Graph, GraphError> {
let index: HashMap<&str, usize> = steps
.iter()
.enumerate()
.map(|(i, s)| (s.name.as_str(), i))
.collect();
if index.len() != steps.len() {
return Err(GraphError("duplicate step names in pipeline".into()));
}
let uses_needs = steps.iter().any(|s| !s.needs.is_empty());
let mut nodes: Vec<Node> = steps
.iter()
.map(|s| Node {
step: s.clone(),
deps: Vec::new(),
dependents: Vec::new(),
})
.collect();
if uses_needs {
for (i, step) in steps.iter().enumerate() {
for need in &step.needs {
let dep = *index.get(need.as_str()).ok_or_else(|| {
GraphError(format!(
"step '{}' needs unknown step '{}'",
step.name, need
))
})?;
if dep == i {
return Err(GraphError(format!("step '{}' needs itself", step.name)));
}
nodes[i].deps.push(dep);
nodes[dep].dependents.push(i);
}
}
} else {
for i in 1..nodes.len() {
nodes[i].deps.push(i - 1);
nodes[i - 1].dependents.push(i);
}
}
let graph = Graph {
nodes,
explicit: uses_needs,
};
graph.check_acyclic()?;
Ok(graph)
}
pub fn is_explicit(&self) -> bool {
self.explicit
}
fn check_acyclic(&self) -> Result<(), GraphError> {
let mut indeg: Vec<usize> = self.nodes.iter().map(|n| n.deps.len()).collect();
let mut queue: VecDeque<usize> = (0..self.nodes.len()).filter(|&i| indeg[i] == 0).collect();
let mut visited = 0;
while let Some(i) = queue.pop_front() {
visited += 1;
for &d in &self.nodes[i].dependents {
indeg[d] -= 1;
if indeg[d] == 0 {
queue.push_back(d);
}
}
}
if visited != self.nodes.len() {
let in_cycle: Vec<&str> = self
.nodes
.iter()
.zip(indeg.iter())
.filter(|(_, &d)| d > 0)
.map(|(n, _)| n.step.name.as_str())
.collect();
return Err(GraphError(format!(
"pipeline has a dependency cycle involving: {}",
in_cycle.join(", ")
)));
}
Ok(())
}
pub fn topo_order(&self) -> Vec<String> {
let mut indeg: Vec<usize> = self.nodes.iter().map(|n| n.deps.len()).collect();
let mut queue: VecDeque<usize> = (0..self.nodes.len()).filter(|&i| indeg[i] == 0).collect();
let mut order = Vec::new();
while let Some(i) = queue.pop_front() {
order.push(self.nodes[i].step.name.clone());
for &d in &self.nodes[i].dependents {
indeg[d] -= 1;
if indeg[d] == 0 {
queue.push_back(d);
}
}
}
order
}
pub fn execute(&self, ctx: &ExecCtx) -> GraphOutcome {
let n = self.nodes.len();
let mut status: Vec<Option<NodeStatus>> = vec![None; n];
let mut indeg: Vec<usize> = self.nodes.iter().map(|node| node.deps.len()).collect();
let mut durations: Vec<Duration> = vec![Duration::ZERO; n];
let mut rebuilt: Vec<bool> = vec![false; n];
let mut force: Vec<bool> = vec![false; n];
let (work_tx, work_rx) = channel::<(usize, bool)>();
let work_rx = Arc::new(Mutex::new(work_rx));
let (res_tx, res_rx) = channel::<NodeResult>();
let workers = ctx.max_parallel.clamp(1, n.max(1));
let outcome = std::thread::scope(|scope| {
for _ in 0..workers {
let work_rx: Arc<Mutex<Receiver<(usize, bool)>>> = Arc::clone(&work_rx);
let res_tx = res_tx.clone();
scope.spawn(move || loop {
let job = {
let rx = work_rx.lock().unwrap();
rx.recv()
};
match job {
Ok((i, force)) => {
let result = self.run_node(i, ctx, force);
if res_tx.send(result).is_err() {
break;
}
}
Err(_) => break, }
});
}
drop(res_tx);
let mut ready: VecDeque<usize> = (0..n).filter(|&i| indeg[i] == 0).collect();
let mut inflight = 0usize;
let mut finished = 0usize;
loop {
while let Some(i) = ready.pop_front() {
if status[i].is_some() {
continue; }
work_tx.send((i, force[i])).expect("workers alive");
inflight += 1;
log::info_line(&format!(
" {} {}",
log::dim("queued"),
self.nodes[i].step.name
));
}
if finished == n {
break;
}
if inflight == 0 {
for s in status.iter_mut() {
if s.is_none() {
*s = Some(NodeStatus::Skipped);
}
}
break;
}
let result = match res_rx.recv() {
Ok(r) => r,
Err(_) => break,
};
inflight -= 1;
finished += 1;
status[result.idx] = Some(result.status);
durations[result.idx] = result.duration;
rebuilt[result.idx] = result.status == NodeStatus::Ok;
if result.status.is_blocking() {
finished += self.cascade_skip(result.idx, &mut status);
} else {
for &dep in &self.nodes[result.idx].dependents {
if status[dep].is_some() {
continue;
}
indeg[dep] = indeg[dep].saturating_sub(1);
if indeg[dep] == 0 {
force[dep] = self.nodes[dep].deps.iter().any(|&d| rebuilt[d]);
ready.push_back(dep);
}
}
}
}
drop(work_tx);
let records: Vec<StepRecord> = self
.nodes
.iter()
.enumerate()
.map(|(i, node)| StepRecord {
name: node.step.name.clone(),
status: status[i].unwrap_or(NodeStatus::Skipped),
duration: durations[i],
})
.collect();
let success = records.iter().all(|r| !r.status.is_blocking());
let total = durations.iter().copied().sum();
GraphOutcome {
records,
success,
total,
}
});
outcome
}
fn cascade_skip(&self, idx: usize, status: &mut [Option<NodeStatus>]) -> usize {
let mut newly = 0;
let mut stack: Vec<usize> = self.nodes[idx].dependents.clone();
let mut seen: HashSet<usize> = HashSet::new();
while let Some(d) = stack.pop() {
if !seen.insert(d) {
continue;
}
if status[d].is_none() {
status[d] = Some(NodeStatus::Skipped);
newly += 1;
for &dd in &self.nodes[d].dependents {
stack.push(dd);
}
}
}
newly
}
fn run_node(&self, idx: usize, ctx: &ExecCtx, force: bool) -> NodeResult {
let step = &self.nodes[idx].step;
if let Some(cond) = &step.only_if {
if !cond.evaluate(&ctx.vars) {
note_line(
&step.name,
&format!("skipped (only_if {} is false)", cond.describe()),
);
return NodeResult {
idx,
status: NodeStatus::Conditional,
duration: Duration::ZERO,
};
}
}
if step.is_hook() {
let tool = step.tool.as_deref().unwrap_or_default();
note_line(
&step.name,
&format!("'{tool}' tool hook (install the {tool} plugin to run)"),
);
return NodeResult {
idx,
status: NodeStatus::Hook,
duration: Duration::ZERO,
};
}
let command = match &step.command {
Some(c) => c.clone(),
None => {
log::emit(&format!(
" {} {} no command\n",
log::red(log::CROSS),
step.name
));
return NodeResult {
idx,
status: NodeStatus::Errored,
duration: Duration::ZERO,
};
}
};
let cache = Cache::new(&ctx.project_root);
if ctx.use_cache && step.cache && !force {
let hash = cache.source_hash_scoped(&step.inputs);
if cache.is_fresh(&step.name, &hash) {
let note = if step.inputs.is_empty() {
"(cached — no changes detected)".to_string()
} else {
format!("(cached — {} unchanged)", step.inputs.join(", "))
};
log::emit(&format!(
" {} {} {}\n",
log::green(log::CHECK),
step.name,
log::dim(¬e)
));
return NodeResult {
idx,
status: NodeStatus::Cached,
duration: Duration::ZERO,
};
}
}
let mut env: Vec<(String, String)> = Vec::new();
for name in &step.secrets {
match ctx.secrets.get(name) {
Some(v) => env.push((name.clone(), v.clone())),
None => note_line(
&step.name,
&format!("secret '{name}' not set — injected as empty"),
),
}
}
let effective = match &ctx.container_image {
Some(image) => containers::wrap_command(&command, image, &ctx.project_root)
.unwrap_or_else(|| command.clone()),
None => command.clone(),
};
let limit = effective_limit(step, ctx.timeout);
let override_note = match step.timeout {
Some(t) => log::dim(&format!(" (timeout {})", describe_limit(t.limit()))),
None => String::new(),
};
log::emit(&format!(
" {} {} {}{}\n",
log::cyan(log::ARROW),
step.name,
log::dim(&command),
override_note
));
let sink = step_sink(&step.name);
let max_attempts = step.retries + 1;
let mut attempt = 0u32;
let mut last_output = String::new();
let mut last_status = NodeStatus::Failed;
let mut total = Duration::ZERO;
while attempt < max_attempts {
attempt += 1;
match shell::run_streamed(
&effective,
&ctx.project_root,
&env,
limit,
Arc::clone(&sink),
) {
Ok(res) => {
total += res.duration;
last_output = res.output;
if res.success {
last_status = NodeStatus::Ok;
break;
}
last_status = if res.timed_out {
NodeStatus::TimedOut
} else {
NodeStatus::Failed
};
if attempt < max_attempts {
let what = if res.timed_out { "timed out" } else { "failed" };
note_line(
&step.name,
&format!("attempt {attempt}/{max_attempts} {what}, retrying"),
);
}
}
Err(e) => {
last_status = NodeStatus::Errored;
last_output = format!("could not launch command: {e}");
break;
}
}
}
if last_status == NodeStatus::Ok && ctx.use_cache && step.cache {
let hash = cache.source_hash_scoped(&step.inputs);
let _ = cache.store(&step.name, &hash);
}
let result_line = match last_status {
NodeStatus::Ok => format!(
" {} {} {}\n",
log::green(log::CHECK),
step.name,
log::dim(&format!("({})", fmt_duration(total)))
),
NodeStatus::Errored => format!(" {} {} errored\n", log::red(log::CROSS), step.name),
NodeStatus::TimedOut => format!(
" {} {} {}\n",
log::red(log::CROSS),
step.name,
log::dim(&timeout_note(limit, attempt))
),
_ => format!(
" {} {} {}\n",
log::red(log::CROSS),
step.name,
log::dim(&format!("failed after {attempt} attempt(s)"))
),
};
log::emit(&result_line);
if matches!(
last_status,
NodeStatus::Failed | NodeStatus::TimedOut | NodeStatus::Errored
) {
let mut suggestions = Vec::new();
if last_status == NodeStatus::TimedOut {
suggestions.push(timeout_suggestion(limit));
}
suggestions.extend(crate::assist::diagnose(&command, &last_output));
if !suggestions.is_empty() {
let mut advice =
format!(" {}\n", log::yellow("Flux assist — possible fixes:"));
for s in suggestions {
advice.push_str(&format!(" {} {}\n", log::dim("•"), s.cause));
advice.push_str(&format!(" {}\n", log::dim(&s.fix)));
}
log::emit(&advice);
}
}
NodeResult {
idx,
status: last_status,
duration: total,
}
}
}
fn note_line(step_name: &str, note: &str) {
log::emit(&format!(
" {} {} {}\n",
log::yellow(log::DOT),
step_name,
log::dim(note)
));
}
fn step_sink(step_name: &str) -> LineSink {
let name = step_name.to_string();
Arc::new(move |stream: Stream, line: &str| {
let glyph = match stream {
Stream::Stdout => log::PIPE_OUT,
Stream::Stderr => log::PIPE_ERR,
};
log::emit(&format!(
" {} {line}\n",
log::dim(&format!("{name} {glyph}"))
));
})
}
fn effective_limit(step: &Step, pipeline_default: Option<Duration>) -> Option<Duration> {
step.timeout.map(|t| t.limit()).unwrap_or(pipeline_default)
}
pub fn describe_limit(limit: Option<Duration>) -> String {
match limit {
Some(d) => format_duration(d),
None => "off".to_string(),
}
}
fn timeout_note(limit: Option<Duration>, attempts: u32) -> String {
let after = describe_limit(limit);
if attempts > 1 {
format!("timed out after {after} (killed, {attempts} attempts)")
} else {
format!("timed out after {after} (killed)")
}
}
fn timeout_suggestion(limit: Option<Duration>) -> Suggestion {
let after = describe_limit(limit);
Suggestion {
cause: format!("The command outlived its timeout of {after} and was killed"),
fix:
"Give the step more room with `timeout \"30m\"`, or `timeout off` to remove the limit. \
If it should be quick, it is likely waiting on input, a lock, or a network call."
.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::config::{Step, Timeout};
fn cmd(name: &str, needs: &[&str]) -> Step {
let mut s = Step::command(name, "echo hi");
s.needs = needs.iter().map(|s| s.to_string()).collect();
s
}
#[test]
fn linear_when_no_needs() {
let steps = vec![cmd("a", &[]), cmd("b", &[]), cmd("c", &[])];
let g = Graph::build(&steps).unwrap();
assert!(!g.is_explicit());
assert_eq!(g.topo_order(), vec!["a", "b", "c"]);
}
#[test]
fn diamond_dependencies_resolve() {
let steps = vec![
cmd("frontend", &[]),
cmd("backend", &[]),
cmd("tests", &["frontend", "backend"]),
cmd("package", &["tests"]),
];
let g = Graph::build(&steps).unwrap();
assert!(g.is_explicit());
let order = g.topo_order();
assert!(
order.iter().position(|s| s == "tests") > order.iter().position(|s| s == "frontend")
);
assert!(
order.iter().position(|s| s == "tests") > order.iter().position(|s| s == "backend")
);
assert_eq!(order.last().unwrap(), "package");
}
#[test]
fn detects_cycles() {
let steps = vec![cmd("a", &["b"]), cmd("b", &["a"])];
let err = Graph::build(&steps).unwrap_err();
assert!(err.0.contains("cycle"), "{}", err.0);
}
#[test]
fn rejects_unknown_dependency() {
let steps = vec![cmd("a", &["ghost"])];
let err = Graph::build(&steps).unwrap_err();
assert!(err.0.contains("unknown step"), "{}", err.0);
}
#[test]
fn build_vars_binds_exactly_the_documented_namespace() {
let vars = build_vars(Path::new("."));
let mut bound: Vec<&str> = vars.keys().map(String::as_str).collect();
bound.sort_unstable();
let mut documented: Vec<&str> = CONDITION_VARS.to_vec();
documented.sort_unstable();
assert_eq!(bound, documented);
}
#[test]
fn step_timeout_overrides_the_pipeline_default() {
let pipeline_default = Some(Duration::from_secs(600));
let inherits = cmd("a", &[]);
assert_eq!(
effective_limit(&inherits, pipeline_default),
pipeline_default
);
assert_eq!(
effective_limit(&inherits, None),
None,
"a pipeline with no limit leaves its steps unbounded"
);
let mut declares = cmd("b", &[]);
declares.timeout = Some(Timeout::After(Duration::from_secs(30)));
assert_eq!(
effective_limit(&declares, pipeline_default),
Some(Duration::from_secs(30))
);
let mut unbounded = cmd("c", &[]);
unbounded.timeout = Some(Timeout::Off);
assert_eq!(effective_limit(&unbounded, pipeline_default), None);
}
#[test]
fn a_timeout_blocks_dependents() {
assert!(NodeStatus::TimedOut.is_blocking());
assert_eq!(NodeStatus::TimedOut.code(), "timeout");
}
#[test]
fn the_default_worker_count_is_at_least_one() {
let n = default_parallelism();
assert!((1..=16).contains(&n), "{n} workers");
}
#[test]
fn flux_env_defaults_to_default() {
let vars = build_vars(Path::new("."));
let expected = std::env::var("FLUX_ENV").unwrap_or_else(|_| "default".to_string());
assert_eq!(vars.get("flux_env").map(String::as_str), Some(&*expected));
}
}