use super::*;
pub(crate) fn rule_resource_rows(
checkpoint: &oxo_flow_core::executor::checkpoint::CheckpointState,
) -> Vec<serde_json::Value> {
let mut names: Vec<&String> = checkpoint.benchmarks.keys().collect();
names.sort_unstable();
names
.into_iter()
.map(|name| {
let b = &checkpoint.benchmarks[name];
let status = if checkpoint.completed_rules.contains(name) {
"completed"
} else if checkpoint.failed_rules.contains(name) {
if checkpoint.when_verdicts.get(name) == Some(&false) {
"skipped_by_when"
} else {
"failed"
}
} else {
"running"
};
serde_json::json!({
"rule": name,
"status": status,
"wall_time_secs": b.wall_time_secs,
"recorded_as": b.recorded_as,
"peak_rss_mb": b.max_memory_mb,
"cpu_seconds": b.cpu_seconds,
"retries": b.retries,
})
})
.collect()
}
#[derive(serde::Serialize, serde::Deserialize, Clone, Default)]
pub(crate) struct RefRecord {
pub(crate) fingerprint: String,
pub(crate) content_sig: Option<String>,
pub(crate) source_path: Option<String>,
}
pub(crate) fn source_content_sig(path: &std::path::Path) -> Option<String> {
let md = std::fs::metadata(path).ok()?;
if !md.is_file() {
return None;
}
let mut sig = format!("size:{}", md.len());
if let Some(hash) = oxo_flow_core::executor::checkpoint::content_hash_if_small(path, &md) {
sig.push_str(&format!("|hash:{hash}"));
}
Some(sig)
}
fn persist_ref_state(ref_checkpoint: &std::path::Path, ref_state: &HashMap<String, RefRecord>) {
if let Some(parent) = ref_checkpoint.parent() {
let _ = std::fs::create_dir_all(parent);
}
let ordered: std::collections::BTreeMap<&String, &RefRecord> = ref_state.iter().collect();
let body = match serde_json::to_string(&ordered) {
Ok(json) => json,
Err(e) => {
tracing::warn!("reference checkpoint serialization failed: {e}");
return;
}
};
if let Err(e) = std::fs::write(ref_checkpoint, body) {
tracing::warn!("reference checkpoint write failed: {e}");
}
}
pub(crate) fn free_kilobytes(path: &std::path::Path) -> Option<u64> {
let out = std::process::Command::new("df")
.args(["-Pk", path.to_str()?])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&out.stdout);
let line = stdout.lines().next_back()?;
line.split_whitespace().nth(3)?.parse().ok()
}
pub(crate) fn age_ready_list(
mut ready: Vec<String>,
order_set: &std::collections::HashSet<String>,
submitted: &std::collections::HashSet<String>,
priority_map: &std::collections::HashMap<String, i32>,
waited: &std::collections::HashMap<String, i32>,
) -> Vec<String> {
ready.retain(|name| order_set.contains(name) && !submitted.contains(name));
ready.sort_by(|a, b| {
let pa = priority_map.get(a).copied().unwrap_or(0) + waited.get(a).copied().unwrap_or(0);
let pb = priority_map.get(b).copied().unwrap_or(0) + waited.get(b).copied().unwrap_or(0);
pb.cmp(&pa).then_with(|| a.cmp(b))
});
ready
}
pub(crate) fn suggest_jobs(
config: &WorkflowConfig,
groups: &[Vec<String>],
system_threads: u32,
) -> u32 {
let system_threads = system_threads.max(1);
let wave_capacity = |names: &[String]| -> u32 {
let max_threads = names
.iter()
.filter_map(|n| config.get_rule(n))
.map(|r| r.effective_threads())
.max()
.unwrap_or(1)
.max(1);
(system_threads / max_threads)
.min(names.len() as u32)
.max(1)
};
groups
.iter()
.map(|g| wave_capacity(g))
.max()
.unwrap_or(1)
.clamp(1, system_threads)
}
#[allow(clippy::too_many_arguments)] pub(crate) async fn auto_build_references(
config: &WorkflowConfig,
dag: &WorkflowDag,
checkpoint: &Mutex<CheckpointState>,
checkpoint_path: &Path,
workdir: &Path,
workflow_dir: &Path,
wildcard_values: &HashMap<String, String>,
sensitive_values: &[String],
env_cache_dir: &Path,
json_summary: &mut RunJsonSummary,
skip_ref_build: bool,
) -> anyhow::Result<()> {
if skip_ref_build || config.references.is_empty() {
return Ok(());
}
let ref_workdir = workdir;
let ref_checkpoint = checkpoint_path
.parent()
.unwrap_or(Path::new("."))
.join("reference-checkpoint.json");
let legacy_ref_checkpoint = ref_checkpoint
.parent()
.unwrap_or(Path::new("."))
.join(".oxo-flow/reference-checkpoint.json");
if !ref_checkpoint.exists() && legacy_ref_checkpoint.exists() {
let _ = std::fs::rename(&legacy_ref_checkpoint, &ref_checkpoint);
if let Some(parent) = legacy_ref_checkpoint.parent() {
let _ = std::fs::remove_dir(parent);
}
}
let mut ref_state: HashMap<String, RefRecord> = if ref_checkpoint.exists() {
let raw = std::fs::read_to_string(&ref_checkpoint).unwrap_or_default();
serde_json::from_str::<HashMap<String, RefRecord>>(&raw)
.ok()
.or_else(|| {
serde_json::from_str::<HashMap<String, String>>(&raw)
.ok()
.map(|m| {
m.into_iter()
.map(|(k, v)| {
(
k,
RefRecord {
fingerprint: v,
content_sig: None,
source_path: None,
},
)
})
.collect()
})
})
.or_else(|| {
serde_json::from_str::<Vec<String>>(&raw)
.map(|names| {
names
.into_iter()
.map(|n| {
(
n,
RefRecord {
fingerprint: String::new(),
content_sig: None,
source_path: None,
},
)
})
.collect::<HashMap<String, RefRecord>>()
})
.ok()
})
.unwrap_or_default()
} else {
HashMap::new()
};
let mut rebuilt_outputs: Vec<String> = Vec::new();
for ref_def in &config.references {
let output_path = oxo_flow_core::executor::checkpoint::expand_config_in_path(
&ref_def.output,
wildcard_values,
);
let output_full = ref_workdir.join(&output_path);
let resolved_source = ref_def.source.as_ref().map(|source| {
let expanded =
oxo_flow_core::executor::checkpoint::expand_config_in_path(source, wildcard_values);
let path = std::path::Path::new(&expanded);
if path.is_absolute() {
path.to_path_buf()
} else {
workflow_dir.join(path)
}
});
let current_fp = oxo_flow_core::config_impact::reference_fingerprint(
ref_def,
&config.config,
resolved_source.as_deref(),
);
let stored: Option<RefRecord> = ref_state.get(&ref_def.name).cloned();
let current_sig = resolved_source.as_deref().and_then(source_content_sig);
let source_newer = resolved_source.as_deref().is_some_and(|p| {
p.exists() && oxo_flow_core::executor::checkpoint::file_is_newer(p, &output_full)
});
let rebuild_reason = if !output_full.exists() {
Some("output missing")
} else if stored.as_ref().is_some_and(|r| r.fingerprint.is_empty()) {
ref_state.insert(
ref_def.name.clone(),
RefRecord {
fingerprint: current_fp.clone(),
content_sig: current_sig.clone(),
source_path: ref_def.source.clone(),
},
);
persist_ref_state(&ref_checkpoint, &ref_state);
None
} else if source_newer {
Some("source is newer than output")
} else if stored.as_ref().is_some_and(|r| r.fingerprint != current_fp) {
let pure_path_migration = (|| {
let rec = stored.as_ref()?;
let old_path_str = rec.source_path.as_deref()?;
let old_def = oxo_flow_core::config::ReferenceDef {
source: Some(old_path_str.to_string()),
..ref_def.clone()
};
let fp_at_old_path = oxo_flow_core::config_impact::reference_fingerprint(
&old_def,
&config.config,
Some(ref_workdir.join(old_path_str)).as_deref(),
);
if fp_at_old_path != rec.fingerprint {
return None; }
let cur = current_sig.as_deref()?;
if !cur.contains("|hash:") {
return None; }
(rec.content_sig.as_deref() == Some(cur)).then_some(())
})()
.is_some();
if pure_path_migration {
tracing::info!(
reference = %ref_def.name,
"reference source moved to a new path with identical content — keeping the existing artifact"
);
ref_state.insert(
ref_def.name.clone(),
RefRecord {
fingerprint: current_fp.clone(),
content_sig: current_sig.clone(),
source_path: ref_def.source.clone(),
},
);
persist_ref_state(&ref_checkpoint, &ref_state);
None
} else {
Some("definition, source content, or referenced config changed")
}
} else {
None
};
if let Some(reason) = rebuild_reason {
let source_rendered: Option<String> = ref_def.source.as_ref().map(|source| {
let expanded = oxo_flow_core::executor::checkpoint::expand_config_in_path(
source,
wildcard_values,
);
if std::path::Path::new(&expanded).is_absolute() || workflow_dir == ref_workdir {
expanded
} else {
workflow_dir.join(expanded).to_string_lossy().into_owned()
}
});
let synthetic_rule = oxo_flow_core::rule::Rule {
name: format!("ref:{}", ref_def.name),
input: source_rendered
.as_deref()
.map(|s| vec![s.to_string()].into())
.unwrap_or_default(),
output: vec![output_path.clone()].into(),
..Default::default()
};
let mut build_cmd = oxo_flow_core::executor::process::render_shell_command(
&ref_def.build,
&synthetic_rule,
wildcard_values,
oxo_flow_core::scheduler::detect_system_limits(),
);
if let Some(expanded) = source_rendered.as_deref() {
build_cmd = substitute_source_placeholder(&build_cmd, expanded);
}
build_cmd = config.defaults.apply_shell_prelude(&build_cmd);
if let Some(ref env) = ref_def.environment
&& !env.is_empty()
{
let resolver =
oxo_flow_core::environment::EnvironmentResolver::with_cache_dir(env_cache_dir);
let resolved_env = env.resolve_relative_paths(Some(workflow_dir));
let key = resolver.cache_key(&resolved_env);
if !resolver.cache_is_ready(&key).await {
let setup = resolver.setup_command(&resolved_env)?;
let out = tokio::process::Command::new("sh")
.arg("-c")
.arg(&setup)
.current_dir(ref_workdir)
.output()
.await;
match out {
Ok(o) if o.status.success() => {
resolver.cache_mark_ready(&key).await;
}
Ok(o) => {
let stderr = String::from_utf8_lossy(&o.stderr).into_owned();
json_summary.emit("failed", &RunCounts::default(), vec![]);
return Err(anyhow::anyhow!(
"failed to set up the environment for reference '{}': {}",
ref_def.name,
stderr.trim()
));
}
Err(e) => {
json_summary.emit("failed", &RunCounts::default(), vec![]);
return Err(anyhow::anyhow!(
"failed to run the environment setup for reference '{}': {e}",
ref_def.name
));
}
}
}
build_cmd = resolver.wrap_command(&build_cmd, &resolved_env, None, ref_workdir)?;
}
eprintln!(
" {} Building {}: {} ({})",
"⚙".cyan().bold(),
ref_def.name,
ref_def.description.as_deref().unwrap_or(&ref_def.output),
reason
);
async fn run_ref_shell(
shell: &str,
cmd: &str,
dir: &Path,
) -> std::io::Result<std::process::ExitStatus> {
tokio::process::Command::new(shell)
.arg("-c")
.arg(cmd)
.current_dir(dir)
.status()
.await
}
let status = match run_ref_shell("bash", &build_cmd, ref_workdir).await {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
run_ref_shell("sh", &build_cmd, ref_workdir).await
}
other => other,
};
match status {
Ok(s) if s.success() => {
let post_fp = oxo_flow_core::config_impact::reference_fingerprint(
ref_def,
&config.config,
resolved_source.as_deref(),
);
ref_state.insert(
ref_def.name.clone(),
RefRecord {
fingerprint: post_fp,
content_sig: resolved_source.as_deref().and_then(source_content_sig),
source_path: ref_def.source.clone(),
},
);
rebuilt_outputs.push(output_path.clone());
persist_ref_state(&ref_checkpoint, &ref_state);
eprintln!(" {} {} built", "✓".green(), ref_def.name);
}
Ok(s) => {
anyhow::bail!(
"failed to build reference '{}' (exit {}). Command: {}",
ref_def.name,
s.code().unwrap_or(-1),
oxo_flow_core::executor::process::mask_sensitive(
&build_cmd,
sensitive_values
)
);
}
Err(e) => {
anyhow::bail!("failed to run build command for '{}': {}", ref_def.name, e);
}
}
} else if stored.is_none() {
ref_state.insert(
ref_def.name.clone(),
RefRecord {
fingerprint: current_fp,
content_sig: current_sig,
source_path: ref_def.source.clone(),
},
);
persist_ref_state(&ref_checkpoint, &ref_state);
}
}
if !rebuilt_outputs.is_empty() {
let mut consumers: HashSet<String> = HashSet::new();
for rule in &config.rules {
let inputs: Vec<String> = rule
.input
.to_vec()
.into_iter()
.map(|i| {
oxo_flow_core::executor::checkpoint::expand_config_in_path(&i, wildcard_values)
})
.collect();
if inputs.iter().any(|input| {
!input.contains('{') && rebuilt_outputs.iter().any(|rebuilt| rebuilt == input)
}) {
consumers.insert(rule.name.clone());
}
}
if !consumers.is_empty() {
let mut ck = checkpoint.lock().await;
let invalidated =
crate::commands::run_preview::invalidate_with_downstream(&mut ck, dag, &consumers);
if let Err(e) = ck.save_to_file(checkpoint_path) {
tracing::warn!(error = %e, "failed to save checkpoint after reference rebuild");
}
eprintln!(
" {} reference rebuild invalidated {}: {}",
"↻".yellow(),
oxo_flow_core::plural(invalidated.len(), "rule", "rules"),
invalidated.join(", ")
);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use oxo_flow_core::config::WorkflowConfig;
use std::collections::HashSet;
fn config_with_rules(rules_toml: &str) -> WorkflowConfig {
WorkflowConfig::parse(rules_toml).unwrap()
}
#[test]
fn suggest_jobs_evaluates_each_wave_against_its_own_heaviest_rule() {
let mut toml = String::from("[workflow]\nname = \"t\"\nversion = \"0.1\"\n");
for i in 0..20 {
toml.push_str(&format!(
"\n[[rules]]\nname = \"a{i}\"\noutput = [\"a{i}.txt\"]\nshell = \"true\"\n\n[rules.resources]\nthreads = 1\n"
));
}
for i in 0..2 {
toml.push_str(&format!(
"\n[[rules]]\nname = \"b{i}\"\noutput = [\"b{i}.txt\"]\nshell = \"true\"\n\n[rules.resources]\nthreads = 16\n"
));
}
for i in 0..20 {
toml.push_str(&format!(
"\n[[rules]]\nname = \"c{i}\"\noutput = [\"c{i}.txt\"]\nshell = \"true\"\n\n[rules.resources]\nthreads = 1\n"
));
}
let config = config_with_rules(&toml);
let groups = vec![
(0..20).map(|i| format!("a{i}")).collect::<Vec<_>>(),
(0..2).map(|i| format!("b{i}")).collect::<Vec<_>>(),
(0..20).map(|i| format!("c{i}")).collect::<Vec<_>>(),
];
assert_eq!(suggest_jobs(&config, &groups, 64), 20);
assert_ne!(suggest_jobs(&config, &groups, 64), 4);
}
#[test]
fn suggest_jobs_clamps_to_the_wave_width_and_system_threads() {
let mut toml = String::from("[workflow]\nname = \"t\"\nversion = \"0.1\"\n");
for i in 0..5 {
toml.push_str(&format!(
"\n[[rules]]\nname = \"h{i}\"\noutput = [\"h{i}.txt\"]\nshell = \"true\"\n\n[rules.resources]\nthreads = 16\n"
));
}
let config = config_with_rules(&toml);
let groups = vec![(0..5).map(|i| format!("h{i}")).collect::<Vec<_>>()];
assert_eq!(suggest_jobs(&config, &groups, 4), 1);
let mut toml = String::from("[workflow]\nname = \"t\"\nversion = \"0.1\"\n");
for i in 0..20 {
toml.push_str(&format!(
"\n[[rules]]\nname = \"l{i}\"\noutput = [\"l{i}.txt\"]\nshell = \"true\"\n\n[rules.resources]\nthreads = 1\n"
));
}
let config = config_with_rules(&toml);
let groups = vec![(0..20).map(|i| format!("l{i}")).collect::<Vec<_>>()];
assert_eq!(suggest_jobs(&config, &groups, 64), 20);
}
#[test]
fn suggest_jobs_defaults_to_one_without_rules() {
let config = config_with_rules("[workflow]\nname = \"t\"\nversion = \"0.1\"\n");
assert_eq!(suggest_jobs(&config, &[], 64), 1);
assert_eq!(suggest_jobs(&config, &[vec![]], 64), 1);
}
fn map_of(pairs: &[(&str, i32)]) -> std::collections::HashMap<String, i32> {
pairs.iter().map(|(k, v)| (k.to_string(), *v)).collect()
}
fn set_of(names: &[&str]) -> HashSet<String> {
names.iter().map(|n| n.to_string()).collect()
}
#[test]
fn age_ready_list_orders_by_declared_priority_then_name() {
let priority = map_of(&[("high", 20), ("low", 10)]);
let ready = age_ready_list(
vec!["low".into(), "high".into()],
&set_of(&["high", "low"]),
&HashSet::new(),
&priority,
&std::collections::HashMap::new(),
);
assert_eq!(ready, vec!["high", "low"]);
}
#[test]
fn age_ready_list_aged_rule_overtakes_higher_declared_priority() {
let priority = map_of(&[("merge", 20), ("dump", 10)]);
let waited = map_of(&[("dump", 15)]);
let ready = age_ready_list(
vec!["merge".into(), "dump".into()],
&set_of(&["merge", "dump"]),
&HashSet::new(),
&priority,
&waited,
);
assert_eq!(ready, vec!["dump", "merge"]);
}
#[test]
fn age_ready_list_drops_submitted_and_out_of_scope_rules() {
let priority = map_of(&[("a", 5), ("b", 5), ("c", 5)]);
let submitted = set_of(&["b"]);
let ready = age_ready_list(
vec!["c".into(), "b".into(), "a".into()],
&set_of(&["a", "b", "c"]),
&submitted,
&priority,
&std::collections::HashMap::new(),
);
assert_eq!(ready, vec!["a", "c"]);
}
#[test]
fn age_ready_list_treats_missing_priority_and_waits_as_zero() {
let ready = age_ready_list(
vec!["z".into(), "a".into()],
&set_of(&["a", "z"]),
&HashSet::new(),
&std::collections::HashMap::new(),
&std::collections::HashMap::new(),
);
assert_eq!(ready, vec!["a", "z"]);
}
}