use std::io::{BufRead, BufReader};
use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use onevcs::{MergePolicy, Session, SessionRequest};
use crate::error::{Error, Result};
use crate::event::Envelope;
pub const BINARY_ENV: &str = "ONEPIPELINE_ONEVCS_BIN";
pub const DEFAULT_BINARY: &str = "onevcs";
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: "onevcs",
message: message.into(),
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Published {
pub url: Option<String>,
pub id: Option<String>,
pub outcome: Option<String>,
}
fn run_json<T: serde::de::DeserializeOwned>(command: &mut Command, what: &str) -> Result<T> {
let output = command
.stdin(Stdio::null())
.output()
.map_err(|e| sibling(format!("cannot start `{} {what}`: {e}", binary())))?;
if !output.status.success() {
return Err(sibling(format!(
"{what} exited {}: {}",
output.status.code().unwrap_or(-1),
String::from_utf8_lossy(&output.stderr).trim()
)));
}
let stdout = String::from_utf8_lossy(&output.stdout);
serde_json::from_str(stdout.trim())
.map_err(|e| sibling(format!("{what} printed something unreadable: {e}")))
}
pub fn session_open(request: &SessionRequest) -> Result<Session> {
let mut command = Command::new(binary());
command.arg("session").arg("open").arg(&request.repo);
if let Some(branch) = &request.branch {
command.arg("--branch").arg(branch);
}
if let Some(base) = &request.base {
command.arg("--base").arg(base);
}
if let Some(checkout) = &request.execution_checkout {
command.arg("--execution-checkout").arg(checkout);
}
run_json(&mut command, "session open")
}
pub fn publish(token: &str, policy: Option<MergePolicy>, title: Option<&str>) -> Result<Published> {
let mut command = Command::new(binary());
command.arg("publish").arg(token);
if let Some(policy) = policy {
command.arg("--policy").arg(policy_arg(policy));
}
if let Some(title) = title {
command.arg("--title").arg(title);
}
let output = command
.stdin(Stdio::null())
.output()
.map_err(|e| sibling(format!("cannot start `{} publish`: {e}", binary())))?;
if !output.status.success() {
return Err(sibling(format!(
"publish exited {}: {}",
output.status.code().unwrap_or(-1),
String::from_utf8_lossy(&output.stderr).trim()
)));
}
Ok(published_from(&events(token)))
}
fn published_from(events: &[Envelope]) -> Published {
fn rank(outcome: &str) -> u8 {
match outcome {
"merged" => 3,
"queued" => 2,
"change-open" => 1,
_ => 0,
}
}
let text = |envelope: &Envelope, key: &str| {
envelope
.payload
.get(key)
.and_then(|value| value.as_str())
.map(str::to_string)
};
let mut published = Published::default();
for envelope in events {
let Ok(kind) = serde_json::from_value::<onevcs::EventKind>(serde_json::Value::String(
envelope.kind.0.clone(),
)) else {
continue;
};
let reached = match kind {
onevcs::EventKind::ChangeOpened => {
published.url = text(envelope, "url").or(published.url.take());
published.id = text(envelope, "id").or(published.id.take());
"change-open"
}
onevcs::EventKind::ChangeMerged | onevcs::EventKind::MergeCompleted => {
published.url = text(envelope, "url").or(published.url.take());
"merged"
}
onevcs::EventKind::MergeQueued if text(envelope, "url").is_some() => {
published.url = text(envelope, "url").or(published.url.take());
"queued"
}
_ => continue,
};
if rank(reached) > rank(published.outcome.as_deref().unwrap_or_default()) {
published.outcome = Some(reached.to_string());
}
}
published
}
pub fn policy_arg(policy: MergePolicy) -> &'static str {
match policy {
MergePolicy::LocalDirect => "local-direct",
MergePolicy::ChangeOpen => "change-open",
MergePolicy::ChangeAuto => "change-auto",
MergePolicy::ChangeDirect => "change-direct",
}
}
pub fn session_close(token: &str) -> Result<()> {
let output = Command::new(binary())
.arg("session")
.arg("close")
.arg(token)
.stdin(Stdio::null())
.output()
.map_err(|e| sibling(format!("cannot start `{} session close`: {e}", binary())))?;
if output.status.success() {
return Ok(());
}
Err(sibling(format!(
"session close {token} exited {}: {}",
output.status.code().unwrap_or(-1),
String::from_utf8_lossy(&output.stderr).trim()
)))
}
pub fn events(token: &str) -> Vec<Envelope> {
let read = Command::new(binary())
.arg("events")
.arg(token)
.stdin(Stdio::null())
.output();
let output = match read {
Ok(output) if output.status.success() => output,
Ok(output) => {
eprintln!(
"onepipeline: cannot read session {token}'s events: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
return Vec::new();
}
Err(error) => {
eprintln!("onepipeline: cannot read session {token}'s events: {error}");
return Vec::new();
}
};
let text = String::from_utf8_lossy(&output.stdout);
let lines: Vec<&str> = text
.lines()
.filter(|line| !line.trim().is_empty())
.collect();
let envelopes: Vec<Envelope> = lines
.iter()
.filter_map(|line| serde_json::from_str::<Envelope>(line).ok())
.collect();
report_skipped("onevcs", lines.len() - envelopes.len());
envelopes
}
const FOLLOW_GRACE: Duration = Duration::from_secs(5);
const FOLLOW_POLL: Duration = Duration::from_millis(20);
pub fn follow(token: &str, sink: Box<dyn Fn(Envelope) + Send>) -> Option<Follower> {
let started = Command::new(binary())
.arg("events")
.arg(token)
.arg("--follow")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.spawn();
let mut child = match started {
Ok(child) => child,
Err(error) => {
eprintln!("onepipeline: cannot follow session {token}'s events: {error}");
return None;
}
};
let Some(stdout) = child.stdout.take() else {
stop(&mut child);
eprintln!("onepipeline: cannot read session {token}'s events as they are written");
return None;
};
let progress = Arc::new(Progress::default());
let reached = Arc::clone(&progress);
let reader = std::thread::Builder::new()
.name(format!("{}-events", binary()))
.spawn(move || {
let mut skipped = 0usize;
for line in BufReader::new(stdout)
.lines()
.map_while(std::io::Result::ok)
{
if line.trim().is_empty() {
continue;
}
match serde_json::from_str::<Envelope>(&line) {
Ok(envelope) => {
reached.reached(envelope.seq);
sink(envelope);
}
Err(_) => skipped += 1,
}
}
report_skipped("onevcs", skipped);
});
match reader {
Ok(reader) => Some(Follower {
child,
reader: Some(reader),
progress,
}),
Err(error) => {
stop(&mut child);
eprintln!("onepipeline: cannot follow session {token}'s events: {error}");
None
}
}
}
#[derive(Debug, Default)]
struct Progress {
count: AtomicU64,
seq: AtomicU64,
}
impl Progress {
fn reached(&self, seq: u64) {
self.count.fetch_add(1, Ordering::SeqCst);
self.seq.fetch_max(seq, Ordering::SeqCst);
}
fn reached_through(&self) -> Option<u64> {
(self.count.load(Ordering::SeqCst) > 0).then(|| self.seq.load(Ordering::SeqCst))
}
}
#[derive(Debug)]
pub struct Follower {
child: Child,
reader: Option<std::thread::JoinHandle<()>>,
progress: Arc<Progress>,
}
impl Drop for Follower {
fn drop(&mut self) {
stop(&mut self.child);
if let Some(reader) = self.reader.take() {
let _ = reader.join();
}
}
}
impl Follower {
pub fn finish(mut self) -> Option<u64> {
let deadline = Instant::now() + FOLLOW_GRACE;
loop {
match self.child.try_wait() {
Ok(Some(_)) | Err(_) => break,
Ok(None) if Instant::now() >= deadline => {
stop(&mut self.child);
break;
}
Ok(None) => std::thread::sleep(FOLLOW_POLL),
}
}
if let Some(reader) = self.reader.take() {
let _ = reader.join();
}
self.progress.reached_through()
}
}
fn stop(child: &mut Child) {
let _ = child.kill();
let _ = child.wait();
}
pub fn report_skipped(tool: &str, skipped: usize) {
if skipped > 0 {
eprintln!("onepipeline: skipped {skipped} {tool} line(s) this build cannot read");
}
}
pub fn session_opened_event(session: &Session, labels: &crate::event::Labels) -> Envelope {
Envelope {
v: crate::event::ENVELOPE_VERSION,
ts: crate::sys::now_rfc3339(),
stream: format!("onevcs-{}", session.token.0),
seq: 0,
source: crate::event::Source::Vcs,
kind: crate::event::EventKind("session-opened".into()),
labels: labels.clone(),
payload: crate::journal::payload(&[
("token", serde_json::json!(session.token.0)),
("branch", serde_json::json!(session.branch)),
("base", serde_json::json!(session.base)),
("worktree", serde_json::json!(session.worktree)),
]),
artifacts: Vec::new(),
}
}
pub fn published_event(
published: &Published,
branch: &str,
labels: &crate::event::Labels,
) -> Envelope {
Envelope {
v: crate::event::ENVELOPE_VERSION,
ts: crate::sys::now_rfc3339(),
stream: format!("onevcs-{branch}"),
seq: 1,
source: crate::event::Source::Vcs,
kind: crate::event::EventKind("published".into()),
labels: labels.clone(),
payload: crate::journal::payload(&[
("branch", serde_json::json!(branch)),
("url", serde_json::json!(published.url)),
("id", serde_json::json!(published.id)),
("outcome", serde_json::json!(published.outcome)),
]),
artifacts: Vec::new(),
}
}
pub fn request_for(node: &crate::plan::Node) -> Option<SessionRequest> {
Some(SessionRequest {
repo: node.repo.clone()?,
branch: node.branch.clone(),
base: node.base_branch.clone(),
execution_checkout: node.execution_checkout.clone(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::plan::Node;
#[test]
fn every_merge_policy_has_one_spelling_on_the_command_line() {
assert_eq!(policy_arg(MergePolicy::LocalDirect), "local-direct");
assert_eq!(policy_arg(MergePolicy::ChangeOpen), "change-open");
assert_eq!(policy_arg(MergePolicy::ChangeAuto), "change-auto");
assert_eq!(policy_arg(MergePolicy::ChangeDirect), "change-direct");
}
#[test]
fn a_lifecycle_node_asks_for_the_session_its_fields_describe() {
let node = Node {
id: "service".into(),
repo: Some("owner/repo".into()),
branch: Some("feature".into()),
base_branch: Some("main".into()),
execution_checkout: Some("primary".into()),
persona: Some("engineer".into()),
task: Some("## What\nship".into()),
..Node::default()
};
let request = request_for(&node).expect("a lifecycle node asks for a session");
assert_eq!(request.repo, "owner/repo");
assert_eq!(request.branch.as_deref(), Some("feature"));
assert_eq!(request.base.as_deref(), Some("main"));
assert_eq!(request.execution_checkout.as_deref(), Some("primary"));
}
#[test]
fn a_direct_agent_node_asks_for_no_session() {
let node = Node {
id: "build".into(),
persona: Some("engineer".into()),
task: Some("## What\ndo it".into()),
..Node::default()
};
assert!(request_for(&node).is_none());
}
fn recorded(kind: &str, payload: serde_json::Value) -> Envelope {
Envelope {
v: crate::event::ENVELOPE_VERSION,
ts: "2026-01-01T00:00:00.000Z".into(),
stream: "s-1".into(),
seq: 1,
source: crate::event::Source::Vcs,
kind: crate::event::EventKind(kind.into()),
labels: crate::event::Labels::default(),
payload: payload.as_object().cloned().unwrap_or_default(),
artifacts: Vec::new(),
}
}
#[test]
fn a_change_request_the_session_recorded_is_where_a_human_reads_it() {
let published = published_from(&[recorded(
"change-opened",
serde_json::json!({"url": "https://example.invalid/pull/7", "id": "7"}),
)]);
assert_eq!(
published.url.as_deref(),
Some("https://example.invalid/pull/7")
);
assert_eq!(published.id.as_deref(), Some("7"));
assert_eq!(published.outcome.as_deref(), Some("change-open"));
}
#[test]
fn a_change_that_reached_its_base_outranks_the_request_that_opened_it() {
let published = published_from(&[
recorded(
"change-opened",
serde_json::json!({"url": "https://example.invalid/pull/7", "id": "7"}),
),
recorded(
"merge-queued",
serde_json::json!({"url": "https://example.invalid/pull/7"}),
),
recorded(
"change-merged",
serde_json::json!({"url": "https://example.invalid/pull/7", "sha": "abc"}),
),
]);
assert_eq!(published.outcome.as_deref(), Some("merged"));
assert_eq!(published.id.as_deref(), Some("7"));
}
#[test]
fn the_identitys_own_lock_queue_is_not_a_publication_the_host_is_holding() {
let published = published_from(&[
recorded("merge-queued", serde_json::json!({"identity": "repo"})),
recorded(
"merge-completed",
serde_json::json!({"identity": "repo", "sha": "abc"}),
),
]);
assert_eq!(published.outcome.as_deref(), Some("merged"));
assert_eq!(published.url, None);
}
#[test]
fn a_session_that_recorded_nothing_publishable_claims_no_outcome() {
assert_eq!(published_from(&[]), Published::default());
}
#[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()
);
}
}