use std::io::{BufRead, BufReader};
use std::path::Path;
use std::process::{Child, Command, Stdio};
use crate::error::{Error, Result};
use crate::event::{Envelope, Labels};
pub const BINARY_ENV: &str = "ONEPIPELINE_ONEAGENTGRAPH_BIN";
pub const DEFAULT_BINARY: &str = "oneagentgraph";
pub const RUN_ID_ENV: &str = "ONEPIPELINE_RUN_ID";
pub const CHECK_IN_MEMBER: &str = "check-in";
pub fn binary() -> String {
std::env::var(BINARY_ENV)
.ok()
.filter(|value| !value.is_empty())
.unwrap_or_else(|| DEFAULT_BINARY.to_string())
}
fn sibling(message: impl Into<String>) -> Error {
Error::Sibling {
tool: "oneagentgraph",
message: message.into(),
}
}
pub fn label_args(labels: &Labels) -> Vec<String> {
let mut args = Vec::new();
let mut push = |key: &str, value: String| args.push(format!("{key}={value}"));
if let Some(run) = &labels.run_id {
push("run_id", run.clone());
}
if let Some(round) = labels.round {
push("round", round.to_string());
}
if let Some(node) = &labels.node {
push("node", node.clone());
}
if let Some(step) = &labels.step {
push("step", step.clone());
}
if let Some(persona) = &labels.persona {
push("persona", persona.clone());
}
args
}
#[derive(Debug)]
pub struct GraphRun {
child: Child,
}
#[derive(Debug, Clone, Copy)]
pub enum GraphOutput<'a> {
Relayed,
Logged(&'a Path),
}
impl GraphRun {
pub fn start(
graph: &str,
task: &str,
dir: Option<&Path>,
labels: &Labels,
env: &[(String, String)],
output: GraphOutput<'_>,
) -> Result<Self> {
let mut command = Command::new(binary());
command.arg("run").arg(graph);
command.arg("--task").arg(task);
command.arg("--output").arg("json");
if let Some(dir) = dir {
command.arg("--dir").arg(dir);
}
for label in label_args(labels) {
command.arg("--label").arg(label);
}
for (key, value) in env {
command.env(key, value);
}
command.stdin(Stdio::null());
match output {
GraphOutput::Relayed => {
command.stdout(Stdio::piped()).stderr(Stdio::piped());
}
GraphOutput::Logged(path) => {
let log = |path: &Path| {
std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
.map_err(|e| {
sibling(format!(
"cannot open {} for the driver: {e}",
path.display()
))
})
};
command.stdout(log(path)?).stderr(log(path)?);
}
}
let child = command
.spawn()
.map_err(|e| sibling(format!("cannot start `{} run`: {e}", binary())))?;
Ok(Self { child })
}
pub fn events(&mut self) -> Box<dyn Iterator<Item = Result<Envelope>> + Send> {
let Some(stdout) = self.child.stdout.take() else {
return Box::new(std::iter::empty());
};
Box::new(
BufReader::new(stdout)
.lines()
.filter_map(|line| match line {
Err(error) => Some(Err(sibling(format!(
"reading `{} run` output: {error}",
binary()
)))),
Ok(line) if line.trim().is_empty() => None,
Ok(line) => match serde_json::from_str::<Envelope>(&line) {
Ok(envelope) => Some(Ok(envelope)),
Err(_) => {
crate::vcs::report_skipped("oneagentgraph", 1);
None
}
},
}),
)
}
pub fn wait(&mut self) -> Result<Settled> {
let status = self
.child
.wait()
.map_err(|e| sibling(format!("waiting for `{} run`: {e}", binary())))?;
let stderr = self
.child
.stderr
.take()
.map(|mut pipe| {
use std::io::Read;
let mut text = String::new();
let _ = pipe.read_to_string(&mut text);
text
})
.unwrap_or_default();
Ok(Settled {
code: status.code(),
stderr,
})
}
pub fn pid(&self) -> u32 {
self.child.id()
}
pub fn has_exited(&mut self) -> bool {
matches!(self.child.try_wait(), Ok(Some(_)))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Settled {
pub code: Option<i32>,
pub stderr: String,
}
impl Settled {
pub fn succeeded(&self) -> bool {
self.code == Some(0)
}
}
pub fn reset_timer(run: &str, member: &str) -> Result<()> {
let output = Command::new(binary())
.arg("reset-timer")
.arg(run)
.arg(member)
.stdin(Stdio::null())
.output()
.map_err(|e| sibling(format!("cannot start `{} reset-timer`: {e}", binary())))?;
if output.status.success() {
return Ok(());
}
Err(sibling(format!(
"reset-timer {run} {member} exited {}: {}",
output.status.code().unwrap_or(-1),
String::from_utf8_lossy(&output.stderr).trim()
)))
}
pub fn health() -> Option<String> {
let output = Command::new(binary())
.arg("health")
.stdin(Stdio::null())
.output()
.ok()?;
if !output.status.success() {
return None;
}
let text = String::from_utf8_lossy(&output.stdout).trim().to_string();
(!text.is_empty()).then_some(text)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_binary_comes_from_the_environment_or_falls_back() {
assert_eq!(
std::env::var(BINARY_ENV)
.ok()
.filter(|v| !v.is_empty())
.unwrap_or_else(|| DEFAULT_BINARY.to_string()),
binary()
);
}
#[test]
fn only_the_reserved_labels_the_contract_names_are_rendered() {
let labels = Labels {
run_id: Some("demo".into()),
round: Some(2),
node: Some("build".into()),
step: Some("implement".into()),
persona: Some("engineer".into()),
extra: serde_json::Map::new(),
};
assert_eq!(
label_args(&labels),
vec![
"run_id=demo",
"round=2",
"node=build",
"step=implement",
"persona=engineer",
]
);
assert!(label_args(&Labels::default()).is_empty());
}
#[test]
fn a_settled_run_reports_only_a_zero_exit_as_success() {
assert!(Settled {
code: Some(0),
stderr: String::new()
}
.succeeded());
assert!(!Settled {
code: Some(1),
stderr: String::new()
}
.succeeded());
assert!(!Settled {
code: None,
stderr: String::new()
}
.succeeded());
}
}