use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use onevcs::{
EventStream, Lifecycle, MergePolicy, Providers, Publication, PublishOutcome, PublishRequest,
Session, SessionRequest, SessionToken, Subject,
};
use crate::error::{Error, Result};
use crate::event::Envelope;
use crate::filter::EventFilter;
fn sibling(message: impl Into<String>) -> Error {
Error::Sibling {
tool: "onevcs",
message: message.into(),
}
}
fn refusal(error: onevcs::Error) -> Error {
sibling(error.to_string())
}
fn providers() -> Providers<'static> {
Providers::real()
}
pub fn session_open(request: &SessionRequest) -> Result<Session> {
providers()
.vcs
.open_session(request.clone())
.map_err(refusal)
}
pub fn publish(
token: &str,
policy: Option<MergePolicy>,
title: Option<&str>,
body: Option<&str>,
) -> Result<Publication> {
let title = title
.map(|title| title.parse::<Subject>().map_err(sibling))
.transpose()?;
onevcs::publish(
&providers(),
&SessionToken(token.to_owned()),
&PublishRequest {
policy,
title,
body: body.map(str::to_owned),
},
)
.map_err(refusal)
}
pub fn outcome_of(outcome: &PublishOutcome) -> &'static str {
match outcome {
PublishOutcome::Merged(_) => "merged",
PublishOutcome::ChangeOpen(_) => "change-open",
PublishOutcome::Queued(_) => "queued",
PublishOutcome::NothingToPublish => "no-changes",
PublishOutcome::Failed { .. } => "publication-failed",
}
}
pub fn landing_of(outcome: &PublishOutcome) -> Option<crate::graph::Landing> {
use crate::graph::Landing;
match outcome {
PublishOutcome::Merged(_) => Some(Landing::Landed),
PublishOutcome::ChangeOpen(_) | PublishOutcome::Queued(_) => Some(Landing::Unlanded),
PublishOutcome::NothingToPublish | PublishOutcome::Failed { .. } => None,
}
}
pub fn change_url(outcome: &PublishOutcome) -> Option<String> {
match outcome {
PublishOutcome::ChangeOpen(url) | PublishOutcome::Queued(url) => Some(url.to_string()),
_ => None,
}
}
pub fn worktree_of(token: &str) -> Option<std::path::PathBuf> {
onevcs::session(&providers(), &SessionToken(token.to_owned()))
.map(|record| record.session.worktree)
.map_err(|error| {
eprintln!("onepipeline: cannot read session {token}'s record: {error}");
error
})
.ok()
}
pub fn session_close(token: &str) -> Result<Session> {
onevcs::close_session(&providers(), &SessionToken(token.to_owned())).map_err(refusal)
}
pub fn change_opened_in(token: &str) -> Option<String> {
let opened = kind_of(onevcs::EventKind::ChangeOpened);
events(token, None)
.iter()
.rev()
.find(|envelope| envelope.kind == opened)
.and_then(|envelope| envelope.payload.get("url"))
.and_then(|url| url.as_str())
.and_then(|url| onevcs::Url::parse(url.trim()).ok())
.map(|url| url.to_string())
}
pub fn holders_of(repo: &str) -> std::result::Result<Vec<onevcs::SessionHolder>, String> {
onevcs::session_holders(repo).map_err(|error| {
eprintln!("onepipeline: cannot read the session holders of {repo}: {error}");
error.to_string()
})
}
fn opened(token: &str, filter: Option<&EventFilter>) -> Option<EventStream> {
let filter = match filter.map(sibling_filter).transpose() {
Ok(filter) => filter.unwrap_or_default(),
Err(error) => {
eprintln!("onepipeline: cannot follow session {token}'s events: {error}");
return None;
}
};
match EventStream::open_filtered(&SessionToken(token.to_owned()), filter) {
Ok(stream) => Some(stream),
Err(error) => {
eprintln!("onepipeline: cannot read session {token}'s events: {error}");
None
}
}
}
fn next_batch(stream: &mut EventStream, token: &str) -> Vec<Envelope> {
match stream.read() {
Ok(events) => events.into_iter().map(relayed).collect(),
Err(error) => {
eprintln!("onepipeline: cannot read session {token}'s events: {error}");
Vec::new()
}
}
}
pub fn events(token: &str, filter: Option<&EventFilter>) -> Vec<Envelope> {
let Some(mut stream) = opened(token, filter) else {
return Vec::new();
};
next_batch(&mut stream, token)
}
fn sibling_filter(filter: &EventFilter) -> Result<onevcs::EventFilter> {
let document = serde_json::to_string(filter).map_err(|error| Error::Sibling {
tool: "onevcs",
message: format!("rendering the event filter: {error}"),
})?;
serde_json::from_str(&document).map_err(|error| Error::Sibling {
tool: "onevcs",
message: format!("`onevcs` refused the event filter: {error}"),
})
}
fn relayed(envelope: onevcs::Envelope) -> Envelope {
Envelope {
v: envelope.v,
ts: envelope.ts,
stream: envelope.stream,
seq: envelope.seq,
source: source_of(envelope.source),
kind: kind_of(envelope.kind),
labels: labels_of(envelope.labels),
payload: envelope.payload,
artifacts: envelope
.artifacts
.into_iter()
.map(|artifact| crate::event::ArtifactRef {
id: crate::event::ArtifactId(artifact.id.0),
kind: artifact.kind,
bytes: artifact.bytes,
})
.collect(),
}
}
fn source_of(source: onevcs::Source) -> crate::event::Source {
match source {
onevcs::Source::Agentgraph => crate::event::Source::Agentgraph,
onevcs::Source::Vcs => crate::event::Source::Vcs,
onevcs::Source::Pipeline => crate::event::Source::Pipeline,
}
}
fn kind_of(kind: onevcs::EventKind) -> crate::event::EventKind {
let wire = serde_json::to_value(kind)
.ok()
.and_then(|value| value.as_str().map(str::to_string))
.unwrap_or_else(|| format!("{kind:?}"));
crate::event::EventKind(wire)
}
fn labels_of(labels: onevcs::Labels) -> crate::event::Labels {
let mut extra = labels.extra;
if let Some(member) = labels.member {
extra.insert("member".to_owned(), serde_json::json!(member));
}
crate::event::Labels {
run_id: labels.run_id,
round: labels.round,
node: labels.node,
step: labels.step,
persona: labels.persona,
extra,
}
}
const FOLLOW_GRACE: Duration = Duration::from_secs(5);
const FOLLOW_POLL: Duration = Duration::from_millis(20);
pub fn follow(
token: &str,
filter: Option<&EventFilter>,
sink: Box<dyn Fn(Envelope) + Send>,
) -> Option<Follower> {
let mut stream = opened(token, filter)?;
let session = SessionToken(token.to_owned());
let progress = Arc::new(Progress::default());
let reached = Arc::clone(&progress);
let stop = Arc::new(AtomicBool::new(false));
let stopping = Arc::clone(&stop);
let followed = token.to_owned();
let reader = std::thread::Builder::new()
.name(format!("onevcs-{token}-events"))
.spawn(move || loop {
for envelope in next_batch(&mut stream, &followed) {
reached.reached(envelope.seq);
sink(envelope);
}
if stopping.load(Ordering::SeqCst) || settled(&session) {
return;
}
std::thread::sleep(FOLLOW_POLL);
});
match reader {
Ok(reader) => Some(Follower {
reader: Some(reader),
stop,
progress,
}),
Err(error) => {
eprintln!("onepipeline: cannot follow session {token}'s events: {error}");
None
}
}
}
fn settled(session: &SessionToken) -> bool {
onevcs::session(&providers(), session)
.map(|record| record.lifecycle == Lifecycle::Closed)
.unwrap_or(true)
}
#[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 {
reader: Option<std::thread::JoinHandle<()>>,
stop: Arc<AtomicBool>,
progress: Arc<Progress>,
}
impl Drop for Follower {
fn drop(&mut self) {
self.stop.store(true, Ordering::SeqCst);
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;
while self
.reader
.as_ref()
.is_some_and(|reader| !reader.is_finished())
&& Instant::now() < deadline
{
std::thread::sleep(FOLLOW_POLL);
}
self.stop.store(true, Ordering::SeqCst);
if let Some(reader) = self.reader.take() {
let _ = reader.join();
}
self.progress.reached_through()
}
}
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: &Publication, labels: &crate::event::Labels) -> Envelope {
Envelope {
v: crate::event::ENVELOPE_VERSION,
ts: crate::sys::now_rfc3339(),
stream: format!("onevcs-{}", published.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!(published.branch)),
("policy", serde_json::json!(published.policy)),
("outcome", serde_json::json!(outcome_of(&published.outcome))),
("url", serde_json::json!(change_url(&published.outcome))),
(
"landing",
serde_json::json!(landing_of(&published.outcome).map(crate::graph::Landing::as_str)),
),
]),
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 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());
}
#[test]
fn every_ending_a_publication_has_settles_the_node_under_its_own_name() {
let sha = onevcs::Sha("abc".into());
let url: onevcs::Url = "https://example.invalid/pull/7".parse().expect("a URL");
assert_eq!(outcome_of(&PublishOutcome::Merged(sha)), "merged");
assert_eq!(
outcome_of(&PublishOutcome::ChangeOpen(url.clone())),
"change-open"
);
assert_eq!(outcome_of(&PublishOutcome::Queued(url)), "queued");
assert_eq!(outcome_of(&PublishOutcome::NothingToPublish), "no-changes");
assert_eq!(
outcome_of(&PublishOutcome::Failed {
kind: onevcs::FailureKind::Gate,
reason: "the gate said no".into(),
retained: None,
}),
"publication-failed"
);
}
#[test]
fn only_a_change_observed_on_its_base_is_called_landed() {
use crate::graph::Landing;
let url: onevcs::Url = "https://example.invalid/pull/7".parse().expect("a URL");
assert_eq!(
landing_of(&PublishOutcome::Merged(onevcs::Sha("abc".into()))),
Some(Landing::Landed)
);
assert_eq!(
landing_of(&PublishOutcome::ChangeOpen(url.clone())),
Some(Landing::Unlanded)
);
assert_eq!(
landing_of(&PublishOutcome::Queued(url)),
Some(Landing::Unlanded)
);
assert_eq!(landing_of(&PublishOutcome::NothingToPublish), None);
assert_eq!(
landing_of(&PublishOutcome::Failed {
kind: onevcs::FailureKind::Gate,
reason: "the gate said no".into(),
retained: None,
}),
None
);
}
#[test]
fn a_change_request_is_where_a_human_reads_it_and_a_local_merge_names_none() {
let url: onevcs::Url = "https://example.invalid/pull/7".parse().expect("a URL");
assert_eq!(
change_url(&PublishOutcome::ChangeOpen(url.clone())).as_deref(),
Some("https://example.invalid/pull/7")
);
assert_eq!(
change_url(&PublishOutcome::Queued(url)).as_deref(),
Some("https://example.invalid/pull/7")
);
assert_eq!(
change_url(&PublishOutcome::Merged(onevcs::Sha("abc".into()))),
None
);
assert_eq!(change_url(&PublishOutcome::NothingToPublish), None);
}
#[test]
fn a_publication_records_what_the_sibling_said_it_did() {
let url: onevcs::Url = "https://example.invalid/pull/7".parse().expect("a URL");
let published = Publication {
session: SessionToken("s-1".into()),
branch: "onepipeline/service".into(),
policy: MergePolicy::ChangeOpen,
outcome: PublishOutcome::ChangeOpen(url),
};
let event = published_event(&published, &crate::event::Labels::default());
assert_eq!(event.stream, "onevcs-onepipeline/service");
assert_eq!(event.payload["branch"], "onepipeline/service");
assert_eq!(event.payload["policy"], "change-open");
assert_eq!(event.payload["outcome"], "change-open");
assert_eq!(event.payload["url"], "https://example.invalid/pull/7");
assert_eq!(event.payload["landing"], "unlanded");
let merged = Publication {
outcome: PublishOutcome::Merged(onevcs::Sha("abc".into())),
..published
};
let event = published_event(&merged, &crate::event::Labels::default());
assert_eq!(event.payload["landing"], "landed");
let empty = Publication {
outcome: PublishOutcome::NothingToPublish,
..merged
};
let event = published_event(&empty, &crate::event::Labels::default());
assert_eq!(event.payload["landing"], serde_json::Value::Null);
}
#[test]
fn a_session_stream_that_is_not_whole_is_read_for_what_it_holds() {
let root = std::env::temp_dir().join(format!("onepipeline-stream-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("streams")).expect("a scratch state root");
std::env::set_var(onevcs_home(), &root);
let record = |token: &str, seq: u64, kind: &str| {
serde_json::json!({
"v": 1,
"ts": "2026-01-01T00:00:00.000Z",
"stream": token,
"seq": seq,
"source": "vcs",
"kind": kind,
"labels": {},
"payload": {},
"artifacts": [],
})
.to_string()
};
let write = |token: &str, body: String| {
std::fs::write(root.join("streams").join(format!("{token}.ndjson")), body)
.expect("the stream is written");
};
let seqs = |envelopes: &[Envelope]| envelopes.iter().map(|e| e.seq).collect::<Vec<_>>();
let torn = "s-unterminated";
write(
torn,
format!(
"{}\n{}",
record(torn, 1, "session-opened"),
record(torn, 2, "push")
),
);
let mut stream = opened(torn, None).expect("the stream opens");
assert_eq!(
seqs(&next_batch(&mut stream, torn)),
vec![1, 2],
"a record whose newline was still unwritten was lost"
);
write(
torn,
format!(
"{}\n{}\n{}\n",
record(torn, 1, "session-opened"),
record(torn, 2, "push"),
record(torn, 3, "session-closed")
),
);
assert_eq!(
seqs(&next_batch(&mut stream, torn)),
vec![3],
"the terminator arriving handed a record back a second time"
);
let cut = "s-cutmidline";
let whole = record(cut, 1, "session-opened");
let partial = record(cut, 2, "push");
write(cut, format!("{whole}\n{}", &partial[..20]));
assert!(
events(cut, None).is_empty(),
"the typed reader now hands back the whole records before a torn one; \
narrow this assertion to the torn record alone"
);
assert!(events("s-neverwritten", None).is_empty());
let _ = std::fs::remove_dir_all(&root);
}
fn onevcs_home() -> &'static str {
"ONEVCS_HOME"
}
#[test]
fn a_relayed_envelope_keeps_the_kind_and_attribution_its_producer_wrote() {
let mut labels = onevcs::Labels {
member: Some("worker".into()),
..onevcs::Labels::default()
};
labels
.extra
.insert("session".into(), serde_json::json!("s-1"));
let envelope = relayed(onevcs::Envelope {
v: 1,
ts: "2026-01-01T00:00:00.000Z".into(),
stream: "s-1".into(),
seq: 4,
source: onevcs::Source::Vcs,
kind: onevcs::EventKind::ChangeOpened,
labels,
payload: serde_json::Map::new(),
artifacts: vec![onevcs::ArtifactRef {
id: onevcs::ArtifactId("a-1".into()),
kind: "log".into(),
bytes: 12,
}],
});
assert_eq!(envelope.kind.0, "change-opened");
assert_eq!(envelope.source, crate::event::Source::Vcs);
assert_eq!(envelope.seq, 4);
assert_eq!(envelope.labels.extra["member"], "worker");
assert_eq!(envelope.labels.extra["session"], "s-1");
assert_eq!(envelope.artifacts[0].id.0, "a-1");
}
}