use std::collections::{BTreeMap, BTreeSet};
use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender};
use std::time::{Duration, Instant};
use onevcs::releases::{ReleaseStyle, RepositoryReleases, TargetName};
use onevcs::{Adoption, InstructionTemplate, ReleaseStatus};
use serde_json::{json, Value};
use crate::channel::Surface;
use crate::error::Result;
use crate::graph::NodeStatus;
use crate::journal::{self, Journal};
use crate::ledger::RunPaths;
use crate::plan::{CrossRepoReference, Node};
use crate::projection::RunState;
pub const POLL_ENV: &str = "ONEPIPELINE_RELEASE_POLL_SECONDS";
pub const DEFAULT_POLL_SECONDS: u64 = 60;
pub const SURFACE_ENV: &str = "ONEPIPELINE_RELEASE_SURFACE_SECONDS";
pub const WITHDRAWN_ASK_ENV: &str = "ONEPIPELINE_RELEASE_WITHDRAWN_ASK_SECONDS";
pub const DEFAULT_SURFACE_SECONDS: u64 = 900;
pub const WAIT_SURFACE_KIND: &str = "release-wait";
type Key = (String, String);
type Answered = (Vec<Key>, Answer);
pub(crate) fn adoption_of(node: &Node) -> Adoption {
if let Some(declared) = node.adoption {
return declared;
}
if let Some(repo) = node.repo.as_deref() {
if let Ok(resolved) = onevcs::adoption_for(repo) {
return resolved;
}
}
Adoption::Fast
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Answer {
Released {
version: String,
},
NotReleased,
AwaitingHumanStep,
NotAnswered,
NotLanded,
}
const NO_ANSWER_YET: &str = "no-answer-yet";
impl Answer {
pub(crate) fn as_str(&self) -> &'static str {
match self {
Self::Released { .. } => "released",
Self::NotReleased => "not-released",
Self::AwaitingHumanStep => "awaiting-human-step",
Self::NotAnswered => "not-answered",
Self::NotLanded => "not-landed",
}
}
fn version(&self) -> Option<&str> {
match self {
Self::Released { version } => Some(version),
_ => None,
}
}
fn of(status: &onevcs::Result<ReleaseStatus>) -> Self {
match status {
Ok(ReleaseStatus::Released { version, .. }) => match renderable(version) {
Some(version) => Self::Released { version },
None => Self::NotAnswered,
},
Ok(ReleaseStatus::NotReleased { .. }) => Self::NotReleased,
Ok(ReleaseStatus::AwaitingHumanStep { .. }) => Self::AwaitingHumanStep,
Ok(ReleaseStatus::NotAnswered { .. }) | Err(_) => Self::NotAnswered,
Ok(ReleaseStatus::NotLanded) => Self::NotLanded,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Dependency {
pub dep: String,
pub identity: String,
pub branch: Option<String>,
pub commit: Option<String>,
pub landing: Option<String>,
pub target: Option<TargetName>,
pub style: Option<ReleaseStyle>,
pub action: Option<String>,
pub instructions: Option<InstructionTemplate>,
}
impl Dependency {
fn reference(&self) -> Option<&str> {
self.landing
.as_deref()
.or(self.branch.as_deref())
.or(self.commit.as_deref())
}
fn askable(&self) -> bool {
self.reference().is_some() && self.style.is_some()
}
fn row(&self, version: Option<&str>) -> CrossRepoReference {
CrossRepoReference {
dependency: self.dep.clone(),
repository: self.identity.clone(),
branch: self.branch.clone().unwrap_or_default(),
commit: self.commit.clone().unwrap_or_default(),
release_target: self
.target
.as_ref()
.map(TargetName::to_string)
.unwrap_or_default(),
version: version.unwrap_or_default().to_owned(),
adoption_instructions: self.instructions.clone(),
}
}
fn named(&self) -> String {
match &self.target {
Some(target) => format!("{} {target}", self.identity),
None => self.identity.clone(),
}
}
}
#[derive(Debug)]
struct Repositories {
known: BTreeMap<String, std::result::Result<RepositoryReleases, UnreadRepository>>,
retry_every: Duration,
}
#[derive(Debug, Clone)]
struct UnreadRepository {
reason: String,
at: Instant,
}
impl Default for Repositories {
fn default() -> Self {
Self::retrying_every(Duration::from_secs(poll_seconds()))
}
}
impl Repositories {
fn retrying_every(retry_every: Duration) -> Self {
Self {
known: BTreeMap::new(),
retry_every,
}
}
fn of(&mut self, repo: &str) -> std::result::Result<&RepositoryReleases, String> {
let stale = self.known.get(repo).is_some_and(|known| match known {
Ok(_) => false,
Err(unread) => unread.at.elapsed() >= self.retry_every,
});
if stale {
self.known.remove(repo);
}
self.known
.entry(repo.to_owned())
.or_insert_with(|| {
onevcs::release_targets(repo).map_err(|failure| UnreadRepository {
reason: failure.to_string(),
at: Instant::now(),
})
})
.as_ref()
.map_err(|unread| unread.reason.clone())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Question {
keys: Vec<Key>,
reference: String,
target: Option<TargetName>,
style: ReleaseStyle,
}
struct Asker {
questions: Sender<Vec<Question>>,
answers: Receiver<Answered>,
}
const TICK: Duration = Duration::from_millis(250);
impl Asker {
fn start(poll: Duration) -> Self {
let (questions, asked): (Sender<Vec<Question>>, Receiver<Vec<Question>>) = mpsc::channel();
let (answered, answers): (Sender<Answered>, Receiver<Answered>) = mpsc::channel();
std::thread::Builder::new()
.name("release-asker".to_owned())
.spawn(move || ask_until_dropped(&asked, &answered, poll))
.map(drop)
.unwrap_or_else(|error| {
eprintln!("onepipeline: cannot start the release watch: {error}");
}); Self { questions, answers }
}
fn ask(&self, questions: Vec<Question>) {
let _ = self.questions.send(questions);
}
fn answered(&self) -> Vec<Answered> {
self.answers.try_iter().collect()
}
}
fn ask_until_dropped(asked: &Receiver<Vec<Question>>, answered: &Sender<Answered>, poll: Duration) {
let linger = withdrawn_ask();
let mut questions: Vec<Question> = Vec::new();
let mut withdrawn: Vec<(Question, Instant)> = Vec::new();
let mut probed: Option<Instant> = None;
loop {
match asked.recv_timeout(TICK) {
Ok(fresh) => questions = retire(&questions, fresh, linger, &mut withdrawn),
Err(RecvTimeoutError::Timeout) => {}
Err(RecvTimeoutError::Disconnected) => return,
}
while let Ok(fresh) = asked.try_recv() {
questions = retire(&questions, fresh, linger, &mut withdrawn);
}
withdrawn.retain(|(_, since)| since.elapsed() < linger);
let due = probed.is_none_or(|last| last.elapsed() >= poll);
let mut ran_a_probe = false;
for question in questions
.iter()
.chain(withdrawn.iter().map(|(held, _)| held))
{
if question.style == ReleaseStyle::Automated {
if !due {
continue;
}
ran_a_probe = true;
}
crate::loopstats::release_asked();
let answer = Answer::of(&onevcs::release_status(
&question.reference,
question.target.as_ref(),
));
if answered.send((question.keys.clone(), answer)).is_err() {
return;
}
}
if ran_a_probe {
probed = Some(Instant::now());
}
}
}
pub(crate) struct Watch {
repositories: Repositories,
dependencies: BTreeMap<String, Vec<Dependency>>,
answers: BTreeMap<Key, Answer>,
since: BTreeMap<Key, u64>,
adopted: BTreeSet<String>,
arrived: BTreeSet<Key>,
surfaced: BTreeMap<String, Instant>,
surface_every: Duration,
relay_every: Duration,
relayed: Option<Instant>,
stated: BTreeMap<String, BTreeMap<String, String>>,
read_landings: Option<Instant>,
unresolved: BTreeMap<String, Vec<Unresolved>>,
asker: Asker,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Unresolved {
dep: String,
reason: String,
since: u64,
}
const UNRESOLVED: &str = "unresolved";
impl Watch {
pub(crate) fn of_run(paths: &RunPaths) -> Self {
let mut adopted = BTreeSet::new();
let mut arrived = BTreeSet::new();
for event in journal::read(&paths.journal()) {
let Some(node) = event.labels.node.clone().and_then(|node| renderable(&node)) else {
continue;
};
match journal::PipelineKind::from_wire(&event.kind) {
Some(journal::PipelineKind::ReleaseAdopted) => {
if !Released::of_payload(event.payload.get("versions").unwrap_or(&Value::Null))
.is_empty()
{
adopted.insert(node);
}
}
Some(journal::PipelineKind::ReleaseArrived) => {
if let Some(dep) = event
.payload
.get("dep")
.and_then(Value::as_str)
.and_then(renderable)
{
arrived.insert((node, dep));
}
}
_ => {}
}
}
Self {
repositories: Repositories::default(),
dependencies: BTreeMap::new(),
answers: BTreeMap::new(),
since: BTreeMap::new(),
adopted,
arrived,
surfaced: BTreeMap::new(),
unresolved: BTreeMap::new(),
surface_every: Duration::from_secs(surface_every_seconds()),
relay_every: Duration::from_secs(poll_seconds()),
relayed: None,
stated: BTreeMap::new(),
read_landings: None,
asker: Asker::start(Duration::from_secs(poll_seconds())),
}
}
pub(crate) fn references(&self, node: &Node) -> Vec<CrossRepoReference> {
self.dependencies
.get(&node.id)
.map(|dependencies| {
dependencies
.iter()
.map(|dependency| {
dependency.row(
self.answers
.get(&(node.id.clone(), dependency.dep.clone()))
.and_then(Answer::version),
)
})
.collect()
})
.unwrap_or_default()
}
pub(crate) fn take_up_every(&self) -> Duration {
self.surface_every
.min(self.relay_every)
.min(Duration::from_secs(60))
}
pub(crate) fn take_up_answers(&mut self) -> bool {
let mut arrived = false;
for (keys, answer) in self.asker.answered() {
arrived = true;
self.take_up(&keys, &answer);
}
arrived
}
fn take_up(&mut self, keys: &[Key], answer: &Answer) {
for key in keys {
if self.answers.get(key).and_then(Answer::version).is_some() {
continue;
}
self.answers.insert(key.clone(), answer.clone());
}
}
pub(crate) fn names_a_release_dependency(&self) -> bool {
!self.unresolved.is_empty() || self.dependencies.values().any(|of| !of.is_empty())
}
pub(crate) fn relays_anything(&mut self, state: &RunState) -> bool {
let repositories: Vec<String> = state
.sessions
.keys()
.filter_map(|node| state.graph.get(node).and_then(|node| node.repo.clone()))
.collect();
repositories.into_iter().any(|repo| {
self.repositories
.of(&repo)
.is_ok_and(|releases| !releases.targets.is_empty())
})
}
pub(crate) fn awaited_deps(&self, node: &str) -> Vec<String> {
self.dependencies
.get(node)
.map(Vec::as_slice)
.unwrap_or_default()
.iter()
.filter(|dependency| {
self.answers
.get(&(node.to_owned(), dependency.dep.clone()))
.and_then(Answer::version)
.is_none()
})
.map(|dependency| dependency.dep.clone())
.chain(self.unresolved_of(node).iter().map(|it| it.dep.clone()))
.collect()
}
fn unresolved_of(&self, node: &str) -> &[Unresolved] {
self.unresolved
.get(node)
.map(Vec::as_slice)
.unwrap_or_default()
}
pub(crate) fn refresh(&mut self, paths: &RunPaths, state: &RunState, watching: &[Node]) {
self.take_up_answers();
let now = crate::sys::now_millis();
let re_read = self.landings_are_due();
let mut waits: Vec<(Key, Dependency)> = Vec::new();
for node in watching {
for dependency in self.resolve(paths, state, node, re_read) {
let key = (node.id.clone(), dependency.dep.clone());
if self.answers.get(&key).and_then(Answer::version).is_some() {
self.since.remove(&key);
continue;
}
self.since.entry(key.clone()).or_insert(now);
waits.push((key, dependency));
}
}
self.unresolved
.retain(|node, _| watching.iter().any(|watched| watched.id == *node));
self.asker.ask(questions_of(&waits));
}
fn all_released(&self, node: &str) -> bool {
let dependencies = self.dependencies.get(node).filter(|of| !of.is_empty());
dependencies.is_some_and(|dependencies| {
dependencies.iter().all(|dependency| {
self.answers
.get(&(node.to_owned(), dependency.dep.clone()))
.and_then(Answer::version)
.is_some()
})
})
}
pub(crate) fn held(&self, watching: &[Node]) -> BTreeSet<String> {
watching
.iter()
.filter(|node| adoption_of(node) == Adoption::Published)
.filter(|node| {
self.unresolved.contains_key(&node.id) || self.awaits_a_release(&node.id)
})
.map(|node| node.id.clone())
.collect()
}
fn awaits_a_release(&self, node: &str) -> bool {
self.dependencies
.get(node)
.is_some_and(|dependencies| !dependencies.is_empty())
&& !self.all_released(node)
}
pub(crate) fn report(
&mut self,
paths: &RunPaths,
journal: &mut Journal,
held: &BTreeSet<String>,
watching: &[Node],
) -> Result<()> {
for node in watching {
let dependencies = self.dependencies.get(&node.id).cloned().unwrap_or_default();
for dependency in &dependencies {
let key = (node.id.clone(), dependency.dep.clone());
let Some(version) = self.answers.get(&key).and_then(Answer::version) else {
continue;
};
if !self.arrived.insert(key) {
continue;
}
journal.emit(
journal::PipelineKind::ReleaseArrived,
journal::labels(&paths.run, Some(&node.id)),
journal::payload(&[
("node", json!(node.id)),
("dep", json!(dependency.dep)),
("identity", json!(dependency.identity)),
(
"target",
json!(dependency.target.as_ref().map(ToString::to_string)),
),
("style", json!(dependency.style.map(|style| style.as_str()))),
("version", json!(version)),
]),
)?;
}
}
for node in held {
let due = self
.surfaced
.get(node)
.is_none_or(|last| last.elapsed() >= self.surface_every);
if !due {
continue;
}
self.surfaced.insert(node.clone(), Instant::now());
crate::engine::raise(paths, journal, self.wait_surface(node))?;
let awaiting = self.awaiting(node);
journal.emit(
journal::PipelineKind::ReleaseWait,
journal::labels(&paths.run, Some(node)),
journal::payload(&[("node", json!(node)), ("awaiting", json!(awaiting))]),
)?;
}
self.surfaced.retain(|node, _| held.contains(node));
Ok(())
}
pub(crate) fn relay_releases(
&mut self,
paths: &RunPaths,
journal: &mut Journal,
state: &RunState,
statuses: &BTreeMap<String, NodeStatus>,
filter: Option<&crate::filter::EventFilter>,
) -> Result<()> {
if !self
.relayed
.is_none_or(|last| last.elapsed() >= self.relay_every)
{
return Ok(());
}
self.relayed = Some(Instant::now());
let mut relayed = crate::vcs::Watermarks::of_relayed(&journal::read(&paths.journal()));
for (node, session) in &state.sessions {
if statuses.get(node) == Some(&NodeStatus::Running) {
continue;
}
let releases_nothing = state
.graph
.get(node)
.and_then(|node| node.repo.clone())
.and_then(|repo| self.repositories.of(&repo).ok())
.is_none_or(|releases| releases.targets.is_empty());
if releases_nothing {
continue;
}
let known = crate::engine::dispatch_labels(
&paths.run,
node,
None,
state
.graph
.get(node)
.and_then(|node| node.persona.as_deref()),
);
for mut envelope in crate::vcs::events(session.token(), filter) {
if envelope.dimensions.phase != Some(crate::event::Phase::Release) {
continue;
}
if !relayed.beyond(&envelope) {
continue;
}
crate::lifecycle::stamp(&mut envelope.labels, &known);
journal.relay(&envelope)?;
relayed.reached(&envelope);
}
}
Ok(())
}
fn last_answer(&self, key: &Key, dependency: &Dependency) -> &'static str {
match self.answers.get(key) {
Some(answer) => answer.as_str(),
None if dependency.askable() => NO_ANSWER_YET,
None => Answer::NotAnswered.as_str(),
}
}
fn awaiting(&self, node: &str) -> Vec<Value> {
let now = crate::sys::now_millis();
self.dependencies
.get(node)
.map(Vec::as_slice)
.unwrap_or_default()
.iter()
.filter(|dependency| {
self.answers
.get(&(node.to_owned(), dependency.dep.clone()))
.and_then(Answer::version)
.is_none()
})
.map(|dependency| {
let key = (node.to_owned(), dependency.dep.clone());
let since = self.since.get(&key).copied().unwrap_or(now);
let mut entry = journal::payload(&[
("dep", json!(dependency.dep)),
("identity", json!(dependency.identity)),
(
"target",
json!(dependency.target.as_ref().map(ToString::to_string)),
),
("style", json!(dependency.style.map(|style| style.as_str()))),
]);
if dependency.style == Some(ReleaseStyle::HumanStep) {
entry.insert("action".to_owned(), json!(dependency.action));
}
entry.insert(
"since".to_owned(),
json!(crate::sys::rfc3339_from_millis(since)),
);
entry.insert(
"waited_seconds".to_owned(),
json!(now.saturating_sub(since) / 1_000),
);
entry.insert(
"last_answer".to_owned(),
json!(self.last_answer(&key, dependency)),
);
Value::Object(entry)
})
.chain(self.unresolved_of(node).iter().map(|unresolved| {
let entry = journal::payload(&[
("dep", json!(unresolved.dep)),
("identity", Value::Null),
("target", Value::Null),
("style", Value::Null),
(
"since",
json!(crate::sys::rfc3339_from_millis(unresolved.since)),
),
(
"waited_seconds",
json!(now.saturating_sub(unresolved.since) / 1_000),
),
("last_answer", json!(UNRESOLVED)),
("reason", json!(unresolved.reason)),
]);
Value::Object(entry)
}))
.collect()
}
fn wait_surface(&self, node: &str) -> Surface {
let now = crate::sys::now_millis();
let mut lines: Vec<String> = Vec::new();
for dependency in self
.dependencies
.get(node)
.map(Vec::as_slice)
.unwrap_or(&[])
{
let key = (node.to_owned(), dependency.dep.clone());
if self.answers.get(&key).and_then(Answer::version).is_some() {
continue;
}
let waited = crate::telemetry::duration(
now.saturating_sub(self.since.get(&key).copied().unwrap_or(now)),
);
let answered = self.last_answer(&key, dependency);
let style = match (dependency.style, dependency.action.as_deref()) {
(Some(ReleaseStyle::HumanStep), Some(action)) => {
format!("human-step release — a person has to: {action}")
}
(Some(ReleaseStyle::HumanStep), None) => "human-step release".to_owned(),
(Some(ReleaseStyle::Automated), _) => "automated release".to_owned(),
(None, _) => "no release target this host can name".to_owned(),
};
lines.push(format!(
"- {named} — {style}, waited {waited}, last answer: {answered}",
named = dependency.named(),
));
}
for unresolved in self.unresolved_of(node) {
let waited = crate::telemetry::duration(now.saturating_sub(unresolved.since));
lines.push(format!(
"- {dep} — not yet resolved ({reason}), waited {waited}, last answer: {UNRESOLVED}",
dep = unresolved.dep,
reason = unresolved.reason,
));
}
Surface {
id: 0,
kind: WAIT_SURFACE_KIND.to_owned(),
message: format!(
"node '{node}' is held under published adoption, waiting on {count} \
release(s):\n{lines}\nNothing times this out and nothing will fail the node. \
Keep waiting, flip this node to `adoption: fast` by live edit, or stop the run.",
count = lines.len(),
lines = lines.join("\n"),
),
source: crate::channel::source::PROPOSAL.to_owned(),
blocking: false,
queued_at: now,
abandoned: false,
asker: None,
workstream: Some(node.to_owned()),
correlation: None,
}
}
pub(crate) fn ready_to_adopt(&self, told: &[Node]) -> Vec<(String, Vec<Released>)> {
told.iter()
.filter(|node| adoption_of(node) == Adoption::Fast)
.filter(|node| !self.adopted.contains(&node.id))
.filter(|node| self.all_released(&node.id))
.map(|node| (node.id.clone(), self.released(&node.id)))
.collect()
}
fn released(&self, node: &str) -> Vec<Released> {
self.dependencies
.get(node)
.map(Vec::as_slice)
.unwrap_or_default()
.iter()
.filter_map(|dependency| {
Some(Released {
dep: dependency.dep.clone(),
identity: dependency.identity.clone(),
branch: dependency.branch.clone().unwrap_or_default(),
commit: dependency.commit.clone().unwrap_or_default(),
target: dependency
.target
.as_ref()
.map(ToString::to_string)
.unwrap_or_default(),
version: self
.answers
.get(&(node.to_owned(), dependency.dep.clone()))
.and_then(Answer::version)?
.to_owned(),
instructions: dependency.instructions.clone(),
})
})
.collect()
}
pub(crate) fn adopted(&mut self, node: &str) {
self.adopted.insert(node.to_owned());
}
fn landings_are_due(&mut self) -> bool {
if !self
.read_landings
.is_none_or(|last| last.elapsed() >= self.relay_every)
{
return false;
}
self.read_landings = Some(Instant::now());
self.stated.clear();
true
}
fn stated_landing(&mut self, paths: &RunPaths, node: &str) -> Option<String> {
if !self.stated.contains_key(&paths.run) {
self.stated.insert(
paths.run.clone(),
stated_landings(&journal::read(&paths.journal())),
);
}
self.stated.get(&paths.run)?.get(node).cloned()
}
fn resolve(
&mut self,
paths: &RunPaths,
state: &RunState,
node: &Node,
re_read: bool,
) -> Vec<Dependency> {
if let Some(known) = self.dependencies.get(&node.id) {
let known = known.clone();
if !re_read {
return known;
}
let re_read: Vec<Dependency> = known
.into_iter()
.map(|mut dependency| {
dependency.landing = self.landing_of(paths, &dependency.dep);
dependency
})
.collect();
self.dependencies.insert(node.id.clone(), re_read.clone());
return re_read;
}
let mine = identity_of(&mut self.repositories, node);
let now = crate::sys::now_millis();
let mut resolved: Vec<Dependency> = Vec::new();
let mut unresolved: Vec<Unresolved> = Vec::new();
for dep in &node.deps {
match self.dependency(paths, state, node, dep, mine.as_deref()) {
Resolution::Unreadable(reason) => {
let since = self
.unresolved_of(&node.id)
.iter()
.find(|it| it.dep == *dep)
.map_or(now, |it| it.since);
unresolved.push(Unresolved {
dep: dep.clone(),
reason,
since,
});
}
Resolution::NothingToAwait => {}
Resolution::Outside(dependency) => resolved.push(dependency),
}
}
if !unresolved.is_empty() {
self.unresolved.insert(node.id.clone(), unresolved);
return Vec::new();
}
self.unresolved.remove(&node.id);
self.dependencies.insert(node.id.clone(), resolved.clone());
resolved
}
fn dependency(
&mut self,
paths: &RunPaths,
state: &RunState,
node: &Node,
dep: &str,
mine: Option<&str>,
) -> Resolution {
let target = node.consumes.get(dep).cloned();
if let Some(reference) = crate::crossdag::parse(dep) {
let Some(upstream) = upstream_of(paths, &reference) else {
return Resolution::Unreadable(format!(
"the run '{}' it names has no ledger under this run root",
reference.run
));
};
let Some(repo) = upstream
.graph
.get(&reference.node)
.and_then(|node| node.repo.clone())
else {
return Resolution::NothingToAwait;
};
let stated = upstream_paths(paths, &reference)
.and_then(|upstream| self.stated_landing(&upstream, &reference.node));
return self.outside(
dep,
&repo,
upstream.branches.get(&reference.node).cloned(),
upstream.landing_commits.get(&reference.node).cloned(),
stated,
target,
);
}
let repo = match across_repositories(&mut self.repositories, state, dep, mine) {
Ok(repo) => repo,
Err(ended) => return ended.into(),
};
let stated = self.stated_landing(paths, dep);
self.outside(
dep,
&repo,
state.branches.get(dep).cloned(),
state.landing_commits.get(dep).cloned(),
stated,
target,
)
}
fn landing_of(&mut self, paths: &RunPaths, dep: &str) -> Option<String> {
match crate::crossdag::parse(dep) {
Some(reference) => {
let upstream = upstream_paths(paths, &reference)?;
self.stated_landing(&upstream, &reference.node)
}
None => self.stated_landing(paths, dep),
}
}
fn outside(
&mut self,
dep: &str,
repo: &str,
branch: Option<String>,
commit: Option<String>,
landing: Option<String>,
named: Option<TargetName>,
) -> Resolution {
outside(
&mut self.repositories,
dep,
repo,
(branch, commit, landing),
named,
)
}
}
fn identity_of(repositories: &mut Repositories, node: &Node) -> Option<String> {
node.repo
.as_deref()
.and_then(|repo| repositories.of(repo).ok())
.map(|releases| releases.identity.clone())
}
fn across_repositories(
repositories: &mut Repositories,
state: &RunState,
dep: &str,
mine: Option<&str>,
) -> std::result::Result<String, Ended> {
let Some(upstream) = state.graph.get(dep) else {
return Err(Ended::Unreadable(format!(
"the graph has no node '{dep}' to say where its work landed"
)));
};
let Some(repo) = upstream.repo.clone() else {
return Err(Ended::NothingToAwait);
};
let identity = match repositories.of(&repo) {
Ok(releases) => releases.identity.clone(),
Err(why) => return Err(Ended::Unreadable(unread(&repo, &why))),
};
if Some(identity.as_str()) == mine {
return Err(Ended::NothingToAwait);
}
Ok(repo)
}
enum Ended {
NothingToAwait,
Unreadable(String),
}
impl From<Ended> for Resolution {
fn from(ended: Ended) -> Self {
match ended {
Ended::NothingToAwait => Resolution::NothingToAwait,
Ended::Unreadable(why) => Resolution::Unreadable(why),
}
}
}
fn unread(repo: &str, why: &str) -> String {
format!("what the repository {repo} releases could not be read: {why}")
}
fn outside(
repositories: &mut Repositories,
dep: &str,
repo: &str,
(branch, commit, landing): (Option<String>, Option<String>, Option<String>),
named: Option<TargetName>,
) -> Resolution {
let releases = match repositories.of(repo) {
Ok(releases) => releases,
Err(why) => return Resolution::Unreadable(unread(repo, &why)),
};
if releases.targets.is_empty() {
return Resolution::NothingToAwait;
}
let identity = releases.identity.clone();
let selected = releases.select(named.as_ref()).ok();
let (target, style, action, instructions) = match selected {
Some(target) => (
Some(target.name.clone()),
Some(target.style()),
target.action().map(str::to_owned),
target.adoption_instructions.clone(),
),
None => (named, None, None, None),
};
Resolution::Outside(Dependency {
dep: dep.to_owned(),
identity,
branch,
commit,
landing,
target,
style,
action,
instructions,
})
}
enum Resolution {
NothingToAwait,
Outside(Dependency),
Unreadable(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Released {
pub dep: String,
pub identity: String,
pub branch: String,
pub commit: String,
pub target: String,
pub version: String,
pub instructions: Option<InstructionTemplate>,
}
impl Released {
pub(crate) fn payload(&self) -> Value {
let mut entry = json!({
"identity": self.identity,
"target": self.target,
"version": self.version,
});
let mut put = |key: &str, value: &str| {
if !value.is_empty() {
entry[key] = json!(value);
}
};
put("dep", &self.dep);
put("branch", &self.branch);
put("commit", &self.commit);
if let Some(instructions) = &self.instructions {
entry["instructions"] = json!(instructions);
}
entry
}
pub(crate) fn row(&self) -> CrossRepoReference {
CrossRepoReference {
dependency: self.dep.clone(),
repository: self.identity.clone(),
branch: self.branch.clone(),
commit: self.commit.clone(),
release_target: self.target.clone(),
version: self.version.clone(),
adoption_instructions: self.instructions.clone(),
}
}
pub(crate) fn of_payload(payload: &Value) -> Vec<Self> {
payload
.as_array()
.map(Vec::as_slice)
.unwrap_or_default()
.iter()
.filter_map(|entry| {
let field = |key: &str| renderable(entry.get(key)?.as_str()?);
Some(Self {
identity: field("identity")?,
target: entry
.get("target")?
.as_str()?
.parse::<TargetName>()
.ok()?
.to_string(),
version: field("version")?,
dep: field("dep").unwrap_or_default(),
branch: field("branch").unwrap_or_default(),
commit: field("commit").unwrap_or_default(),
instructions: entry
.get("instructions")
.and_then(Value::as_str)
.and_then(|declared| declared.parse::<InstructionTemplate>().ok()),
})
})
.collect()
}
}
fn renderable(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(crate) fn acknowledge_stated_releases(commands: &[crate::channel::Command]) -> Result<()> {
for command in commands {
let crate::channel::Command::Settle {
id,
landing: Some(landing),
release: Some(release),
..
} = command
else {
continue;
};
onevcs::acknowledge_release(landing, &release.target, &release.version, false).map_err(
|refused| {
crate::Error::Refused(format!(
"settle: node '{id}' states that {target} {version} carries the landing \
{landing}, and `onevcs` would not record it: {refused}; nothing was queued",
target = release.target,
version = release.version,
))
},
)?;
}
Ok(())
}
const NO_BASELINE: &str = "no baseline was captured";
pub(crate) fn hold_warnings_for_stated_landings(
state: &RunState,
commands: &[crate::channel::Command],
) -> Vec<String> {
let mut said = Vec::new();
for command in commands {
let crate::channel::Command::Settle {
id,
landing: Some(landing),
release: None,
..
} = command
else {
continue;
};
let Some(repo) = state.graph.get(id).and_then(|node| node.repo.clone()) else {
continue;
};
let mut repositories = Repositories::default();
let Some(awaited) = awaited_targets(&mut repositories, state, id, &repo) else {
said.push(format!(
"onepipeline: settle: node '{id}' was settled at {landing}, and what its \
repository {repo} releases could not be read, so whether that landing has a \
release baseline was not asked; `onevcs release targets {repo}` says why"
));
continue;
};
let identity = repositories
.of(&repo)
.map(|releases| releases.identity.clone())
.unwrap_or_else(|_| repo.clone());
for target in awaited {
let named = target.to_string();
let reference = shell_word(landing);
match onevcs::release_status(landing, Some(&target)) {
Ok(ReleaseStatus::NotAnswered { reason }) if reason.contains(NO_BASELINE) => {
said.push(format!(
"onepipeline: settle: node '{id}' was settled at the landing {landing}, \
and that landing has no release baseline for the release target \
'{named}' of {identity}: nothing recorded what that target had \
published when the work landed, so no probe answer can show a release \
carries it, and a node waiting on that release holds until one is \
recorded. Once you have verified the version that first carries it, \
record it:\n onevcs release acknowledge {reference} --target {named} \
--version <VERSION>",
));
}
Err(refused) => said.push(format!(
"onepipeline: settle: node '{id}' was settled at {landing}, which `onevcs` \
cannot resolve to landed work, so no release of the release target \
'{named}' of {identity} will be attributed through it and a node waiting \
on that release holds: {refused}. State a landing `onevcs` can resolve \
by settling the node again at the change request that carried the work, \
with the `release` that carries it once you have verified it; or settle it \
there without one and record the release:\n onevcs release acknowledge \
'<CHANGE-REQUEST-URL>' --target {named} --version <VERSION>",
)),
Ok(_) => {}
}
}
}
said
}
fn awaited_targets(
repositories: &mut Repositories,
state: &RunState,
node: &str,
repo: &str,
) -> Option<BTreeSet<TargetName>> {
let mut resolutions: Vec<Resolution> = Vec::new();
for dependent in state
.graph
.iter()
.filter(|dependent| dependent.deps.iter().any(|dep| dep == node))
{
let mine = identity_of(repositories, dependent);
resolutions.push(
match across_repositories(repositories, state, node, mine.as_deref()) {
Ok(repo) => outside(
repositories,
node,
&repo,
(None, None, None),
dependent.consumes.get(node).cloned(),
),
Err(ended) => ended.into(),
},
);
}
let mut awaited = asked_about(resolutions)?;
if awaited.is_empty() {
awaited = asked_about(vec![outside(
repositories,
node,
repo,
(None, None, None),
None,
)])?;
}
Some(awaited)
}
fn asked_about(resolutions: Vec<Resolution>) -> Option<BTreeSet<TargetName>> {
let mut asked = BTreeSet::new();
for resolution in resolutions {
match resolution {
Resolution::Unreadable(_) => return None,
Resolution::NothingToAwait => {}
Resolution::Outside(dependency) => {
asked.extend(dependency.style.and(dependency.target));
}
}
}
Some(asked)
}
fn shell_word(word: &str) -> String {
format!("'{}'", word.replace('\'', "'\\''"))
}
pub(crate) fn draft_reason(references: &[CrossRepoReference]) -> Option<onevcs::DraftReason> {
let unreleased: Vec<(&CrossRepoReference, &str, TargetName)> = references
.iter()
.filter(|row| row.version.is_empty())
.filter_map(|row| {
let (reference, target) = askable(row)?;
Some((row, reference, target))
}) .filter(|(_, reference, target)| !released_already(reference, target))
.collect();
let (row, reference, target) = unreleased.first()?;
Some(onevcs::DraftReason::AwaitingRelease {
because: format!(
"this node adopted {identity} early and is pinned to {reference} rather than to a \
released version; it is one of {count} release(s) this node adopted early, and \
landing now would make that pin permanent",
identity = row.repository,
count = unreleased.len(),
),
awaiting: row.repository.clone(),
target: target.clone(),
reference: (*reference).to_owned(),
})
}
pub(crate) fn drafted_detail(reason: &onevcs::DraftReason) -> String {
match reason {
onevcs::DraftReason::AwaitingRelease {
target,
awaiting,
reference,
..
} => format!(
"complete, and held as a draft: awaiting the {target} release of {awaiting}, \
pinned to {reference} until it arrives"
),
onevcs::DraftReason::Held { .. } => HELD_DETAIL.to_owned(),
}
}
pub(crate) const HELD_DETAIL: &str =
"complete, and left as a draft as the plan asked, for a person to mark ready for review";
pub(crate) fn held_reason(node: &str) -> onevcs::DraftReason {
onevcs::DraftReason::Held {
because: format!(
"node '{node}' is declared `draft: true`, so its change request is left as a draft \
for a person to mark ready for review"
),
}
}
fn askable(row: &CrossRepoReference) -> Option<(&str, TargetName)> {
let reference = [row.branch.as_str(), row.commit.as_str()]
.into_iter()
.find(|value| renderable(value).is_some())?;
Some((reference, row.release_target.parse::<TargetName>().ok()?))
}
fn released_already(reference: &str, target: &TargetName) -> bool {
Answer::of(&onevcs::release_status(reference, Some(target)))
.version()
.is_some()
}
pub(crate) fn arrival_note(released: &[Released]) -> String {
crate::plan::arrival_note(&released.iter().map(Released::row).collect::<Vec<_>>())
}
fn questions_of(waits: &[(Key, Dependency)]) -> Vec<Question> {
let mut questions: Vec<Question> = Vec::new();
let mut asked: BTreeMap<(&str, Option<&TargetName>, &'static str), usize> = BTreeMap::new();
for (key, dependency) in waits {
let Some(reference) = dependency.reference() else {
continue;
};
let Some(style) = dependency.style else {
continue;
};
let about = (reference, dependency.target.as_ref(), style.as_str());
match asked.get(&about) {
Some(&already) => questions[already].keys.push(key.clone()),
None => {
asked.insert(about, questions.len());
questions.push(Question {
keys: vec![key.clone()],
reference: reference.to_owned(),
target: dependency.target.clone(),
style,
});
}
}
}
questions
}
pub(crate) fn watching(
state: &RunState,
statuses: &BTreeMap<String, NodeStatus>,
running: &BTreeSet<String>,
) -> Vec<Node> {
state
.graph
.iter()
.filter(|node| {
matches!(
statuses.get(&node.id),
Some(&NodeStatus::Ready | &NodeStatus::CompleteDraft)
) || running.contains(&node.id)
})
.cloned()
.collect()
}
fn upstream_of(paths: &RunPaths, reference: &crate::crossdag::Reference) -> Option<RunState> {
let upstream = upstream_paths(paths, reference)?;
Some(crate::projection::fold(&journal::read(&upstream.journal())))
}
fn upstream_paths(paths: &RunPaths, reference: &crate::crossdag::Reference) -> Option<RunPaths> {
let upstream = RunPaths::under(paths.dir.parent()?, &reference.run);
upstream.exists().then_some(upstream)
}
fn stated_landings(events: &[crate::event::Envelope]) -> BTreeMap<String, String> {
crate::projection::fold(events)
.stated_landings
.into_iter()
.filter_map(|(node, stated)| Some((renderable(&node)?, String::from(stated))))
.collect()
}
fn poll_seconds() -> u64 {
std::env::var(POLL_ENV)
.ok()
.and_then(|value| value.parse().ok())
.filter(|seconds| *seconds > 0)
.unwrap_or(DEFAULT_POLL_SECONDS)
}
const MAX_WITHDRAWN_ASK_SECONDS: u64 = 300;
fn withdrawn_ask() -> Duration {
Duration::from_secs(
std::env::var(WITHDRAWN_ASK_ENV)
.ok()
.and_then(|value| value.parse().ok())
.filter(|seconds| *seconds <= MAX_WITHDRAWN_ASK_SECONDS)
.unwrap_or(0),
)
}
fn retire(
current: &[Question],
fresh: Vec<Question>,
linger: Duration,
withdrawn: &mut Vec<(Question, Instant)>,
) -> Vec<Question> {
if !linger.is_zero() {
let now = Instant::now();
let names = |question: &Question, other: &Question| {
question.reference == other.reference && question.target == other.target
};
for question in current {
if !fresh.iter().any(|other| names(question, other))
&& !withdrawn.iter().any(|(held, _)| names(question, held))
{
withdrawn.push((question.clone(), now));
}
}
}
fresh
}
fn surface_every_seconds() -> u64 {
std::env::var(SURFACE_ENV)
.ok()
.and_then(|value| value.parse().ok())
.filter(|seconds| *seconds > 0)
.unwrap_or(DEFAULT_SURFACE_SECONDS)
}
#[cfg(test)]
mod tests {
use super::*;
use onevcs::Baseline;
fn dependency(target: Option<&str>, style: Option<ReleaseStyle>) -> Dependency {
Dependency {
dep: "engine".to_owned(),
identity: "github.com/owner/engine".to_owned(),
branch: Some("onevcs/s-1".to_owned()),
commit: Some("9f3c1ab".to_owned()),
landing: None,
target: target.map(|name| name.parse().expect("a target name")),
style,
action: style
.filter(|style| *style == ReleaseStyle::HumanStep)
.map(|_| "cut a release on PyPI".to_owned()),
instructions: None,
}
}
#[test]
fn no_answer_the_sibling_gives_is_folded_into_another() {
let cases: Vec<(onevcs::Result<ReleaseStatus>, &str)> = vec![
(
Ok(ReleaseStatus::Released {
target: "crate".parse().expect("a target name"),
style: ReleaseStyle::Automated,
version: "0.2.0".to_owned(),
source: onevcs::ReleaseSource::Probed,
}),
"released",
),
(
Ok(ReleaseStatus::Released {
target: "crate".parse().expect("a target name"),
style: ReleaseStyle::Automated,
version: "0.2.0".to_owned(),
source: onevcs::ReleaseSource::Acknowledged,
}),
"released",
),
(
Ok(ReleaseStatus::NotReleased {
at_landing: Baseline::At {
version: "0.1.0".to_owned(),
},
now: "0.1.0".to_owned(),
}),
"not-released",
),
(
Ok(ReleaseStatus::AwaitingHumanStep {
target: "wheel".parse().expect("a target name"),
action: "cut a release on PyPI".to_owned(),
since: "2026-08-24T00:00:00.000Z".to_owned(),
}),
"awaiting-human-step",
),
(
Ok(ReleaseStatus::NotAnswered {
reason: "the probe timed out".to_owned(),
}),
"not-answered",
),
(Ok(ReleaseStatus::NotLanded), "not-landed"),
(
Err(onevcs::Error::Invalid {
reason: "the repository declares no release targets".to_owned(),
}),
"not-answered",
),
];
for (status, expected) in cases {
assert_eq!(
Answer::of(&status).as_str(),
expected,
"{status:?} was read as another answer"
);
}
assert_eq!(
Answer::Released {
version: "0.2.0".to_owned()
}
.version(),
Some("0.2.0")
);
for answer in [
Answer::NotReleased,
Answer::AwaitingHumanStep,
Answer::NotAnswered,
Answer::NotLanded,
] {
assert_eq!(answer.version(), None, "{answer:?} released a hold");
}
}
#[test]
fn the_node_rung_wins_outright_and_a_node_with_no_repository_falls_to_the_floor() {
let stated = Node {
id: "stated".to_owned(),
adoption: Some(Adoption::Published),
..Node::default()
};
assert_eq!(adoption_of(&stated), Adoption::Published);
assert_eq!(adoption_of(&Node::default()), Adoption::Fast);
let unknown = Node {
id: "unknown".to_owned(),
repo: Some("no-such-repository-on-this-host".to_owned()),
..Node::default()
};
assert_eq!(adoption_of(&unknown), Adoption::Fast);
}
#[test]
fn a_dependency_the_run_cannot_fully_name_is_rendered_with_the_cell_empty() {
let named = dependency(Some("crate"), Some(ReleaseStyle::Automated)).row(None);
assert_eq!(named.repository, "github.com/owner/engine");
assert_eq!(named.branch, "onevcs/s-1");
assert_eq!(named.commit, "9f3c1ab");
assert_eq!(named.release_target, "crate");
let mut unnamed = dependency(None, None);
unnamed.branch = None;
unnamed.commit = None;
let row = unnamed.row(None);
assert_eq!(row.dependency, "engine");
assert_eq!(row.repository, "github.com/owner/engine");
assert!(row.branch.is_empty() && row.commit.is_empty() && row.release_target.is_empty());
}
#[test]
fn the_reference_the_sibling_is_asked_about_is_the_branch() {
assert_eq!(
dependency(Some("crate"), None).reference(),
Some("onevcs/s-1")
);
let mut branchless = dependency(Some("crate"), None);
branchless.branch = None;
assert_eq!(branchless.reference(), Some("9f3c1ab"));
branchless.commit = None;
assert_eq!(branchless.reference(), None);
}
#[test]
fn a_landing_an_operator_stated_is_what_the_sibling_is_asked_about() {
for landing in [
"https://github.com/owner/engine/pull/12",
"3f9a1c2e5b7d9081f2a3b4c5d6e7f8091a2b3c4d",
] {
let mut stated = dependency(Some("crate"), Some(ReleaseStyle::Automated));
stated.landing = Some(landing.to_owned());
assert_eq!(
stated.reference(),
Some(landing),
"the branch this settle corrects is still what the release is measured against"
);
stated.branch = None;
stated.commit = None;
assert!(stated.askable(), "a stated landing is no question at all");
assert_eq!(stated.reference(), Some(landing));
}
}
#[test]
fn a_stated_landing_is_read_off_the_journal_and_the_last_one_wins() {
let edit = |operations: Value| {
serde_json::from_value::<crate::event::Envelope>(json!({
"v": 1,
"ts": "2026-09-07T00:00:00.000Z",
"stream": "pipeline",
"seq": 0,
"source": "pipeline",
"kind": journal::PipelineKind::EditCommitted.as_str(),
"labels": {"run_id": "settled"},
"payload": {"operations": operations},
}))
.expect("an envelope")
};
let stated = |node: &str, landing: &str| json!([{"kind": "landing-from-evidence", "node": node, "landing": landing}]);
assert_eq!(
stated_landings(&[
edit(stated("publish", "3f9a1c2ab")),
edit(stated("other", "https://example.invalid/pull/1")),
edit(stated("publish", "9d8c7b6ef")),
]),
[
(
"other".to_owned(),
"https://example.invalid/pull/1".to_owned()
),
("publish".to_owned(), "9d8c7b6ef".to_owned()),
]
.into_iter()
.collect::<BTreeMap<String, String>>()
);
for unreadable in [
json!("not a list of operations"),
json!([{"kind": "settled-from-evidence", "node": "publish", "outcome": "done",
"evidence": "it merged"}]),
stated("publish", ""),
stated("publish", "3f9a1c2 and the one before it"),
stated("", "3f9a1c2"),
stated("publish", "the-change-that-merged"),
stated("publish", "3f9a1c"),
] {
assert!(
stated_landings(&[edit(unreadable.clone())]).is_empty(),
"{unreadable} was read as a landing"
);
}
assert!(stated_landings(&[]).is_empty());
}
fn arrival(instructions: Option<&str>) -> Released {
Released {
dep: "engine".to_owned(),
identity: "github.com/nickderobertis/onevcs".to_owned(),
branch: "onevcs/s-1".to_owned(),
commit: "9f3c1ab".to_owned(),
target: "crate".to_owned(),
version: "0.13.0".to_owned(),
instructions: instructions.map(|declared| {
declared
.parse()
.expect("a template the producer could declare")
}),
}
}
#[test]
fn the_arrival_note_names_the_versions_and_states_no_criterion() {
let released = vec![arrival(None)];
let note = arrival_note(&released);
assert_eq!(
note,
"The releases this node was waiting on have arrived:\n\n\
- github.com/nickderobertis/onevcs — crate 0.13.0\n\n\
This reports observed state and adds no acceptance criteria. What the producer of \
each dependency above states about adopting it:\n\n\
Move from the git pin to that released version.\n\n\
That is the end of what the producers state; none of it is a criterion of this node."
);
assert!(!note.to_lowercase().contains("must"));
let payload = json!(released.iter().map(Released::payload).collect::<Vec<_>>());
assert_eq!(
arrival_note(&Released::of_payload(&payload)),
note,
"a note replayed from the record is not the note that was sent"
);
let declared = vec![arrival(Some(
"Raise the `onevcs` pin to {{ version }}; the branch pin at {{ branch }} goes.",
))];
let stated = "Raise the `onevcs` pin to 0.13.0; the branch pin at onevcs/s-1 goes.";
let declared_note = arrival_note(&declared);
assert!(
declared_note.contains(stated),
"the producer's own instruction did not reach the note:\n{declared_note}"
);
assert!(
!declared_note.contains(crate::plan::DEFAULT_ADOPTION_INSTRUCTION),
"a producer that declared one still got the engine's default:\n{declared_note}"
);
let record = json!(declared.iter().map(Released::payload).collect::<Vec<_>>());
assert_eq!(
arrival_note(&Released::of_payload(&record)),
declared_note,
"a note replayed from the record lost the producer's own instruction"
);
assert_eq!(record[0]["dep"], json!("engine"));
assert_eq!(record[0]["branch"], json!("onevcs/s-1"));
assert_eq!(record[0]["commit"], json!("9f3c1ab"));
let mut bare = arrival(None);
bare.dep = String::new();
bare.branch = String::new();
bare.commit = String::new();
assert_eq!(
bare.payload(),
json!({
"identity": "github.com/nickderobertis/onevcs",
"target": "crate",
"version": "0.13.0",
}),
"a record gained a key about something the run could not name"
);
for refused in [json!(""), json!("{{ unclosed"), json!(42)] {
let entry = json!([{
"identity": "a", "target": "crate", "version": "0.13.0",
"instructions": refused,
}]);
assert_eq!(
Released::of_payload(&entry)
.first()
.expect("the release itself still reads")
.instructions,
None,
"{refused} was read as a template"
);
}
for unreadable in [
json!([{"identity": "a", "target": "crate"}]),
json!([{"identity": "a", "target": "crate", "version": 13}]),
json!([{"identity": "a", "target": "crate", "version": ""}]),
json!([{"target": "crate", "version": "0.13.0"}]),
json!("not a list at all"),
json!([{"identity": "a\n- b — c 9.9.9", "target": "crate", "version": "0.13.0"}]),
json!([{"identity": "a", "target": "crate", "version": "0.13.0\u{7}"}]),
json!([{"identity": "a", "target": "crate", "version": "0.13.0 and more"}]),
json!([{"identity": "a", "target": "not a target name", "version": "0.13.0"}]),
json!([{"identity": "a", "target": "-leading", "version": "0.13.0"}]),
] {
assert!(
Released::of_payload(&unreadable).is_empty(),
"{unreadable} was read as a release"
);
}
}
#[test]
fn waits_naming_one_release_put_one_question_between_them() {
let wait = |node: &str, dependency: Dependency| {
((node.to_owned(), dependency.dep.clone()), dependency)
};
let automated = || dependency(Some("crate"), Some(ReleaseStyle::Automated));
let questions = questions_of(&[
wait("first", automated()),
wait("second", automated()),
wait("third", automated()),
]);
assert_eq!(questions.len(), 1, "{questions:?}");
assert_eq!(
questions[0].keys,
vec![
("first".to_owned(), "engine".to_owned()),
("second".to_owned(), "engine".to_owned()),
("third".to_owned(), "engine".to_owned()),
],
);
assert_eq!(questions[0].reference, "onevcs/s-1");
assert_eq!(questions[0].style, ReleaseStyle::Automated);
let mut wheel = automated();
wheel.target = Some("wheel".parse().expect("a target name"));
wheel.style = Some(ReleaseStyle::HumanStep);
let questions = questions_of(&[wait("first", automated()), wait("second", wheel.clone())]);
assert_eq!(questions.len(), 2, "{questions:?}");
assert_eq!(questions[1].style, ReleaseStyle::HumanStep);
let mut other_branch = automated();
other_branch.branch = Some("onevcs/s-2".to_owned());
let questions = questions_of(&[
wait("first", automated()),
wait("second", other_branch.clone()),
]);
assert_eq!(questions.len(), 2, "{questions:?}");
let mut styleless = automated();
styleless.style = None;
let mut referenceless = automated();
referenceless.branch = None;
referenceless.commit = None;
let questions = questions_of(&[
wait("unanswerable", styleless),
wait("unnameable", referenceless),
wait("asked", automated()),
]);
assert_eq!(questions.len(), 1, "{questions:?}");
assert_eq!(
questions[0].keys,
vec![("asked".to_owned(), "engine".to_owned())]
);
assert!(questions_of(&[]).is_empty());
}
#[test]
fn a_wait_still_expecting_its_first_answer_is_not_a_probe_that_could_not_answer() {
let mut watch = Watch::of_run(&RunPaths::under(std::path::Path::new("/nowhere"), "demo"));
watch.dependencies.insert(
"asked".to_owned(),
vec![dependency(Some("crate"), Some(ReleaseStyle::Automated))],
);
watch
.dependencies
.insert("unanswerable".to_owned(), vec![dependency(None, None)]);
assert_eq!(
watch.awaiting("asked")[0]["last_answer"],
json!("no-answer-yet"),
"a probe that has not come back yet was reported as one that failed"
);
assert!(watch
.wait_surface("asked")
.message
.contains("last answer: no-answer-yet"));
assert_eq!(
watch.awaiting("unanswerable")[0]["last_answer"],
json!("not-answered"),
"a question that could not be put was reported as one still in flight"
);
watch.answers.insert(
("asked".to_owned(), "engine".to_owned()),
Answer::NotAnswered,
);
assert_eq!(
watch.awaiting("asked")[0]["last_answer"],
json!("not-answered")
);
watch.answers.insert(
("asked".to_owned(), "engine".to_owned()),
Answer::NotReleased,
);
assert_eq!(
watch.awaiting("asked")[0]["last_answer"],
json!("not-released")
);
}
#[test]
fn a_wait_on_a_machine_and_a_wait_on_a_person_read_differently() {
let mut watch = Watch::of_run(&RunPaths::under(std::path::Path::new("/nowhere"), "demo"));
watch.dependencies.insert(
"auto".to_owned(),
vec![dependency(Some("crate"), Some(ReleaseStyle::Automated))],
);
watch.dependencies.insert(
"person".to_owned(),
vec![dependency(Some("wheel"), Some(ReleaseStyle::HumanStep))],
);
watch.answers.insert(
("auto".to_owned(), "engine".to_owned()),
Answer::NotReleased,
);
watch.answers.insert(
("person".to_owned(), "engine".to_owned()),
Answer::AwaitingHumanStep,
);
let automated = watch.wait_surface("auto").message;
assert!(
automated.contains("automated release") && !automated.contains("human-step"),
"{automated}"
);
assert!(
automated.contains("last answer: not-released"),
"{automated}"
);
let person = watch.wait_surface("person").message;
assert!(
person.contains("human-step release — a person has to: cut a release on PyPI"),
"{person}"
);
assert!(
person.contains("last answer: awaiting-human-step"),
"a wait on a person read as a probe that failed: {person}"
);
for surface in [watch.wait_surface("auto"), watch.wait_surface("person")] {
assert!(!surface.blocking, "a release wait held a subtree twice");
assert_eq!(surface.kind, WAIT_SURFACE_KIND);
}
let entries = watch.awaiting("person");
assert_eq!(entries[0]["style"], json!("human-step"));
assert_eq!(entries[0]["last_answer"], json!("awaiting-human-step"));
assert_eq!(entries[0]["action"], json!("cut a release on PyPI"));
assert!(watch.awaiting("auto")[0].get("action").is_none());
}
#[test]
fn a_release_that_arrived_is_never_awaited_again() {
let published = Node {
id: "held".to_owned(),
adoption: Some(Adoption::Published),
..Node::default()
};
let paths = RunPaths::under(std::path::Path::new("/nowhere"), "demo");
let mut watch = Watch::of_run(&paths);
let key = ("held".to_owned(), "engine".to_owned());
watch.dependencies.insert(
"held".to_owned(),
vec![dependency(Some("crate"), Some(ReleaseStyle::Automated))],
);
let watching = vec![published];
let long_ago = crate::sys::now_millis().saturating_sub(4_394_000);
watch.since.insert(key.clone(), long_ago);
assert!(watch.held(&watching).contains("held"));
watch.take_up(
std::slice::from_ref(&key),
&Answer::Released {
version: "0.2.0".to_owned(),
},
);
watch.refresh(&paths, &RunState::default(), &watching);
assert!(watch.held(&watching).is_empty(), "a released hold held");
assert!(
watch.awaiting("held").is_empty(),
"a released dependency is still on the awaited list"
);
assert!(
!watch.since.contains_key(&key),
"the clock of a wait that ended is still running"
);
for gone in [Answer::NotAnswered, Answer::NotReleased, Answer::NotLanded] {
watch.take_up(std::slice::from_ref(&key), &gone);
assert_eq!(
watch.answers.get(&key).and_then(Answer::version),
Some("0.2.0"),
"{gone:?} un-released a release that had happened"
);
}
watch.refresh(&paths, &RunState::default(), &watching);
assert!(
watch.held(&watching).is_empty() && watch.awaiting("held").is_empty(),
"a satisfied hold was resurrected by a probe that stopped answering"
);
assert!(
!watch.since.contains_key(&key),
"the resurrected wait would have counted from the hold that ended"
);
watch.answers.remove(&key);
watch.refresh(&paths, &RunState::default(), &watching);
let waited = watch.awaiting("held")[0]["waited_seconds"]
.as_u64()
.expect("a wait says how long it has been");
assert!(
waited < 60,
"a second hold on the same node counted {waited}s from a hold that had ended"
);
}
#[test]
fn nothing_but_released_releases_a_hold() {
let published = Node {
id: "held".to_owned(),
adoption: Some(Adoption::Published),
..Node::default()
};
let mut watch = Watch::of_run(&RunPaths::under(std::path::Path::new("/nowhere"), "demo"));
watch.dependencies.insert(
"held".to_owned(),
vec![dependency(Some("crate"), Some(ReleaseStyle::Automated))],
);
let watching = vec![published.clone()];
assert!(watch.held(&watching).contains("held"));
for answer in [
Answer::NotReleased,
Answer::AwaitingHumanStep,
Answer::NotAnswered,
Answer::NotLanded,
] {
watch
.answers
.insert(("held".to_owned(), "engine".to_owned()), answer.clone());
assert!(
watch.held(&watching).contains("held"),
"{answer:?} released the hold"
);
}
watch.answers.insert(
("held".to_owned(), "engine".to_owned()),
Answer::Released {
version: "0.2.0".to_owned(),
},
);
assert!(watch.held(&watching).is_empty());
watch.dependencies.insert("held".to_owned(), Vec::new());
watch.answers.clear();
assert!(watch.held(&watching).is_empty());
let fast = Node {
adoption: Some(Adoption::Fast),
..published
};
watch.dependencies.insert(
"held".to_owned(),
vec![dependency(Some("crate"), Some(ReleaseStyle::Automated))],
);
assert!(watch.held(&[fast]).is_empty());
}
#[test]
fn a_dependency_the_run_cannot_describe_holds_the_node_and_is_named() {
let published = Node {
id: "held".to_owned(),
adoption: Some(Adoption::Published),
..Node::default()
};
let mut watch = Watch::of_run(&RunPaths::under(std::path::Path::new("/nowhere"), "demo"));
let since = crate::sys::now_millis().saturating_sub(5_000);
watch.unresolved.insert(
"held".to_owned(),
vec![Unresolved {
dep: "engine".to_owned(),
reason: "what the repository engine releases could not be read: malformed"
.to_owned(),
since,
}],
);
let watching = vec![published.clone()];
assert!(watch.held(&watching).contains("held"));
assert!(watch.names_a_release_dependency());
assert_eq!(watch.awaited_deps("held"), vec!["engine".to_owned()]);
let awaiting = watch.awaiting("held");
assert_eq!(awaiting.len(), 1, "{awaiting:?}");
assert_eq!(awaiting[0]["dep"], json!("engine"));
assert_eq!(awaiting[0]["last_answer"], json!(UNRESOLVED));
assert_eq!(
awaiting[0]["reason"],
json!("what the repository engine releases could not be read: malformed")
);
assert!(awaiting[0]["identity"].is_null() && awaiting[0]["target"].is_null());
assert!(
awaiting[0]["waited_seconds"]
.as_u64()
.is_some_and(|waited| waited >= 5),
"the clock does not run from the first pass that found it unreadable: {awaiting:?}"
);
let surface = watch.wait_surface("held").message;
assert!(
surface.contains(
"- engine — not yet resolved (what the repository engine releases \
could not be read: malformed), waited"
) && surface.contains("last answer: unresolved"),
"{surface}"
);
let fast = Node {
adoption: Some(Adoption::Fast),
..published.clone()
};
assert!(watch.held(&[fast]).is_empty());
watch.unresolved.remove("held");
assert!(watch.held(&watching).is_empty());
assert!(watch.awaited_deps("held").is_empty());
}
#[test]
fn a_fresh_driver_takes_up_what_its_predecessor_already_said() {
let root = std::env::temp_dir().join(format!("op-release-seed-{}", std::process::id()));
let paths = RunPaths::under(&root, "restarted");
std::fs::create_dir_all(&paths.dir).expect("a scratch run directory");
let record = |kind: journal::PipelineKind, node: &str, payload: Value| {
serde_json::json!({
"v": 1,
"ts": "2026-08-24T00:00:00.000Z",
"stream": "predecessor",
"seq": 0,
"source": "pipeline",
"kind": kind.as_str(),
"labels": {"run_id": "restarted", "node": node},
"payload": payload,
})
.to_string()
};
std::fs::write(
paths.journal(),
format!(
"{}\n{}\n",
record(
journal::PipelineKind::ReleaseAdopted,
"told",
json!({
"node": "told",
"delivery": "live",
"versions": [{
"identity": "github.com/owner/engine",
"target": "crate",
"version": "0.2.0"
}]
}),
),
record(
journal::PipelineKind::ReleaseArrived,
"told",
json!({"node": "told", "dep": "engine"}),
),
),
)
.expect("the predecessor's journal is written");
let mut watch = Watch::of_run(&paths);
watch.dependencies.insert(
"told".to_owned(),
vec![dependency(Some("crate"), Some(ReleaseStyle::Automated))],
);
watch.answers.insert(
("told".to_owned(), "engine".to_owned()),
Answer::Released {
version: "0.2.0".to_owned(),
},
);
let running = vec![Node {
id: "told".to_owned(),
adoption: Some(Adoption::Fast),
..Node::default()
}];
assert!(
watch.ready_to_adopt(&running).is_empty(),
"a fresh driver told a node its releases had arrived a second time"
);
assert!(
watch
.arrived
.contains(&("told".to_owned(), "engine".to_owned())),
"a fresh driver did not take up the arrival its predecessor reported"
);
std::fs::write(
paths.journal(),
format!(
"{}\n",
record(
journal::PipelineKind::ReleaseAdopted,
"told",
json!({"node": "told", "delivery": "live", "versions": [{"identity": ""}]}),
),
),
)
.expect("the predecessor's journal is written");
assert!(
Watch::of_run(&paths).adopted.is_empty(),
"an unreadable record suppressed a delivery nothing can say happened"
);
let fresh = vec![Node {
id: "fresh".to_owned(),
adoption: Some(Adoption::Fast),
..Node::default()
}];
watch.dependencies.insert(
"fresh".to_owned(),
vec![dependency(Some("crate"), Some(ReleaseStyle::Automated))],
);
watch.answers.insert(
("fresh".to_owned(), "engine".to_owned()),
Answer::Released {
version: "0.2.0".to_owned(),
},
);
assert_eq!(watch.ready_to_adopt(&fresh).len(), 1);
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn an_unusable_bound_falls_back_to_the_shipped_one() {
for (key, read) in [
(POLL_ENV, poll_seconds as fn() -> u64),
(SURFACE_ENV, surface_every_seconds as fn() -> u64),
] {
let shipped = read();
for unusable in ["0", "", "soon", "-1"] {
std::env::set_var(key, unusable);
assert_eq!(read(), shipped, "{key}={unusable:?}");
}
std::env::set_var(key, "7");
assert_eq!(read(), 7);
std::env::remove_var(key);
}
assert_eq!(poll_seconds(), DEFAULT_POLL_SECONDS);
assert_eq!(surface_every_seconds(), DEFAULT_SURFACE_SECONDS);
}
#[test]
fn the_shipped_probe_interval_is_no_longer_than_the_loop_promises() {
std::env::remove_var(POLL_ENV);
let shipped = Duration::from_secs(poll_seconds());
assert!(
shipped <= Duration::from_secs(60),
"the shipped probe interval is {shipped:?}, which is longer than the minute a held \
node is promised for every other answer this loop owes on a clock"
);
}
#[test]
fn the_suites_copy_of_the_shipped_probe_interval_is_this_one() {
let suite = include_str!("../tests/e2e/adoption.rs");
let declaration = "const SHIPPED_POLL_SECONDS: u64 = ";
let start = suite
.find(declaration)
.expect("tests/e2e/adoption.rs declares SHIPPED_POLL_SECONDS")
+ declaration.len();
let copied: u64 = suite[start..]
.split(';')
.next()
.expect("the declaration ends in a semicolon")
.trim()
.parse()
.expect("SHIPPED_POLL_SECONDS is a plain integer literal");
assert_eq!(
copied, DEFAULT_POLL_SECONDS,
"tests/e2e/adoption.rs holds a real build to a probe interval of {copied}s, but this \
build ships {DEFAULT_POLL_SECONDS}s"
);
}
#[test]
fn the_divergence_records_copies_of_the_shipped_probe_interval_are_this_one() {
let record = include_str!("../docs/contract-divergences.md");
let phrase = " seconds by default";
let stated: Vec<u64> = record
.match_indices(phrase)
.map(|(at, _)| {
record[..at]
.rsplit(|c: char| !c.is_ascii_digit())
.next()
.filter(|digits| !digits.is_empty())
.unwrap_or_else(|| {
panic!("docs/contract-divergences.md states \"{phrase}\" after no number")
})
.parse()
.expect("the interval the divergence record states is a plain integer")
})
.collect();
assert!(
!stated.is_empty(),
"docs/contract-divergences.md no longer states the probe interval at all, so this \
gate is reconciling nothing"
);
for interval in stated {
assert_eq!(
interval, DEFAULT_POLL_SECONDS,
"docs/contract-divergences.md tells the contract's owner this build polls \
every {interval}s, but it ships {DEFAULT_POLL_SECONDS}s"
);
}
}
}