use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
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 const LABEL_PREFIX: &str = "onepipeline.";
pub const DEFAULT_STARTUP_TIMEOUT_SECONDS: u64 = 30;
pub const STARTUP_TIMEOUT_ENV: &str = "ONEPIPELINE_STARTUP_TIMEOUT_SECONDS";
fn startup_timeout() -> Duration {
let seconds = std::env::var(STARTUP_TIMEOUT_ENV)
.ok()
.and_then(|value| value.parse().ok())
.filter(|seconds| *seconds > 0)
.unwrap_or(DEFAULT_STARTUP_TIMEOUT_SECONDS);
Duration::from_secs(seconds)
}
const LAUNCH_POLL: Duration = Duration::from_millis(10);
const EVIDENCE_CHARS: usize = crate::event::MAX_PAYLOAD_TEXT_BYTES / 4;
fn report_skipped(skipped: usize) {
if skipped > 0 {
eprintln!("onepipeline: skipped {skipped} oneagentgraph line(s) this build cannot read");
}
}
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(),
}
}
fn ended(status: &std::process::ExitStatus) -> String {
status.code().map_or_else(
|| "was ended by a signal".to_string(),
|code| format!("exited {code}"),
)
}
fn is_envelope(line: &str) -> bool {
serde_json::from_str::<Envelope>(line.trim()).is_ok()
}
pub fn label_args(labels: &Labels) -> Vec<String> {
let mut args = Vec::new();
let mut push = |key: &str, value: String| args.push(format!("{LABEL_PREFIX}{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
}
pub fn adopt_labels(labels: &mut Labels) {
let stamped = |key: &str| {
labels
.extra
.get(&format!("{LABEL_PREFIX}{key}"))
.and_then(|value| value.as_str())
.map(str::to_string)
};
let (run, round, node, step, persona) = (
stamped("run_id"),
stamped("round"),
stamped("node"),
stamped("step"),
stamped("persona"),
);
labels.run_id = labels.run_id.take().or(run);
labels.round = labels.round.or_else(|| round.and_then(|r| r.parse().ok()));
labels.node = labels.node.take().or(node);
labels.step = labels.step.take().or(step);
labels.persona = labels.persona.take().or(persona);
}
#[derive(Debug)]
pub struct GraphRun {
child: Child,
output: Output,
started_with: Vec<String>,
}
#[derive(Debug, Clone, Copy)]
pub enum GraphOutput<'a> {
Relayed,
Logged(&'a Path),
}
#[derive(Debug)]
enum Output {
Relayed(Option<BufReader<std::process::ChildStdout>>),
Logged(PathBuf),
}
impl GraphRun {
pub fn start(
graph: &str,
task: &str,
dir: Option<&Path>,
labels: &Labels,
env: &[(String, String)],
sets: &[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 value in sets {
command.arg("--set").arg(value);
}
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 mut child = command
.spawn()
.map_err(|e| sibling(format!("cannot start `{} run`: {e}", binary())))?;
let output = match output {
GraphOutput::Relayed => Output::Relayed(child.stdout.take().map(BufReader::new)),
GraphOutput::Logged(path) => Output::Logged(path.to_path_buf()),
};
Ok(Self {
child,
output,
started_with: Vec::new(),
})
}
pub fn confirm_started(&mut self) -> Result<()> {
let piped = match &mut self.output {
Output::Relayed(stdout) => stdout.take(),
Output::Logged(_) => return self.await_logged_line(),
};
match piped {
Some(reader) => self.await_first_line(reader),
None => self.settle_unstarted(),
}
}
fn await_first_line(&mut self, reader: BufReader<std::process::ChildStdout>) -> Result<()> {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::Builder::new()
.name(format!("{}-handshake", binary()))
.spawn(move || {
let mut reader = reader;
let mut read = Vec::new();
loop {
let mut line = String::new();
match reader.read_line(&mut line) {
Err(error) => break tx.send((Some(error), read, reader)),
Ok(0) => break tx.send((None, read, reader)),
Ok(_) => {
let announced = is_envelope(&line);
read.push(line);
if announced {
break tx.send((None, read, reader));
}
}
}
}
})
.map_err(|e| sibling(format!("cannot wait for `{} run` to start: {e}", binary())))?;
match rx.recv_timeout(startup_timeout()) {
Ok((error, read, reader)) => {
let announced = read.last().is_some_and(|line| is_envelope(line));
self.output = Output::Relayed(Some(reader));
self.started_with = read;
match error {
Some(error) => Err(sibling(format!(
"cannot read `{} run`'s first envelope: {error}",
binary()
))),
None if announced => Ok(()),
None => self.settle_unstarted(),
}
}
Err(_) => self.gave_no_answer(),
}
}
fn await_logged_line(&mut self) -> Result<()> {
let deadline = Instant::now() + startup_timeout();
loop {
if self.logged_an_envelope() {
return Ok(());
}
match self.child.try_wait() {
Err(error) => {
return Err(sibling(format!(
"cannot tell whether `{} run` started: {error}",
binary()
)))
}
Ok(Some(status)) if status.success() => return Ok(()),
Ok(Some(status)) => return self.refused(status),
Ok(None) => {}
}
if Instant::now() >= deadline {
return self.gave_no_answer();
}
std::thread::sleep(LAUNCH_POLL);
}
}
fn logged_an_envelope(&self) -> bool {
let Output::Logged(path) = &self.output else {
return false;
};
let Ok(text) = std::fs::read_to_string(path) else {
return false;
};
text.split_inclusive('\n')
.filter(|line| line.ends_with('\n'))
.any(is_envelope)
}
fn settle_unstarted(&mut self) -> Result<()> {
let deadline = Instant::now() + startup_timeout();
loop {
match self.child.try_wait() {
Err(error) => {
return Err(sibling(format!(
"cannot tell whether `{} run` started: {error}",
binary()
)))
}
Ok(Some(status)) if status.success() => return Ok(()),
Ok(Some(status)) => return self.refused(status),
Ok(None) if Instant::now() >= deadline => return self.gave_no_answer(),
Ok(None) => std::thread::sleep(LAUNCH_POLL),
}
}
}
fn refused(&mut self, status: std::process::ExitStatus) -> Result<()> {
Err(sibling(format!(
"`{} run` {} instead of driving the run: {}",
binary(),
ended(&status),
self.evidence()
)))
}
fn gave_no_answer(&mut self) -> Result<()> {
let _ = self.child.kill();
let _ = self.child.wait();
Err(sibling(format!(
"`{} run` neither started nor exited within {}s, so nothing is driving the run: {}",
binary(),
startup_timeout().as_secs(),
self.evidence()
)))
}
fn evidence(&mut self) -> String {
let text = match &self.output {
Output::Logged(path) => std::fs::read_to_string(path).unwrap_or_default(),
Output::Relayed(_) => 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(),
};
let trimmed = text.trim();
if trimmed.is_empty() {
return "it said nothing".to_string();
}
let mut tail: Vec<char> = trimmed.chars().rev().take(EVIDENCE_CHARS).collect();
tail.reverse();
tail.into_iter().collect()
}
pub fn events(&mut self) -> Box<dyn Iterator<Item = Result<Envelope>> + Send> {
let piped = match &mut self.output {
Output::Relayed(stdout) => stdout.take(),
Output::Logged(_) => None,
};
let Some(stdout) = piped else {
return Box::new(std::iter::empty());
};
let announced: Vec<_> = std::mem::take(&mut self.started_with);
Box::new(
announced
.into_iter()
.map(Ok)
.chain(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(mut envelope) => {
adopt_labels(&mut envelope.labels);
Some(Ok(envelope))
}
Err(_) => {
report_skipped(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()
)))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TurnAddress {
run: String,
member: String,
}
impl TurnAddress {
pub fn of(run: &str, member: &str) -> Option<Self> {
let (run, member) = (run.trim(), member.trim());
(!run.is_empty() && oneagentgraph::config::is_member_name(member)).then(|| Self {
run: run.to_string(),
member: member.to_string(),
})
}
pub fn run(&self) -> &str {
&self.run
}
pub fn member(&self) -> &str {
&self.member
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Interrupted {
Delivered,
NoTurn(String),
Failed(String),
}
#[derive(Debug, Clone, PartialEq)]
pub struct Interrupt {
pub outcome: Interrupted,
pub events: Vec<Envelope>,
}
pub fn interrupt(address: &TurnAddress, input: &str) -> Interrupt {
let output = Command::new(binary())
.arg("interrupt")
.arg(address.run())
.arg(address.member())
.arg("--input")
.arg(input)
.stdin(Stdio::null())
.output();
let output = match output {
Ok(output) => output,
Err(error) => {
return Interrupt {
outcome: Interrupted::Failed(format!(
"cannot start `{} interrupt`: {error}",
binary()
)),
events: Vec::new(),
}
} };
let mut skipped = 0;
let events: Vec<Envelope> = String::from_utf8_lossy(&output.stdout)
.lines()
.filter(|line| !line.trim().is_empty())
.filter_map(|line| match serde_json::from_str::<Envelope>(line.trim()) {
Ok(envelope) => Some(envelope),
Err(_) => {
skipped += 1;
None
}
})
.collect();
report_skipped(skipped);
let reason = || {
events
.iter()
.rev()
.find_map(|event| event.payload.get("reason").and_then(|v| v.as_str()))
.map(str::to_string)
.unwrap_or_else(|| {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if stderr.is_empty() {
"it said nothing".to_string()
} else {
stderr
}
})
};
let outcome = match output.status.code() {
Some(oneagentgraph::error::EXIT_SUCCESS) => Interrupted::Delivered,
Some(oneagentgraph::error::EXIT_NO_CONTROLLABLE_TURN) => Interrupted::NoTurn(reason()),
_ => Interrupted::Failed(format!(
"`{} interrupt {} {}` {}: {}",
binary(),
address.run(),
address.member(),
ended(&output.status),
reason()
)),
};
Interrupt { outcome, events }
}
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_and_each_is_namespaced() {
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![
"onepipeline.run_id=demo",
"onepipeline.round=2",
"onepipeline.node=build",
"onepipeline.step=implement",
"onepipeline.persona=engineer",
]
);
assert!(label_args(&Labels::default()).is_empty());
}
#[test]
fn every_label_this_crate_sends_is_one_oneagentgraph_accepts() {
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(),
};
for arg in label_args(&labels) {
let parsed = oneagentgraph::run::parse_label(&arg)
.unwrap_or_else(|error| panic!("oneagentgraph refuses `--label {arg}`: {error}"));
assert!(
parsed.key().starts_with(LABEL_PREFIX),
"{} escaped the namespace",
parsed.key()
);
}
}
#[test]
fn a_relayed_envelopes_namespaced_labels_are_adopted_without_rewriting_the_producers() {
let mut labels = Labels {
run_id: Some("node-scope-1786304152340-19".into()),
..Labels::default()
};
for (key, value) in [
("onepipeline.run_id", "demo"),
("onepipeline.round", "2"),
("onepipeline.node", "build"),
("onepipeline.step", "implement"),
("onepipeline.persona", "engineer"),
] {
labels.extra.insert(key.into(), value.into());
}
adopt_labels(&mut labels);
assert_eq!(
labels.run_id.as_deref(),
Some("node-scope-1786304152340-19"),
"the graph run's own id was overwritten"
);
assert_eq!(labels.round, Some(2));
assert_eq!(labels.node.as_deref(), Some("build"));
assert_eq!(labels.step.as_deref(), Some("implement"));
assert_eq!(labels.persona.as_deref(), Some("engineer"));
assert_eq!(
labels.extra["onepipeline.run_id"], "demo",
"the namespaced copy is what tells the two runs apart"
);
}
#[test]
fn a_relayed_envelope_stamped_with_nothing_of_this_crates_is_left_as_it_arrived() {
let mut labels = Labels {
run_id: Some("elsewhere".into()),
..Labels::default()
};
labels.extra.insert("member".into(), "worker".into());
let untouched = labels.clone();
adopt_labels(&mut labels);
assert_eq!(labels, untouched);
}
#[test]
fn an_oneagentgraph_that_cannot_be_started_is_a_failed_delivery() {
std::env::set_var(BINARY_ENV, "oneagentgraph-that-is-not-installed");
let interrupt = interrupt(
&TurnAddress {
run: "node-scope-1".into(),
member: "worker".into(),
},
"the fixture moved",
);
assert!(
matches!(&interrupt.outcome, Interrupted::Failed(reason)
if reason.contains("oneagentgraph-that-is-not-installed")),
"{:?} does not name the binary that could not be started",
interrupt.outcome
);
assert!(
interrupt.events.is_empty(),
"a delivery nothing ran produced envelopes"
);
}
#[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());
}
}