use std::collections::BTreeMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use onevcs::{
DraftReason, 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: &SessionToken,
policy: Option<MergePolicy>,
title: Option<&str>,
body: Option<&str>,
draft: Option<&DraftReason>,
) -> Result<Publication> {
let title = title
.map(|title| title.parse::<Subject>().map_err(sibling))
.transpose()?;
if let Some(reason) = draft {
reason.checked().map_err(refusal)?;
}
onevcs::publish(
&providers(),
token,
&PublishRequest {
policy,
title,
body: body.map(str::to_owned),
draft: draft.cloned(),
},
)
.map_err(refusal)
}
pub const DRAFTED: &str = "change-draft";
pub fn outcome_of(outcome: &PublishOutcome) -> &'static str {
match outcome {
PublishOutcome::Merged(_) => "merged",
PublishOutcome::ChangeOpen(_) => "change-open",
PublishOutcome::ChangeDraft(_) => DRAFTED,
PublishOutcome::Queued(_) => "queued",
PublishOutcome::NothingToPublish => "no-changes",
PublishOutcome::Failed { kind, .. } => failure_of(*kind).outcome(),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Preserving {
ChecksFailed,
ChecksUnsettled,
PushRejected,
SyncConflict,
}
impl Preserving {
#[must_use]
pub fn outcome(self) -> &'static str {
match self {
Self::ChecksFailed => "checks-failed",
Self::ChecksUnsettled => "checks-unsettled",
Self::PushRejected => "push-rejected",
Self::SyncConflict => "sync-conflict",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Failure {
Preserving(Preserving),
Unread,
Terminal,
}
impl Failure {
pub const RESIDUAL: &'static str = "publication-failed";
pub const UNREAD: &'static str = "pushed-unverified";
#[must_use]
pub fn outcome(self) -> &'static str {
match self {
Self::Preserving(preserving) => preserving.outcome(),
Self::Unread => Self::UNREAD,
Self::Terminal => Self::RESIDUAL,
}
}
}
pub fn failure_of(kind: onevcs::FailureKind) -> Failure {
use onevcs::FailureKind;
match kind {
FailureKind::ChecksFailed => Failure::Preserving(Preserving::ChecksFailed),
FailureKind::ChecksUnsettled => Failure::Preserving(Preserving::ChecksUnsettled),
FailureKind::PushRejected => Failure::Preserving(Preserving::PushRejected),
FailureKind::SyncConflict => Failure::Preserving(Preserving::SyncConflict),
FailureKind::PushedUnverified => Failure::Unread,
FailureKind::Gate | FailureKind::Invalid | FailureKind::NotImplemented => Failure::Terminal,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Evidence {
pub kind: crate::event::EventKind,
pub id: crate::event::ArtifactId,
}
pub fn evidence_in(token: &SessionToken) -> Vec<Evidence> {
let mut evidence: Vec<Evidence> = Vec::new();
for envelope in events(token, None) {
for artifact in envelope.artifacts {
let found = Evidence {
kind: envelope.kind.clone(),
id: artifact.id,
};
if !evidence.iter().any(|kept| kept.id == found.id) {
evidence.push(found);
}
}
}
evidence
}
pub fn landing_of(outcome: &PublishOutcome) -> Option<crate::graph::Landing> {
use crate::graph::Landing;
match outcome {
PublishOutcome::Merged(_) => Some(Landing::Landed),
PublishOutcome::ChangeOpen(_)
| PublishOutcome::ChangeDraft(_)
| PublishOutcome::Queued(_) => Some(Landing::Unlanded),
PublishOutcome::NothingToPublish | PublishOutcome::Failed { .. } => None,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum LandingRead {
Answered(onevcs::Landed),
Refused {
because: String,
},
}
pub(crate) fn landing_now(branch: &str, repo: Option<&str>) -> LandingRead {
crate::rendercost::landing_read_taken(branch, repo);
let answered = read_of(onevcs::landing_status(branch, repo));
crate::rendercost::repository_asked(repo);
answered
}
fn read_of(answered: onevcs::Result<onevcs::Landed>) -> LandingRead {
match answered {
Ok(landed) => LandingRead::Answered(landed),
Err(refused) => LandingRead::Refused {
because: crate::views::one_line(&format!("this host could not decide it: {refused}")),
},
}
}
pub(crate) fn proved_landed(branch: &str, repo: Option<&str>) -> bool {
matches!(
landing_now(branch, repo),
LandingRead::Answered(landed) if landed.is_landed()
)
}
pub fn change_url(outcome: &PublishOutcome) -> Option<String> {
match outcome {
PublishOutcome::ChangeOpen(url)
| PublishOutcome::ChangeDraft(url)
| PublishOutcome::Queued(url) => Some(url.to_string()),
_ => None,
}
}
pub fn working_session(token: &SessionToken) -> Option<Session> {
onevcs::session(&providers(), token)
.map(|record| record.session)
.map_err(|error| {
eprintln!(
"onepipeline: cannot read session {}'s record: {error}",
token.0
);
error
})
.ok()
}
pub fn session_close(token: &SessionToken) -> Result<Session> {
onevcs::close_session(&providers(), token).map_err(refusal)
}
pub fn branch_head_in(token: &SessionToken) -> Option<String> {
let preserved = kind_of(onevcs::EventKind::CommitPreserved);
events(token, None)
.iter()
.rev()
.find(|envelope| envelope.kind == preserved)
.and_then(|envelope| envelope.payload.get("sha"))
.and_then(|sha| sha.as_str())
.and_then(usable)
}
pub fn change_opened_in(token: &SessionToken) -> 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: &SessionToken, 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 {}'s events: {error}",
token.0
);
return None;
}
};
match EventStream::open_filtered(token, filter) {
Ok(stream) => Some(stream),
Err(error) => {
eprintln!(
"onepipeline: cannot read session {}'s events: {error}",
token.0
);
None
}
}
}
fn next_batch(stream: &mut EventStream, token: &SessionToken) -> Vec<Envelope> {
match stream.read() {
Ok(events) => events.into_iter().map(relayed).collect(),
Err(error) => {
eprintln!(
"onepipeline: cannot read session {}'s events: {error}",
token.0
);
Vec::new()
}
}
}
pub fn events(token: &SessionToken, 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),
phase: Some(phase_of(envelope.phase)),
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 phase_of(phase: onevcs::Phase) -> crate::event::Phase {
match phase {
onevcs::Phase::Development => crate::event::Phase::Development,
onevcs::Phase::Integrate => crate::event::Phase::Integrate,
onevcs::Phase::Review => crate::event::Phase::Review,
onevcs::Phase::Release => crate::event::Phase::Release,
}
}
fn phase_of_kind(kind: onevcs::EventKind) -> Option<crate::event::Phase> {
onevcs::Phase::of(kind).map(phase_of)
}
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_POLL: Duration = Duration::from_millis(20);
pub fn follow(
token: &SessionToken,
filter: Option<&EventFilter>,
sink: Box<dyn Fn(Envelope) + Send>,
) -> Option<Follower> {
let mut stream = opened(token, filter)?;
let progress: Arc<Mutex<Watermarks>> = Arc::new(Mutex::new(Watermarks::default()));
let reached = Arc::clone(&progress);
let stop = Arc::new(AtomicBool::new(false));
let stopping = Arc::clone(&stop);
let followed = token.clone();
let reader = std::thread::Builder::new()
.name(format!("onevcs-{}-events", token.0))
.spawn(move || loop {
for envelope in next_batch(&mut stream, &followed) {
if let Ok(mut reached) = reached.lock() {
reached.reached(&envelope);
}
sink(envelope);
}
if stopping.load(Ordering::SeqCst) || settled(&followed) {
return;
}
std::thread::sleep(FOLLOW_POLL);
});
match reader {
Ok(reader) => Some(Follower {
reader: Some(reader),
stop,
progress,
}),
Err(error) => {
eprintln!(
"onepipeline: cannot follow session {}'s events: {error}",
token.0
);
None
}
}
}
fn settled(session: &SessionToken) -> bool {
onevcs::session(&providers(), session)
.map(|record| record.lifecycle == Lifecycle::Closed)
.unwrap_or(true)
}
#[derive(Debug, Default, Clone)]
pub struct Watermarks {
reached: BTreeMap<String, u64>,
}
impl Watermarks {
pub fn of_relayed(envelopes: &[Envelope]) -> Self {
let mut marks = Self::default();
for envelope in envelopes {
marks.reached(envelope);
}
marks
}
pub fn reached(&mut self, envelope: &Envelope) {
self.reached
.entry(envelope.stream.clone())
.and_modify(|seq| *seq = (*seq).max(envelope.seq))
.or_insert(envelope.seq);
}
#[must_use]
pub fn beyond(&self, envelope: &Envelope) -> bool {
self.reached
.get(&envelope.stream)
.is_none_or(|seq| envelope.seq > *seq)
}
}
#[must_use]
pub fn is_terminator(envelope: &Envelope) -> bool {
envelope.kind == kind_of(onevcs::EventKind::SessionClosed)
}
#[derive(Debug)]
pub struct Follower {
reader: Option<std::thread::JoinHandle<()>>,
stop: Arc<AtomicBool>,
progress: Arc<Mutex<Watermarks>>,
}
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) -> Watermarks {
self.stop.store(true, Ordering::SeqCst);
if let Some(reader) = self.reader.take() {
let _ = reader.join();
}
let mut reached = self
.progress
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
std::mem::take(&mut *reached)
}
}
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: kind_of(onevcs::EventKind::SessionOpened),
phase: phase_of_kind(onevcs::EventKind::SessionOpened),
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()),
phase: None,
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(),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DispatchSession {
token: SessionToken,
branch: BranchName,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BranchName(String);
impl BranchName {
pub fn checked(value: &str) -> Option<Self> {
usable(value).map(Self)
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for BranchName {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl DispatchSession {
pub fn read_from(envelope: &Envelope) -> Option<Self> {
let session: Session =
serde_json::from_value(serde_json::Value::Object(envelope.payload.clone())).ok()?;
let token = token_of(&session.token.0)?;
if !wrote(&envelope.stream, &token) {
return None;
}
Some(Self {
token,
branch: BranchName::checked(&session.branch)?,
})
}
pub fn token(&self) -> &SessionToken {
&self.token
}
pub fn branch(&self) -> &BranchName {
&self.branch
}
}
fn wrote(stream: &str, token: &SessionToken) -> bool {
stream == token.0 || stream == format!("onevcs-{}", token.0)
}
fn token_of(value: &str) -> Option<SessionToken> {
let value = usable(value)?;
if value.contains(['/', '\\']) || value.trim_matches('.').is_empty() {
return None;
}
Some(SessionToken(value))
}
pub(crate) fn usable(value: &str) -> Option<String> {
if value.is_empty() || value.len() >= crate::event::MAX_PAYLOAD_TEXT_BYTES {
return None;
}
if value.chars().any(|c| c.is_whitespace() || c.is_control()) {
return None;
}
Some(value.to_owned())
}
pub fn is_session_opened(kind: &crate::event::EventKind) -> bool {
*kind == kind_of(onevcs::EventKind::SessionOpened)
}
pub fn is_merge_completed(kind: &crate::event::EventKind) -> bool {
*kind == kind_of(onevcs::EventKind::MergeCompleted)
}
pub fn landing_commit_of(event: &crate::event::Envelope) -> Option<String> {
if event.source != crate::event::Source::Vcs || !is_merge_completed(&event.kind) {
return None;
}
usable(event.payload.get("sha")?.as_str()?)
}
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)]
pub(crate) fn scratch_home_held() -> std::sync::MutexGuard<'static, ()> {
static IN_USE: Mutex<()> = Mutex::new(());
IN_USE
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use super::*;
use crate::plan::Node;
const EVERY_KIND: &[onevcs::FailureKind] = &[
onevcs::FailureKind::Gate,
onevcs::FailureKind::Invalid,
onevcs::FailureKind::SyncConflict,
onevcs::FailureKind::NotImplemented,
onevcs::FailureKind::ChecksFailed,
onevcs::FailureKind::ChecksUnsettled,
onevcs::FailureKind::PushRejected,
onevcs::FailureKind::PushedUnverified,
];
const EVERY_PRESERVING: &[Preserving] = &[
Preserving::ChecksFailed,
Preserving::ChecksUnsettled,
Preserving::PushRejected,
Preserving::SyncConflict,
];
#[test]
fn the_publication_failure_words_and_the_contract_are_one_vocabulary() {
let contract = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("docs/contract.md"),
)
.expect("the contract ships");
for kind in EVERY_KIND {
let word = failure_of(*kind).outcome();
assert!(
contract.contains(&format!("`{word}`")),
"docs/contract.md does not name the `{word}` outcome this crate settles on"
);
}
let clause = contract
.split_once("under a word of its own:")
.expect("the contract lists the words a failed publication settles on")
.1
.split_once("is the **residual**")
.expect("the clause ends where the residual is named")
.0;
let listed: BTreeSet<&str> = clause.split('`').skip(1).step_by(2).collect();
let vocabulary: BTreeSet<&str> = EVERY_PRESERVING
.iter()
.map(|preserving| preserving.outcome())
.chain([Failure::UNREAD, Failure::RESIDUAL])
.collect();
let produced: BTreeSet<&str> = EVERY_KIND
.iter()
.map(|kind| failure_of(*kind).outcome())
.collect();
assert_eq!(
produced, vocabulary,
"the words `failure_of` settles on are not the vocabulary `Preserving` closes"
);
assert_eq!(
listed, vocabulary,
"the contract's publication-failure words are not the ones this crate settles on"
);
}
#[test]
fn the_readmes_publication_failure_summary_is_the_vocabulary_this_crate_settles_on() {
let raw = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("README.md"),
)
.expect("the README ships");
let readme = raw.split_whitespace().collect::<Vec<_>>().join(" ");
for kind in EVERY_KIND {
let word = failure_of(*kind).outcome();
assert!(
readme.contains(&format!("`{word}`")),
"the README does not name the `{word}` outcome this crate settles on"
);
}
let clause = readme
.split_once("settle under a word of their own")
.expect("the README names the failures that settle under a word of their own")
.1
.split_once("The first four leave the rejected tree")
.expect("that clause ends where the README says what those failures share")
.0;
let listed: BTreeSet<&str> = clause.split('`').skip(1).step_by(2).collect();
let routed: BTreeSet<&str> = EVERY_PRESERVING
.iter()
.map(|preserving| preserving.outcome())
.chain(std::iter::once(Failure::UNREAD))
.collect();
assert_eq!(
listed, routed,
"the README's named failures are not the ones this crate gives a word of its own"
);
assert!(
readme.contains(&format!("`{}` is answered differently", Failure::UNREAD)),
"the README does not say that `{}` is recovered by a re-read",
Failure::UNREAD
);
assert!(
readme.contains(crate::engine::MERGE_PATH_READS_ENV),
"the README does not name what bounds that re-read"
);
assert!(
readme.contains(&format!("Everything else settles `{}`", Failure::RESIDUAL)),
"the README does not name `{}` as what everything else settles on",
Failure::RESIDUAL
);
}
#[test]
fn each_kind_is_on_the_side_of_the_line_the_contract_puts_it() {
let terminal = [
onevcs::FailureKind::Invalid,
onevcs::FailureKind::NotImplemented,
onevcs::FailureKind::Gate,
];
for kind in terminal {
assert_eq!(
failure_of(kind),
Failure::Terminal,
"{kind:?} is retried, and asking again would reproduce the diagnosis"
);
}
assert_eq!(
failure_of(onevcs::FailureKind::PushedUnverified),
Failure::Unread,
"a push that reached the remote is answered by re-dispatching the agent"
);
let preserving: BTreeSet<&str> = EVERY_KIND
.iter()
.filter(|kind| !terminal.contains(kind))
.filter(|kind| **kind != onevcs::FailureKind::PushedUnverified)
.map(|kind| match failure_of(*kind) {
Failure::Preserving(preserving) => preserving.outcome(),
Failure::Unread | Failure::Terminal => {
panic!("{kind:?} is not the tree being rejected, so nothing re-dispatches it")
}
})
.collect();
assert_eq!(
preserving,
BTreeSet::from([
"checks-failed",
"checks-unsettled",
"push-rejected",
"sync-conflict"
]),
"the failures a further attempt can answer are not the four the contract names"
);
assert_eq!(Failure::Terminal.outcome(), Failure::RESIDUAL);
assert_eq!(Failure::Unread.outcome(), "pushed-unverified");
}
fn ours(token: &str, branch: &str) -> Envelope {
session_opened_event(
&Session {
token: SessionToken(token.to_owned()),
worktree: std::path::PathBuf::from("/tmp/worktree"),
branch: branch.to_owned(),
base: "main".to_owned(),
},
&crate::event::Labels::default(),
)
}
#[test]
fn a_session_record_is_read_only_where_every_value_it_names_is_usable() {
let read = DispatchSession::read_from(&ours("s-abc", "onevcs/s-abc"))
.expect("a whole session record is read");
assert_eq!(read.token(), &SessionToken("s-abc".into()));
assert_eq!(read.branch().as_str(), "onevcs/s-abc");
let mut theirs = ours("s-abc", "onevcs/s-abc");
theirs.stream = "s-abc".to_owned();
for (key, value) in [
("identity", serde_json::json!("github.com/owner/service")),
("clone", serde_json::json!("/tmp/runs/s-abc/clone")),
("execution_checkout", serde_json::json!("/tmp/service")),
("publication_checkout", serde_json::json!("/tmp/service")),
("reused", serde_json::json!(true)),
] {
theirs.payload.insert(key.to_owned(), value);
}
assert_eq!(
DispatchSession::read_from(&theirs).as_ref(),
Some(&read),
"the sibling's own record of a session it opened was not read"
);
let without = |key: &str| {
let mut event = ours("s-abc", "onevcs/s-abc");
event.payload.remove(key);
event
};
let mut elsewhere = ours("s-elsewhere", "onevcs/s-elsewhere");
elsewhere.stream = "onevcs-s-abc".to_owned();
for (why, event) in [
("a record naming no branch at all", without("branch")),
("a record naming no token at all", without("token")),
("a record about another session entirely", elsewhere),
] {
assert_eq!(
DispatchSession::read_from(&event),
None,
"{why} was read as a session a manager can be sent to"
);
}
for (why, value) in unusable() {
assert_eq!(
DispatchSession::read_from(&ours("s-abc", &value)),
None,
"a branch that is {why} was read as one a manager can be sent to"
);
}
let hops = [
("a directory hop", "..".to_owned()),
("carrying a path separator", "onevcs/../x".to_owned()),
];
for (why, value) in unusable().into_iter().chain(hops) {
let mut record = ours(&value, "onevcs/s-abc");
record.stream = value.clone();
assert_eq!(
DispatchSession::read_from(&record),
None,
"a token that is {why} was read as one a session answers to"
);
}
}
fn unusable() -> Vec<(&'static str, String)> {
vec![
("empty", String::new()),
(
"a line of its own",
"onevcs/x\n audit running".to_owned(),
),
("carrying a space", "onevcs/ x".to_owned()),
("carrying a control character", "onevcs/x\u{7}".to_owned()),
(
"as long as the bound a producer cuts text at",
"b".repeat(crate::event::MAX_PAYLOAD_TEXT_BYTES),
),
]
}
#[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::ChangeDraft(url.clone())),
"change-draft"
);
assert_eq!(outcome_of(&PublishOutcome::Queued(url)), "queued");
assert_eq!(outcome_of(&PublishOutcome::NothingToPublish), "no-changes");
let failed = |kind| {
outcome_of(&PublishOutcome::Failed {
kind,
reason: "the publication said no".into(),
retained: None,
})
};
assert_eq!(failed(onevcs::FailureKind::Gate), "publication-failed");
assert_eq!(failed(onevcs::FailureKind::Invalid), "publication-failed");
assert_eq!(
failed(onevcs::FailureKind::NotImplemented),
"publication-failed"
);
assert_eq!(failed(onevcs::FailureKind::ChecksFailed), "checks-failed");
assert_eq!(
failed(onevcs::FailureKind::ChecksUnsettled),
"checks-unsettled"
);
assert_eq!(failed(onevcs::FailureKind::PushRejected), "push-rejected");
assert_eq!(failed(onevcs::FailureKind::SyncConflict), "sync-conflict");
assert_eq!(
failed(onevcs::FailureKind::PushedUnverified),
"pushed-unverified"
);
}
#[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::ChangeDraft(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::PushRejected,
reason: "the merge path refused the publishing push".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 _home = super::scratch_home_held();
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";
let followed = SessionToken(torn.to_owned());
write(
torn,
format!(
"{}\n{}",
record(torn, 1, "session-opened"),
record(torn, 2, "push")
),
);
let mut stream = opened(&followed, None).expect("the stream opens");
assert_eq!(
seqs(&next_batch(&mut stream, &followed)),
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, &followed)),
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(&SessionToken(cut.to_owned()), 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(&SessionToken("s-neverwritten".into()), None).is_empty());
let _ = std::fs::remove_dir_all(&root);
}
fn onevcs_home() -> &'static str {
"ONEVCS_HOME"
}
#[test]
fn the_marks_a_later_reader_starts_from_are_folded_from_what_the_store_holds() {
let wrote = |stream: &str, seq: u64| Envelope {
v: crate::event::ENVELOPE_VERSION,
ts: "2026-01-01T00:00:00.000Z".into(),
stream: stream.to_owned(),
seq,
source: crate::event::Source::Vcs,
kind: crate::event::EventKind("release-observed".into()),
phase: Some(crate::event::Phase::Release),
labels: crate::event::Labels::default(),
payload: serde_json::Map::new(),
artifacts: Vec::new(),
};
let held = vec![wrote("s-1", 1), wrote("s-1", 2), wrote("releases-abc", 7)];
let marks = Watermarks::of_relayed(&held);
for envelope in &held {
assert!(
!marks.beyond(envelope),
"a record the store already holds would be relayed again: {envelope:?}"
);
}
assert!(marks.beyond(&wrote("s-1", 3)));
assert!(marks.beyond(&wrote("releases-abc", 8)));
assert!(!marks.beyond(&wrote("releases-abc", 6)));
assert!(marks.beyond(&wrote("releases-def", 0)));
}
#[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,
phase: onevcs::Phase::Review,
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");
}
#[test]
fn a_read_that_got_no_answer_says_what_refused_rather_than_that_nothing_landed() {
let refused = read_of(Err(onevcs::Error::Invalid {
reason: "no such repository\nand a second line".into(),
}));
assert_eq!(
refused,
LandingRead::Refused {
because: "this host could not decide it: invalid input: no such repository and \
a second line"
.into()
}
);
assert!(!proved_landed_from(&refused), "a refusal read as a landing");
let landed = read_of(Ok(onevcs::Landed::Yes {
evidence: onevcs::LandingEvidence::RecordedLanding {
commit: onevcs::Sha("abc1234".into()),
},
}));
assert!(matches!(
&landed,
LandingRead::Answered(onevcs::Landed::Yes { .. })
));
assert!(proved_landed_from(&landed));
}
fn proved_landed_from(read: &LandingRead) -> bool {
matches!(read, LandingRead::Answered(landed) if landed.is_landed())
}
}