use std::path::{Path, PathBuf};
use std::time::Duration;
use speccoder_core::new_project::{PlanStepResult, PlanStepState, StepAccess};
use crate::sandbox::{self, Network};
use crate::verify::process::Invocation;
pub(crate) const SECRETS: &[&str] = &[
"ANTHROPIC_API_KEY",
"OPENAI_API_KEY",
"GH_TOKEN",
"GITHUB_TOKEN",
"SPECCODER_GITHUB_TOKEN",
];
const TAIL: usize = 20;
pub(crate) fn cache_dir(state: &Path, tool: &str) -> PathBuf {
state.join("cache").join("new").join(tool)
}
fn cache_env(cache: &Path) -> Vec<(String, String)> {
let c = |sub: &str| cache.join(sub).display().to_string();
vec![
("UV_CACHE_DIR".to_owned(), c("uv")),
("UV_PYTHON_INSTALL_DIR".to_owned(), c("uv-python")),
("npm_config_cache".to_owned(), c("npm")),
("npm_config_update_notifier".to_owned(), "false".to_owned()),
("GOMODCACHE".to_owned(), c("gomod")),
("GOCACHE".to_owned(), c("gobuild")),
("GOTELEMETRY".to_owned(), "off".to_owned()),
]
}
pub(crate) struct Run<'a> {
pub id: &'a str,
pub argv: &'a [String],
pub access: StepAccess,
pub limit_s: u32,
pub env: &'a [(String, String)],
}
pub(crate) fn tail(text: &str) -> Vec<String> {
let lines: Vec<String> = text
.lines()
.map(str::trim_end)
.filter(|l| !l.is_empty())
.map(str::to_owned)
.collect();
let skip = lines.len().saturating_sub(TAIL);
lines.into_iter().skip(skip).collect()
}
pub(crate) fn run(target: &Path, state: &Path, step: &Run<'_>) -> PlanStepResult {
let tool = step.argv.first().map_or("tool", String::as_str);
let cache = cache_dir(state, tool);
let failed = |message: String| PlanStepResult {
id: step.id.to_owned(),
state: PlanStepState::Failed,
exit: None,
duration_ms: 0,
output_tail: vec![message],
};
if let Err(e) = std::fs::create_dir_all(&cache) {
return failed(format!("speccoder's cache {}: {e}", cache.display()));
}
let mut env: Vec<(String, String)> = std::env::vars()
.filter(|(k, _)| !SECRETS.contains(&k.as_str()))
.collect();
let mut extra = cache_env(&cache);
extra.extend(step.env.iter().cloned());
for (k, v) in extra {
env.retain(|(key, _)| *key != k);
env.push((k, v));
}
let config = sandbox::Config {
network: Network::Off,
writable: vec![cache.display().to_string()],
..sandbox::Config::default()
};
let runner = match sandbox::runner(target, &config) {
Ok((r, _)) => r,
Err(e) => return failed(e),
};
let git = target.join(".git");
let runner = if git.is_dir() {
runner.carve(&[git], &[]).0
} else {
runner
};
let inv = Invocation {
argv: step.argv.to_vec(),
cwd: target.to_path_buf(),
env,
timeout: Duration::from_secs(u64::from(step.limit_s)),
output_cap: 256 * 1024,
};
let out = runner.run_with(&inv, step.access == StepAccess::Network);
let text = format!(
"{}\n{}",
String::from_utf8_lossy(&out.stdout.kept),
String::from_utf8_lossy(&out.stderr.kept)
);
let mut output_tail = tail(&text);
let ok = out.exit_code == Some(0) && !out.timed_out && out.spawn_error.is_none();
if let Some(e) = &out.spawn_error {
output_tail.push(e.clone());
}
if out.timed_out {
output_tail.push(format!(
"timed out after {}s; it may be waiting for input — run it yourself: {}",
step.limit_s,
step.argv.join(" ")
));
}
PlanStepResult {
id: step.id.to_owned(),
state: if ok {
PlanStepState::Done
} else {
PlanStepState::Failed
},
exit: out.exit_code,
duration_ms: u64::try_from(out.duration.as_millis()).unwrap_or(u64::MAX),
output_tail,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_tail_keeps_the_last_twenty_lines_that_say_something() {
let text: String = (1..=30).map(|i| format!("line {i}\n\n")).collect();
let t = tail(&text);
assert_eq!(t.len(), 20);
assert_eq!(t.first().map(String::as_str), Some("line 11"));
assert_eq!(t.last().map(String::as_str), Some("line 30"));
}
}