use std::collections::{BTreeMap, BTreeSet};
use onevcs::releases::TargetName;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use crate::channel::{Author, Command, Deliver, Dependents, SettleOutcome};
use crate::error::{Error, Result};
use crate::graph::{self, Graph, NodeStatus};
use crate::plan::Node;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum Operation {
NodeAdded {
node: Box<Node>,
#[serde(default, skip_serializing_if = "Option::is_none")]
retry_of: Option<String>,
},
EdgeAdded {
from: String,
to: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
target: Option<TargetName>,
},
EdgeRemoved {
from: String,
to: String,
},
NodeDropped {
node: String,
dependents: Dependents,
},
Reparent {
node: String,
from: Vec<String>,
to: Vec<String>,
},
RetryRequested {
node: String,
replacement: String,
reset: Vec<String>,
},
NodeParked {
node: String,
#[serde(default)]
by: Author,
#[serde(default, skip_serializing_if = "Option::is_none")]
reason: Option<String>,
},
NodeRequeued {
node: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
amend: Option<Map<String, Value>>,
},
HumanAttested {
node: String,
},
CompletionRequested {
reason: String,
},
FindingRaised {
#[serde(default, skip_serializing_if = "Option::is_none")]
node: Option<String>,
message: String,
blocking: bool,
},
SettledFromEvidence {
node: String,
outcome: SettleOutcome,
evidence: String,
},
TaskAmended {
node: String,
text: String,
},
ContextAdded {
node: String,
note: String,
#[serde(default)]
delivery: Delivery,
},
NoteDelivered {
node: String,
addressee: crate::note::Addressee,
text: crate::note::NoteText,
#[serde(default, skip_serializing_if = "Option::is_none")]
criterion: Option<crate::note::Criterion>,
#[serde(flatten)]
reached: crate::note::Reached,
},
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Delivery {
Live,
#[default]
Deferred,
}
#[derive(Debug, Clone, Default)]
pub struct Frontier {
pub recorded: BTreeMap<String, NodeStatus>,
pub attestations: BTreeSet<String>,
pub parks: BTreeMap<String, Park>,
pub in_flight: BTreeMap<String, LiveDispatch>,
pub node_validator: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Park {
pub by: Author,
reason: Option<String>,
}
impl Park {
pub(crate) fn of(by: Author, reason: Option<&str>) -> Self {
Self {
by,
reason: reason
.filter(|reason| !reason.trim().is_empty())
.map(str::to_string),
}
}
fn named(&self) -> String {
match &self.reason {
Some(reason) => format!("the {}, whose reason was: {reason}", self.by.as_str()),
None => format!("the {}, which stated no reason", self.by.as_str()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct LiveDispatch {
pub graph_run: Option<String>,
pub running_for_seconds: u64,
}
impl LiveDispatch {
fn named(&self) -> String {
let run = match &self.graph_run {
Some(run) => format!("graph run '{run}'"),
None => "a graph run it has not yet named".to_string(),
};
format!(
"{run}, running for {}",
crate::telemetry::duration(self.running_for_seconds * 1_000)
)
}
}
pub fn compile(
graph: &mut Graph,
frontier: &Frontier,
author: Author,
command: &Command,
) -> Result<Vec<Operation>> {
let mut candidate = graph.clone();
let operations = compile_into(&mut candidate, frontier, author, command)?;
if !matches!(
command,
Command::Complete { .. }
| Command::Attest { .. }
| Command::Finding { .. }
| Command::Settle { .. }
) {
let plan = candidate.to_plan(&crate::plan::Plan {
schema_version: crate::plan::PLAN_SCHEMA_VERSION,
goal: None,
name: None,
concurrency: candidate.concurrency,
tasks: Vec::new(),
});
graph::validate_edited(&plan).map_err(|e| Error::Refused(e.to_string()))?;
}
if let Some(node) = node_whose_task_is_new(command, &candidate) {
offer_to_validator(frontier.node_validator.as_deref(), command, node)?;
}
*graph = candidate;
Ok(operations)
}
pub fn advance(frontier: &mut Frontier, operations: &[Operation]) {
for operation in operations {
match operation {
Operation::NodeParked { node, by, reason } => {
frontier
.parks
.insert(node.clone(), Park::of(*by, reason.as_deref()));
}
Operation::NodeRequeued { node, .. } => {
frontier.parks.remove(node);
frontier.recorded.remove(node);
}
Operation::SettledFromEvidence { node, outcome, .. } => {
frontier
.recorded
.insert(node.clone(), settled_status(*outcome));
}
_ => {}
}
}
}
fn node_whose_task_is_new<'a>(command: &Command, graph: &'a Graph) -> Option<&'a Node> {
let id = match command {
Command::Add { node } | Command::Retry { node, .. } => node.id.as_str(),
Command::Amend { id, .. } => id.as_str(),
Command::Requeue { id, amend } => {
amend.as_ref().filter(|a| a.contains_key("task"))?;
id.as_str()
}
_ => return None,
};
graph.get(id)
}
const MAX_HOOK_STDERR: u64 = crate::event::MAX_PAYLOAD_TEXT_BYTES as u64;
struct HookAnswer {
status: std::process::ExitStatus,
stderr: Vec<u8>,
}
impl HookAnswer {
fn reason(&self) -> String {
self.reason_from(&String::from_utf8_lossy(&self.stderr))
}
fn reason_from(&self, said: &str) -> String {
let said = crate::views::one_line(said).trim().to_string();
if !said.is_empty() {
return said;
}
format!(
"it exited {} and said nothing on stderr",
self.status
.code()
.map_or_else(|| "without a status".to_string(), |code| code.to_string())
)
}
}
enum HookFailure {
NotStarted(std::io::Error),
NotCollected(std::io::Error),
}
fn ask_hook(hook: &str, document: &str) -> std::result::Result<HookAnswer, HookFailure> {
let mut child = std::process::Command::new(hook)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(HookFailure::NotStarted)?;
if let Some(mut stdin) = child.stdin.take() {
use std::io::Write;
let _ = stdin.write_all(document.as_bytes());
}
let mut stderr = Vec::new();
if let Some(pipe) = child.stderr.take() {
use std::io::Read;
let mut bounded = pipe.take(MAX_HOOK_STDERR);
if let Err(e) = bounded.read_to_end(&mut stderr) {
stderr.extend_from_slice(format!(" [its stderr stopped early: {e}]").as_bytes());
}
let _ = std::io::copy(&mut bounded.into_inner(), &mut std::io::sink());
}
let status = child.wait().map_err(HookFailure::NotCollected)?;
Ok(HookAnswer { status, stderr })
}
fn offer_to_validator(validator: Option<&str>, command: &Command, node: &Node) -> Result<()> {
let Some(validator) = validator.filter(|command| !command.trim().is_empty()) else {
return Ok(());
};
let op = crate::channel::op_of(command);
let document = serde_json::to_string(node)
.map_err(|e| refuse(format!("{op}: node '{}' does not serialize: {e}", node.id)))?;
let answer = ask_hook(validator, &document).map_err(|failure| match failure {
HookFailure::NotStarted(e) => refuse(format!(
"{op}: the node validator '{validator}' this run was launched with could not \
be started ({e}), so node '{}' was checked by nothing and the edit was not \
applied",
node.id
)),
HookFailure::NotCollected(e) => refuse(format!(
"{op}: the node validator '{validator}' did not answer for node '{}' ({e}), so the \
edit was not applied",
node.id
)),
})?;
if answer.status.success() {
return Ok(());
}
Err(refuse(format!(
"{op}: the node validator refused node '{}': {}",
node.id,
answer.reason()
)))
}
#[derive(Debug, Serialize)]
struct ChangedNode<'a> {
op: &'static str,
node: &'a Node,
}
#[derive(Debug, Serialize)]
struct EnvelopeUnderReview<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
goal: Option<&'a str>,
changes: Vec<ChangedNode<'a>>,
plan: crate::plan::Plan,
}
fn node_the_command_changes<'a>(command: &Command, graph: &'a Graph) -> Option<&'a Node> {
let id = match command {
Command::Add { node } | Command::Retry { node, .. } => node.id.as_str(),
Command::Amend { id, .. } | Command::Reparent { id, .. } => id.as_str(),
Command::Requeue { id, amend } => {
amend.as_ref()?;
id.as_str()
}
_ => return None,
};
graph.get(id)
}
pub(crate) fn offer_envelope_to_reviewer(
reviewer: Option<&str>,
commands: &[Command],
edited: &Graph,
launched_with: Option<&crate::plan::Plan>,
) -> Result<()> {
let Some(reviewer) = reviewer.filter(|command| !command.trim().is_empty()) else {
return Ok(());
};
let source = launched_with.cloned().unwrap_or_else(|| crate::plan::Plan {
schema_version: crate::plan::PLAN_SCHEMA_VERSION,
goal: None,
name: None,
concurrency: edited.concurrency,
tasks: Vec::new(),
});
let under_review = EnvelopeUnderReview {
goal: source.goal.as_ref().map(|goal| goal.text.as_str()),
changes: commands
.iter()
.filter_map(|command| {
node_the_command_changes(command, edited).map(|node| ChangedNode {
op: crate::channel::op_of(command),
node,
})
})
.collect(),
plan: edited.to_plan(&source),
};
let document = serde_json::to_string(&under_review)
.map_err(|e| refuse(format!("this envelope does not serialize: {e}")))?;
let answer = ask_hook(reviewer, &document).map_err(|failure| match failure {
HookFailure::NotStarted(e) => refuse(format!(
"the envelope reviewer '{reviewer}' this run was launched with could not be \
started ({e}), so this envelope was reviewed by nothing and none of its edits \
were applied"
)),
HookFailure::NotCollected(e) => refuse(format!(
"the envelope reviewer '{reviewer}' did not answer ({e}), so none of this \
envelope's edits were applied"
)),
})?;
if answer.status.success() {
return Ok(());
}
let said = String::from_utf8_lossy(&answer.stderr);
let objection = Objection::read(&said);
let envelope_named: BTreeSet<String> = commands
.iter()
.filter_map(crate::channel::target_of)
.collect();
Err(refuse(format!(
"the envelope reviewer refused this envelope{}, so none of its edits were applied — \
it carried {}: {}",
objection.against(&envelope_named),
carried(commands),
answer.reason_from(&objection.said)
)))
}
fn carried(commands: &[Command]) -> String {
commands
.iter()
.map(|command| {
let op = crate::channel::op_of(command);
match crate::channel::target_of(command) {
Some(id) => format!("{op} '{id}'"),
None => op.to_string(),
}
})
.collect::<Vec<_>>()
.join(", ")
}
const OBJECTION_PREFIX: &str = "objection:";
#[derive(Debug)]
struct Objection {
named: Vec<String>,
said: String,
}
impl Objection {
fn read(stderr: &str) -> Self {
let mut named: Vec<String> = Vec::new();
let mut said: Vec<&str> = Vec::new();
for line in stderr.lines() {
let trimmed = line.trim();
let declared = trimmed
.get(..OBJECTION_PREFIX.len())
.filter(|start| start.eq_ignore_ascii_case(OBJECTION_PREFIX))
.map(|_| &trimmed[OBJECTION_PREFIX.len()..]);
let Some(declared) = declared else {
said.push(line);
continue;
};
let name = crate::views::one_line(declared).trim().to_string();
if !name.is_empty() && !named.contains(&name) {
named.push(name);
}
}
Self {
named,
said: said.join("\n"),
}
}
fn against(&self, envelope_named: &BTreeSet<String>) -> String {
let (changed, elsewhere): (Vec<&String>, Vec<&String>) = self
.named
.iter()
.partition(|name| envelope_named.contains(*name));
let unknown = |names| {
listed("the name", names)
.map(|named| format!("{named}, which no node this envelope changes goes by"))
};
match (listed("node", &changed), unknown(&elsewhere)) {
(Some(changed), Some(elsewhere)) => format!(" over {changed}, and over {elsewhere}"),
(Some(changed), None) => format!(" over {changed}"),
(None, Some(elsewhere)) => format!(" over {elsewhere}"),
(None, None) => " without declaring the node it objected to".to_string(),
}
}
}
fn listed(noun: &str, names: &[&String]) -> Option<String> {
let (first, rest) = names.split_first()?;
let plural = match rest.is_empty() {
true => String::new(),
false => "s".to_string(),
};
let quoted = std::iter::once(first)
.chain(rest)
.map(|name| format!("'{name}'"))
.collect::<Vec<_>>()
.join(", ");
Some(format!("{noun}{plural} {quoted}"))
}
fn compile_into(
graph: &mut Graph,
frontier: &Frontier,
author: Author,
command: &Command,
) -> Result<Vec<Operation>> {
match command {
Command::Add { node } => compile_add(graph, node),
Command::Drop { id, dependents } => compile_drop(graph, frontier, id, *dependents),
Command::Reparent { id, deps } => compile_reparent(graph, frontier, id, deps),
Command::Retry { id, node } => compile_retry(graph, frontier, id, node),
Command::Cancel { id, reason } => {
compile_cancel(graph, frontier, author, id, reason.as_deref())
}
Command::Requeue { id, amend } => {
compile_requeue(graph, frontier, author, id, amend.as_ref())
}
Command::Settle {
id,
outcome,
evidence,
} => compile_settle(graph, frontier, id, *outcome, evidence),
Command::Attest { reference } => compile_attest(frontier, reference),
Command::Complete { reason } => Ok(vec![Operation::CompletionRequested {
reason: reason.clone(),
}]),
Command::Amend { id, text } => compile_amend(graph, frontier, id, text),
Command::Note {
id,
deliver,
persist,
..
} => compile_note(graph, frontier, id, *deliver, *persist),
Command::Finding {
message,
blocking,
id,
} => compile_finding(graph, id.as_deref(), message, *blocking),
}
}
fn refuse(what: impl Into<String>) -> Error {
Error::Refused(what.into())
}
fn compile_add(graph: &mut Graph, node: &Node) -> Result<Vec<Operation>> {
if graph.contains(&node.id) {
return Err(refuse(format!("add: node '{}' already exists", node.id)));
}
graph::validate_node(node).map_err(|e| refuse(e.to_string()))?;
let mut operations = vec![Operation::NodeAdded {
node: Box::new(node.clone()),
retry_of: None,
}];
for dep in &node.deps {
operations.push(Operation::EdgeAdded {
from: dep.clone(),
to: node.id.clone(),
target: node.consumes.get(dep).cloned(),
});
}
graph.insert(node.clone());
Ok(operations)
}
fn compile_reparent(
graph: &mut Graph,
frontier: &Frontier,
id: &str,
deps: &[String],
) -> Result<Vec<Operation>> {
let Some(node) = graph.get(id) else {
return Err(refuse(format!("reparent: no node '{id}'")));
};
if frontier.recorded.contains_key(id) {
return Err(refuse(format!("reparent: node '{id}' has already started")));
}
let previous = node.deps.clone();
let consumes = node.consumes.clone();
let mut operations: Vec<Operation> = previous
.iter()
.map(|dep| Operation::EdgeRemoved {
from: dep.clone(),
to: id.to_string(),
})
.collect();
operations.extend(deps.iter().map(|dep| Operation::EdgeAdded {
from: dep.clone(),
to: id.to_string(),
target: consumes.get(dep).cloned(),
}));
operations.push(Operation::Reparent {
node: id.to_string(),
from: previous,
to: deps.to_vec(),
});
if let Some(node) = graph.get_mut(id) {
node.deps = deps.to_vec();
node.consumes
.retain(|dep, _| node.deps.iter().any(|d| d == dep));
}
Ok(operations)
}
fn compile_drop(
graph: &mut Graph,
frontier: &Frontier,
id: &str,
dependents: Dependents,
) -> Result<Vec<Operation>> {
let Some(target) = graph.get(id).cloned() else {
return Err(refuse(format!("drop: no node '{id}'")));
};
let direct = graph.dependents_of(id);
if let Some(repo) = &target.repo {
let unresolved_same_identity = direct.iter().any(|dependent| {
graph.get(dependent).and_then(|n| n.repo.as_ref()) == Some(repo)
&& frontier.recorded.get(dependent) != Some(&NodeStatus::Done)
});
let alternative_anchor = graph.iter().any(|node| {
node.id != id
&& node.repo.as_ref() == Some(repo)
&& frontier.recorded.get(&node.id) == Some(&NodeStatus::Done)
});
if unresolved_same_identity && !alternative_anchor {
return Err(refuse(
"drop: would remove the last unresolved publication anchor",
));
}
}
let mut operations = Vec::new();
let mut removed: BTreeSet<String> = BTreeSet::from([id.to_string()]);
match dependents {
Dependents::Drop => {
let mut pending = direct;
while let Some(candidate) = pending.pop() {
if !removed.insert(candidate.clone()) {
continue;
}
pending.extend(graph.dependents_of(&candidate));
}
}
Dependents::Detach => {
for dependent in &direct {
if let Some(node) = graph.get_mut(dependent) {
node.deps.retain(|dep| dep != id);
node.consumes.remove(id);
}
operations.push(Operation::EdgeRemoved {
from: id.to_string(),
to: dependent.clone(),
});
}
}
}
for dropped in &removed {
graph.remove(dropped);
operations.push(Operation::NodeDropped {
node: dropped.clone(),
dependents,
});
}
Ok(operations)
}
fn compile_retry(
graph: &mut Graph,
frontier: &Frontier,
id: &str,
replacement: &Node,
) -> Result<Vec<Operation>> {
let Some(target) = graph.get(id).cloned() else {
return Err(refuse(format!("retry: no node '{id}'")));
};
match frontier.recorded.get(id) {
Some(NodeStatus::Running | NodeStatus::Failed | NodeStatus::Cancelled) => {}
_ => {
return Err(refuse(format!(
"retry: node '{id}' is not running, failed, or cancelled"
)))
}
}
if replacement.id.trim().is_empty() {
return Err(refuse("retry: the replacement needs a non-empty id"));
}
if graph.contains(&replacement.id) {
return Err(refuse(format!(
"retry: replacement id '{}' must be new",
replacement.id
)));
}
let replacement = pin_retry_branch(inherit_preserved_branch(
validate_retry_pin(replacement)?,
&target,
));
graph::validate_node(&replacement).map_err(|e| refuse(e.to_string()))?;
let mut replacement = replacement;
if replacement.deps.is_empty() {
replacement.deps = target.deps.clone();
replacement.consumes.clone_from(&target.consumes);
}
let direct = graph.dependents_of(id);
let mut reset: BTreeSet<String> = direct.iter().cloned().collect();
let mut pending: Vec<String> = direct.clone();
while let Some(predecessor) = pending.pop() {
for dependent in graph.dependents_of(&predecessor) {
if reset.insert(dependent.clone()) {
pending.push(dependent);
}
}
}
let mut operations = vec![
Operation::RetryRequested {
node: id.to_string(),
replacement: replacement.id.clone(),
reset: reset.into_iter().collect(),
},
Operation::NodeAdded {
node: Box::new(replacement.clone()),
retry_of: Some(id.to_string()),
},
];
for dep in &replacement.deps {
operations.push(Operation::EdgeAdded {
from: dep.clone(),
to: replacement.id.clone(),
target: replacement.consumes.get(dep).cloned(),
});
}
graph.insert(replacement.clone());
for dependent in &direct {
let mut carried = None;
if let Some(node) = graph.get_mut(dependent) {
for dep in &mut node.deps {
if dep == id {
dep.clone_from(&replacement.id);
}
}
carried = node.consumes.remove(id);
if let Some(carried) = carried.clone() {
node.consumes.insert(replacement.id.clone(), carried);
}
}
operations.push(Operation::EdgeRemoved {
from: id.to_string(),
to: dependent.clone(),
});
operations.push(Operation::EdgeAdded {
from: replacement.id.clone(),
to: dependent.clone(),
target: carried,
});
}
graph.remove(id);
operations.push(Operation::NodeDropped {
node: id.to_string(),
dependents: Dependents::Detach,
});
Ok(operations)
}
fn validate_retry_pin(node: &Node) -> Result<Node> {
if let (Some(branch), Some(resume)) = (&node.branch, &node.resume) {
if &resume.branch != branch {
return Err(refuse(format!(
"retry: replacement '{}' pins branch '{branch}' but resumes branch '{}'; \
a retry may name only one branch",
node.id, resume.branch
)));
}
}
Ok(node.clone())
}
fn inherit_preserved_branch(mut node: Node, superseded: &Node) -> Node {
if node.branch.is_some() || node.resume.is_some() {
return node;
}
node.branch.clone_from(&superseded.branch);
node.resume.clone_from(&superseded.resume);
node
}
fn pin_retry_branch(mut node: Node) -> Node {
if node.branch.is_none() {
if let Some(resume) = &node.resume {
if !resume.branch.is_empty() {
node.branch = Some(resume.branch.clone());
}
}
}
node
}
fn compile_cancel(
graph: &mut Graph,
frontier: &Frontier,
author: Author,
id: &str,
reason: Option<&str>,
) -> Result<Vec<Operation>> {
let reason = match reason {
Some(reason) if reason.trim().is_empty() => {
return Err(refuse(format!(
"cancel: node '{id}' would be parked stating an empty reason; say why it is \
being held, or state no reason at all"
)))
}
Some(reason) => Some(reason.to_string()),
None => None,
};
let Some(node) = graph.get(id) else {
return Err(refuse(format!("cancel: no node '{id}'")));
};
if node.parked {
return Err(refuse(format!("cancel: node '{id}' is already parked")));
}
match frontier.recorded.get(id) {
None | Some(NodeStatus::Running) => {}
Some(status) => {
return Err(refuse(format!(
"cancel: node '{id}' is {}, not pending or running",
status.as_str()
)))
}
}
if let Some(node) = graph.get_mut(id) {
node.parked = true;
}
Ok(vec![Operation::NodeParked {
node: id.to_string(),
by: author,
reason,
}])
}
fn compile_requeue(
graph: &mut Graph,
frontier: &Frontier,
author: Author,
id: &str,
amend: Option<&Map<String, Value>>,
) -> Result<Vec<Operation>> {
let Some(node) = graph.get(id).cloned() else {
return Err(refuse(format!("requeue: no node '{id}'")));
};
if !node.parked {
return Err(refuse(format!("requeue: node '{id}' is not parked")));
}
let park = frontier.parks.get(id).cloned().unwrap_or_default();
if author == Author::Monitor && park.by != Author::Monitor {
return Err(refuse(format!(
"requeue: node '{id}' was parked by {}. Undoing that is the parking author's \
decision rather than an observation: surface it to the planner instead",
park.named()
)));
}
if let Some(live) = frontier.in_flight.get(id) {
return Err(refuse(format!(
"requeue: node '{id}' still has a dispatch in flight ({}); a cancel asks that \
dispatch to stop rather than stopping it, so wait for the node to settle and \
requeue it then",
live.named()
)));
}
if let Some(amend) = amend {
let forbidden: Vec<&str> = ["id", "deps"]
.into_iter()
.filter(|key| amend.contains_key(*key))
.collect();
if !forbidden.is_empty() {
return Err(refuse(format!(
"requeue: cannot amend {}: use 'add' or 'reparent' for that",
forbidden.join(", ")
)));
}
}
let mut merged =
serde_json::to_value(&node).map_err(|e| refuse(format!("requeue: node '{id}': {e}")))?;
if let (Some(object), Some(amend)) = (merged.as_object_mut(), amend) {
for (key, value) in amend {
object.insert(key.clone(), value.clone());
}
}
if let Some(object) = merged.as_object_mut() {
object.remove("parked");
}
if let Some(retired) = crate::plan::retired_field_refusal(&merged) {
return Err(refuse(format!("requeue: node {retired}")));
}
let amended: Node = serde_json::from_value(merged)
.map_err(|e| refuse(format!("requeue: amended node '{id}' is invalid: {e}")))?;
graph::validate_node(&amended).map_err(|e| refuse(e.to_string()))?;
graph.insert(amended);
Ok(vec![Operation::NodeRequeued {
node: id.to_string(),
amend: amend.filter(|a| !a.is_empty()).cloned(),
}])
}
pub(crate) fn settled_status(outcome: SettleOutcome) -> NodeStatus {
match outcome {
SettleOutcome::Done => NodeStatus::Done,
SettleOutcome::Failed => NodeStatus::Failed,
}
}
fn compile_settle(
graph: &Graph,
frontier: &Frontier,
id: &str,
outcome: SettleOutcome,
evidence: &str,
) -> Result<Vec<Operation>> {
if evidence.trim().is_empty() {
return Err(refuse(format!(
"settle: node '{id}' would be settled {} on no evidence at all; the evidence is \
journalled as the reason the node is in that state, so state what was seen",
outcome.as_str()
)));
}
if !graph.contains(id) {
return Err(refuse(format!(
"settle: no node '{id}' in this run; it has: {}",
graph.ids().cloned().collect::<Vec<_>>().join(", ")
)));
}
if frontier.recorded.get(id).copied() == Some(settled_status(outcome)) {
return Err(refuse(format!(
"settle: node '{id}' has already settled {0}, so this settles nothing — the \
record already says {0}. A settle stating a **different** outcome is accepted: \
correcting a record the world has moved past is what this op is for",
outcome.as_str()
)));
}
if let Some(live) = frontier.in_flight.get(id) {
return Err(refuse(format!(
"settle: node '{id}' still has a dispatch in flight ({}); that dispatch will \
settle the node itself, so wait for it and settle it then if it settled wrongly",
live.named()
)));
}
Ok(vec![Operation::SettledFromEvidence {
node: id.to_string(),
outcome,
evidence: evidence.to_string(),
}])
}
fn compile_finding(
graph: &Graph,
id: Option<&str>,
message: &str,
blocking: bool,
) -> Result<Vec<Operation>> {
if message.trim().is_empty() {
return Err(refuse(
"a finding carries what was found: this one has an empty message",
));
}
if let Some(node) = id {
if !graph.contains(node) {
return Err(refuse(format!(
"cannot raise a finding about node '{node}', which this run does not have; \
it has: {}",
graph.ids().cloned().collect::<Vec<_>>().join(", ")
)));
}
}
Ok(vec![Operation::FindingRaised {
node: id.map(str::to_string),
message: message.to_string(),
blocking,
}])
}
fn compile_attest(frontier: &Frontier, reference: &str) -> Result<Vec<Operation>> {
if frontier.attestations.contains(reference) {
return Err(refuse(format!(
"attest: '{reference}' was already attested"
)));
}
if !matches!(
frontier.recorded.get(reference),
Some(NodeStatus::Waiting | NodeStatus::Failed)
) {
return Err(refuse(format!(
"attest: '{reference}' is not a ready, waiting human action, nor a node \
that settled failed; attest accepts one of those two references"
)));
}
Ok(vec![Operation::HumanAttested {
node: reference.to_string(),
}])
}
fn compile_note(
graph: &mut Graph,
frontier: &Frontier,
id: &str,
deliver: Deliver,
persist: bool,
) -> Result<Vec<Operation>> {
if !graph.contains(id) {
return Err(refuse(format!("note: no node '{id}'")));
}
let reach = crate::note::Reach::of(id, deliver, persist)?;
if !reach.attempts_a_live_turn() && frontier.recorded.get(id) == Some(&NodeStatus::Done) {
return Err(crate::note::reaches_nobody(
id,
"it has settled done, so no dispatch of it will ever take the note and \
`deliver: next` asks for no live delivery",
));
}
Ok(Vec::new())
}
fn compile_amend(
graph: &mut Graph,
frontier: &Frontier,
id: &str,
text: &str,
) -> Result<Vec<Operation>> {
if !graph.contains(id) {
return Err(refuse(format!("amend: no node '{id}'")));
}
if text.trim().is_empty() {
return Err(refuse(format!(
"amend: node '{id}': the amendment cannot be blank"
)));
}
if frontier.recorded.get(id) == Some(&NodeStatus::Done) {
return Err(refuse(format!(
"amend: node '{id}' has settled done, so nothing will read the amendment"
)));
}
if let Some(node) = graph.get_mut(id) {
node.amendment = Some(text.to_string());
}
Ok(vec![Operation::TaskAmended {
node: id.to_string(),
text: text.to_string(),
}])
}
pub fn apply(graph: &mut Graph, operation: &Operation) {
match operation {
Operation::NodeAdded { node, .. } => graph.insert((**node).clone()),
Operation::NodeDropped { node, .. } => {
graph.remove(node);
for id in graph.ids().cloned().collect::<Vec<_>>() {
if let Some(other) = graph.get_mut(&id) {
other.consumes.remove(node);
}
}
}
Operation::Reparent { node, to, .. } => {
if let Some(node) = graph.get_mut(node) {
node.deps.clone_from(to);
node.consumes.retain(|dep, _| to.iter().any(|d| d == dep));
}
}
Operation::EdgeRemoved { from, to } => {
if let Some(node) = graph.get_mut(to) {
node.deps.retain(|dep| dep != from);
}
}
Operation::EdgeAdded { from, to, target } => {
if let Some(node) = graph.get_mut(to) {
if !node.deps.contains(from) {
node.deps.push(from.clone());
}
if let Some(target) = target {
node.consumes.insert(from.clone(), target.clone());
}
}
}
Operation::NodeParked { node, .. } => {
if let Some(node) = graph.get_mut(node) {
node.parked = true;
}
}
Operation::SettledFromEvidence { .. } => {}
Operation::NodeRequeued { node, amend } => {
let Some(existing) = graph.get(node).cloned() else {
return;
};
let mut merged = match serde_json::to_value(&existing) {
Ok(value) => value,
Err(_) => return,
};
if let Some(object) = merged.as_object_mut() {
object.remove("parked");
for (key, value) in amend.iter().flatten() {
object.insert(key.clone(), value.clone());
}
}
if let Ok(amended) = serde_json::from_value::<Node>(merged) {
graph.insert(amended);
}
}
Operation::TaskAmended { node, text } => {
if let Some(node) = graph.get_mut(node) {
node.amendment = Some(text.clone());
}
}
Operation::ContextAdded {
node,
note,
delivery: Delivery::Deferred,
} => {
if let Some(node) = graph.get_mut(node) {
node.context = Some(note.clone());
}
}
Operation::ContextAdded { .. } => {}
Operation::NoteDelivered {
node,
text,
reached: crate::note::Reached::Carried,
..
} => {
if let Some(node) = graph.get_mut(node) {
node.context = Some(text.as_str().to_string());
}
}
Operation::NoteDelivered { .. }
| Operation::HumanAttested { .. }
| Operation::CompletionRequested { .. }
| Operation::FindingRaised { .. }
| Operation::RetryRequested { .. } => {}
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
use crate::plan::{NodeKind, Plan, Resume, PLAN_SCHEMA_VERSION};
fn compile(
graph: &mut Graph,
frontier: &Frontier,
command: &Command,
) -> Result<Vec<Operation>> {
super::compile(graph, frontier, Author::Planner, command)
}
fn agent(id: &str, deps: &[&str]) -> Node {
Node {
id: id.into(),
persona: Some("engineer".into()),
task: Some("## What\ndo it".into()),
deps: deps.iter().map(|d| (*d).to_string()).collect(),
..Node::default()
}
}
fn target(name: &str) -> TargetName {
name.parse().expect("a release target name")
}
fn targets_in(graph: &Graph) -> BTreeSet<(String, String, String)> {
graph
.iter()
.flat_map(|node| {
node.consumes
.iter()
.map(|(dep, target)| (node.id.clone(), dep.clone(), target.to_string()))
})
.collect()
}
fn graph_of(nodes: Vec<Node>) -> Graph {
Graph::from_plan(&Plan {
schema_version: PLAN_SCHEMA_VERSION,
goal: None,
name: None,
concurrency: 4,
tasks: nodes,
})
}
fn note_for(id: &str, note: &str) -> Command {
note_with(id, note, Deliver::Live, true)
}
fn note_with(id: &str, note: &str, deliver: Deliver, persist: bool) -> Command {
Command::Note {
id: id.into(),
addressee: crate::note::Addressee::Worker,
text: note.parse().expect("a usable note"),
criterion: None,
deliver,
persist,
}
}
fn frontier(entries: &[(&str, NodeStatus)]) -> Frontier {
Frontier {
recorded: entries
.iter()
.map(|(id, status)| ((*id).to_string(), *status))
.collect(),
..Frontier::default()
}
}
#[test]
fn add_inserts_a_node_and_records_its_edges() {
let mut graph = graph_of(vec![agent("a", &[])]);
let operations = compile(
&mut graph,
&Frontier::default(),
&Command::Add {
node: agent("b", &["a"]),
},
)
.expect("the add is legal");
assert!(graph.contains("b"));
assert!(matches!(operations[0], Operation::NodeAdded { .. }));
assert!(
matches!(&operations[1], Operation::EdgeAdded { from, to, .. } if from == "a" && to == "b")
);
}
#[test]
fn add_refuses_a_duplicate_id_an_invalid_node_and_a_dangling_dependency() {
let mut graph = graph_of(vec![agent("a", &[])]);
let refusals = [
(
Command::Add {
node: agent("a", &[]),
},
"already exists",
),
(
Command::Add {
node: Node {
id: "b".into(),
..Node::default()
},
},
"needs a persona",
),
(
Command::Add {
node: agent("c", &["nowhere"]),
},
"not in the plan",
),
];
for (command, expected) in refusals {
let message = compile(&mut graph, &Frontier::default(), &command)
.unwrap_err()
.to_string();
assert!(message.contains(expected), "{message:?} lacks {expected:?}");
}
assert_eq!(graph.len(), 1, "a refused add mutated the graph");
}
#[test]
fn an_add_that_would_create_a_cycle_is_refused_and_changes_nothing() {
let mut graph = graph_of(vec![agent("a", &["b"]), agent("b", &[])]);
let message = compile(
&mut graph,
&Frontier::default(),
&Command::Reparent {
id: "b".into(),
deps: vec!["a".into()],
},
)
.unwrap_err()
.to_string();
assert!(message.contains("cycle"), "{message}");
assert!(graph.get("b").expect("b").deps.is_empty());
}
#[test]
fn reparent_requires_an_unstarted_node() {
let mut graph = graph_of(vec![agent("a", &[]), agent("b", &[])]);
let started = frontier(&[("b", NodeStatus::Running)]);
let message = compile(
&mut graph,
&started,
&Command::Reparent {
id: "b".into(),
deps: vec!["a".into()],
},
)
.unwrap_err()
.to_string();
assert!(message.contains("already started"), "{message}");
let operations = compile(
&mut graph,
&Frontier::default(),
&Command::Reparent {
id: "b".into(),
deps: vec!["a".into()],
},
)
.expect("an unstarted node reparents");
assert_eq!(graph.get("b").expect("b").deps, vec!["a".to_string()]);
assert!(operations
.iter()
.any(|op| matches!(op, Operation::Reparent { .. })));
assert!(compile(
&mut graph,
&Frontier::default(),
&Command::Reparent {
id: "nowhere".into(),
deps: vec![],
},
)
.unwrap_err()
.to_string()
.contains("no node"));
}
#[test]
fn drop_detaches_or_recursively_removes_and_must_state_which() {
let mut graph = graph_of(vec![
agent("a", &[]),
agent("b", &["a"]),
agent("c", &["b"]),
]);
compile(
&mut graph,
&Frontier::default(),
&Command::Drop {
id: "a".into(),
dependents: Dependents::Detach,
},
)
.expect("detaching is legal");
assert!(!graph.contains("a"));
assert!(graph.get("b").expect("b").deps.is_empty());
assert!(graph.contains("c"));
let mut graph = graph_of(vec![
agent("a", &[]),
agent("b", &["a"]),
agent("c", &["b"]),
]);
compile(
&mut graph,
&Frontier::default(),
&Command::Drop {
id: "a".into(),
dependents: Dependents::Drop,
},
)
.expect("recursive dropping is legal");
assert!(graph.is_empty(), "the dependents were not dropped too");
}
#[test]
fn drop_refuses_to_remove_the_last_unresolved_publication_anchor() {
let lifecycle = |id: &str, deps: &[&str]| Node {
repo: Some("owner/repo".into()),
..agent(id, deps)
};
let mut graph = graph_of(vec![
lifecycle("anchor", &[]),
lifecycle("stacked", &["anchor"]),
]);
let message = compile(
&mut graph,
&Frontier::default(),
&Command::Drop {
id: "anchor".into(),
dependents: Dependents::Detach,
},
)
.unwrap_err()
.to_string();
assert!(message.contains("publication anchor"), "{message}");
assert!(graph.contains("anchor"));
let mut graph = graph_of(vec![
lifecycle("anchor", &[]),
lifecycle("stacked", &["anchor"]),
lifecycle("landed", &[]),
]);
compile(
&mut graph,
&frontier(&[("landed", NodeStatus::Done)]),
&Command::Drop {
id: "anchor".into(),
dependents: Dependents::Detach,
},
)
.expect("a settled alternative anchor allows the drop");
}
#[test]
fn retry_supersedes_only_a_running_failed_or_cancelled_node() {
let mut graph = graph_of(vec![agent("build", &[]), agent("ship", &["build"])]);
let message = compile(
&mut graph,
&Frontier::default(),
&Command::Retry {
id: "build".into(),
node: agent("build-2", &[]),
},
)
.unwrap_err()
.to_string();
assert!(
message.contains("not running, failed, or cancelled"),
"{message}"
);
let operations = compile(
&mut graph,
&frontier(&[("build", NodeStatus::Failed)]),
&Command::Retry {
id: "build".into(),
node: agent("build-2", &[]),
},
)
.expect("a failed node retries");
assert!(graph.contains("build-2"));
assert_eq!(
graph.get("ship").expect("ship").deps,
vec!["build-2".to_string()],
"the dependent was not redirected"
);
assert!(operations
.iter()
.any(|op| matches!(op, Operation::RetryRequested { .. })));
}
#[test]
fn retry_demands_a_new_id() {
let mut graph = graph_of(vec![agent("build", &[])]);
let failed = frontier(&[("build", NodeStatus::Failed)]);
for (node, expected) in [
(agent("build", &[]), "must be new"),
(
Node {
id: String::new(),
..agent("x", &[])
},
"non-empty id",
),
] {
let message = compile(
&mut graph,
&failed,
&Command::Retry {
id: "build".into(),
node,
},
)
.unwrap_err()
.to_string();
assert!(message.contains(expected), "{message:?} lacks {expected:?}");
}
assert!(compile(
&mut graph,
&failed,
&Command::Retry {
id: "nowhere".into(),
node: agent("x", &[])
}
)
.unwrap_err()
.to_string()
.contains("no node"));
}
#[test]
fn a_retry_may_name_only_one_branch() {
let mut graph = graph_of(vec![Node {
repo: Some("owner/repo".into()),
..agent("build", &[])
}]);
let failed = frontier(&[("build", NodeStatus::Failed)]);
let disagreeing = Node {
repo: Some("owner/repo".into()),
branch: Some("pinned".into()),
resume: Some(Resume {
branch: "preserved".into(),
checkpoint: None,
completed_steps: Vec::new(),
}),
..agent("build-2", &[])
};
let message = compile(
&mut graph,
&failed,
&Command::Retry {
id: "build".into(),
node: disagreeing,
},
)
.unwrap_err()
.to_string();
assert!(message.contains("only one branch"), "{message}");
let resuming = Node {
repo: Some("owner/repo".into()),
resume: Some(Resume {
branch: "preserved".into(),
checkpoint: Some("abc123".into()),
completed_steps: Vec::new(),
}),
..agent("build-3", &[])
};
compile(
&mut graph,
&failed,
&Command::Retry {
id: "build".into(),
node: resuming,
},
)
.expect("naming a continuation is naming its branch");
assert_eq!(
graph.get("build-3").expect("build-3").branch.as_deref(),
Some("preserved")
);
}
#[test]
fn cancel_parks_a_pending_or_running_node_and_nothing_else() {
let mut graph = graph_of(vec![agent("sweep", &[])]);
compile(
&mut graph,
&Frontier::default(),
&Command::Cancel {
id: "sweep".into(),
reason: None,
},
)
.expect("a pending node parks");
assert!(graph.get("sweep").expect("sweep").parked);
let message = compile(
&mut graph,
&Frontier::default(),
&Command::Cancel {
id: "sweep".into(),
reason: None,
},
)
.unwrap_err()
.to_string();
assert!(message.contains("already parked"), "{message}");
let mut graph = graph_of(vec![agent("done", &[])]);
let message = compile(
&mut graph,
&frontier(&[("done", NodeStatus::Done)]),
&Command::Cancel {
id: "done".into(),
reason: None,
},
)
.unwrap_err()
.to_string();
assert!(message.contains("not pending or running"), "{message}");
assert!(compile(
&mut graph,
&Frontier::default(),
&Command::Cancel {
id: "nowhere".into(),
reason: None
}
)
.unwrap_err()
.to_string()
.contains("no node"));
}
#[test]
fn a_park_records_the_author_who_issued_it_and_the_reason_it_carried() {
let disk = "a third very large build would fill the disk this host has 8G left on";
let mut graph = graph_of(vec![agent("build", &[])]);
let operations = super::compile(
&mut graph,
&Frontier::default(),
Author::Monitor,
&Command::Cancel {
id: "build".into(),
reason: Some(disk.into()),
},
)
.expect("a pending node parks");
assert_eq!(
operations,
vec![Operation::NodeParked {
node: "build".into(),
by: Author::Monitor,
reason: Some(disk.into()),
}],
"the park does not say who made it or why"
);
let mut graph = graph_of(vec![agent("sweep", &[])]);
let operations = compile(
&mut graph,
&Frontier::default(),
&Command::Cancel {
id: "sweep".into(),
reason: None,
},
)
.expect("a park stating no reason still parks");
assert_eq!(
operations,
vec![Operation::NodeParked {
node: "sweep".into(),
by: Author::Planner,
reason: None,
}]
);
assert!(graph.get("sweep").expect("sweep").parked);
let mut graph = graph_of(vec![agent("sweep", &[])]);
let message = compile(
&mut graph,
&Frontier::default(),
&Command::Cancel {
id: "sweep".into(),
reason: Some(" ".into()),
},
)
.unwrap_err()
.to_string();
assert!(message.contains("empty reason"), "{message}");
assert!(
!graph.get("sweep").expect("sweep").parked,
"a refused cancel parked the node anyway"
);
}
#[test]
fn a_park_recorded_before_those_fields_existed_reads_back_as_the_planners() {
let old: Operation = serde_json::from_value(json!({
"kind": "node-parked",
"node": "sweep",
}))
.expect("a record written before the fields existed still reads");
assert_eq!(
old,
Operation::NodeParked {
node: "sweep".into(),
by: Author::Planner,
reason: None,
}
);
let mut graph = graph_of(vec![agent("sweep", &[])]);
apply(&mut graph, &old);
let mut expected = agent("sweep", &[]);
expected.parked = true;
assert_eq!(graph.get("sweep"), Some(&expected));
}
#[test]
fn a_recorded_reason_that_says_nothing_reads_as_a_park_that_stated_none() {
assert_eq!(
Park::of(Author::Planner, Some(" \n ")),
Park::of(Author::Planner, None),
"a blank recorded reason was kept as a reason"
);
assert_ne!(
Park::of(Author::Monitor, Some("it is redundant")),
Park::of(Author::Monitor, None),
"a reason that says something was dropped"
);
let mut graph = graph_of(vec![{
let mut node = agent("build", &[]);
node.parked = true;
node
}]);
let message = super::compile(
&mut graph,
&Frontier {
parks: [("build".to_string(), Park::of(Author::Planner, Some(" ")))]
.into_iter()
.collect(),
..Frontier::default()
},
Author::Monitor,
&Command::Requeue {
id: "build".into(),
amend: None,
},
)
.unwrap_err()
.to_string();
assert!(
message.contains("stated no reason"),
"a blank reason was quoted as one: {message}"
);
}
#[test]
fn a_monitor_may_requeue_only_a_park_it_made_itself() {
let disk = "a third very large build would fill the disk this host has 8G left on";
let parked = |by: Author, reason: Option<&str>| Frontier {
parks: [("build".to_string(), Park::of(by, reason))]
.into_iter()
.collect(),
..Frontier::default()
};
let requeue = Command::Requeue {
id: "build".into(),
amend: None,
};
let graph = || {
let mut node = agent("build", &[]);
node.parked = true;
graph_of(vec![node])
};
let message = super::compile(
&mut graph(),
&parked(Author::Planner, Some(disk)),
Author::Monitor,
&requeue,
)
.unwrap_err()
.to_string();
assert!(
message.contains("parked by the planner") && message.contains(disk),
"{message}"
);
assert!(
message.contains("surface it to the planner"),
"the refusal does not say what to do instead: {message}"
);
let message = super::compile(
&mut graph(),
&parked(Author::Planner, None),
Author::Monitor,
&requeue,
)
.unwrap_err()
.to_string();
assert!(message.contains("stated no reason"), "{message}");
let message = super::compile(
&mut graph(),
&Frontier::default(),
Author::Monitor,
&requeue,
)
.unwrap_err()
.to_string();
assert!(message.contains("parked by the planner"), "{message}");
let mut live = graph();
super::compile(
&mut live,
&parked(Author::Monitor, Some("it was plainly redundant")),
Author::Monitor,
&requeue,
)
.expect("the monitor may requeue its own park");
assert!(!live.get("build").expect("build").parked);
let mut live = graph();
super::compile(
&mut live,
&parked(Author::Planner, Some(disk)),
Author::Planner,
&requeue,
)
.expect("the planner may requeue any park");
assert!(!live.get("build").expect("build").parked);
}
#[test]
fn a_settle_moves_the_record_and_leaves_the_graph_exactly_as_it_was() {
let evidence = "the change merged at 3f9a1c2 while the dispatch was dying";
let mut graph = graph_of(vec![agent("publish", &[]), agent("announce", &["publish"])]);
let before = graph.clone();
let operations = compile(
&mut graph,
&frontier(&[("publish", NodeStatus::Running)]),
&Command::Settle {
id: "publish".into(),
outcome: crate::channel::SettleOutcome::Done,
evidence: evidence.into(),
},
)
.expect("a node the graph holds settles from evidence");
assert_eq!(
operations,
vec![Operation::SettledFromEvidence {
node: "publish".into(),
outcome: crate::channel::SettleOutcome::Done,
evidence: evidence.into(),
}]
);
assert_eq!(
graph.get("publish"),
before.get("publish"),
"the settled node's own definition moved"
);
assert_eq!(
graph.get("announce").map(|node| node.deps.clone()),
Some(vec!["publish".to_string()]),
"the dependent's edge was rewired"
);
let mut replayed = before.clone();
apply(&mut replayed, &operations[0]);
assert_eq!(replayed, before);
}
#[test]
fn a_settle_is_refused_without_evidence_a_node_or_a_state_left_to_settle() {
let settle = |id: &str, evidence: &str| Command::Settle {
id: id.into(),
outcome: crate::channel::SettleOutcome::Done,
evidence: evidence.into(),
};
let mut graph = graph_of(vec![agent("publish", &[])]);
let message = compile(
&mut graph,
&Frontier::default(),
&settle("publish", " \n "),
)
.unwrap_err()
.to_string();
assert!(message.contains("no evidence at all"), "{message}");
let message = compile(
&mut graph,
&Frontier::default(),
&settle("nowhere", "it merged"),
)
.unwrap_err()
.to_string();
assert!(
message.contains("no node 'nowhere'") && message.contains("publish"),
"the refusal does not name the nodes the run does have: {message}"
);
let message = compile(
&mut graph,
&frontier(&[("publish", NodeStatus::Done)]),
&settle("publish", "it merged"),
)
.unwrap_err()
.to_string();
assert!(
message.contains("already settled done")
&& message.contains("settles nothing")
&& message.contains("different"),
"the refusal does not say that a different outcome is accepted: {message}"
);
compile(
&mut graph,
&frontier(&[("publish", NodeStatus::Failed)]),
&settle("publish", "it merged at 3f9a1c2 after the dispatch died"),
)
.expect("a node the run recorded failed is exactly what a settle corrects");
let in_flight = Frontier {
in_flight: [(
"publish".to_string(),
LiveDispatch {
graph_run: Some("run-7".into()),
running_for_seconds: 90,
},
)]
.into_iter()
.collect(),
..Frontier::default()
};
let message = compile(&mut graph, &in_flight, &settle("publish", "it merged"))
.unwrap_err()
.to_string();
assert!(
message.contains("dispatch in flight") && message.contains("run-7"),
"{message}"
);
}
#[test]
fn a_ready_nodes_fields_change_in_place_through_park_and_requeue() {
let mut waiting = agent("consume", &["build"]);
waiting.adoption = Some(onevcs::releases::Adoption::Published);
let mut graph = graph_of(vec![
agent("build", &[]),
waiting,
agent("after", &["consume"]),
]);
let ready = frontier(&[("build", NodeStatus::Done)]);
compile(
&mut graph,
&ready,
&Command::Cancel {
id: "consume".into(),
reason: Some("it is waiting on a release nobody is going to cut".into()),
},
)
.expect("a ready node parks");
let mut amend = Map::new();
amend.insert("adoption".into(), json!("fast"));
compile(
&mut graph,
&ready,
&Command::Requeue {
id: "consume".into(),
amend: Some(amend),
},
)
.expect("the parked node returns with its field changed");
let moved = graph.get("consume").expect("consume");
assert_eq!(moved.adoption, Some(onevcs::releases::Adoption::Fast));
assert!(!moved.parked, "the node is still parked");
assert_eq!(moved.id, "consume", "the node was renamed");
assert_eq!(moved.deps, vec!["build".to_string()], "its lineage moved");
assert_eq!(
graph.get("after").map(|node| node.deps.clone()),
Some(vec!["consume".to_string()]),
"its dependent was rewired"
);
}
#[test]
fn requeue_returns_a_parked_node_and_refuses_to_rewrite_id_or_deps() {
let mut parked = agent("sweep", &[]);
parked.parked = true;
let mut graph = graph_of(vec![parked]);
for key in ["id", "deps"] {
let mut amend = Map::new();
amend.insert(key.to_string(), Value::String("other".into()));
let message = compile(
&mut graph,
&Frontier::default(),
&Command::Requeue {
id: "sweep".into(),
amend: Some(amend),
},
)
.unwrap_err()
.to_string();
assert!(message.contains("cannot amend"), "{message}");
}
let mut amend = Map::new();
amend.insert("max_turns".into(), Value::from(32));
let operations = compile(
&mut graph,
&Frontier::default(),
&Command::Requeue {
id: "sweep".into(),
amend: Some(amend),
},
)
.expect("a parked node requeues");
let node = graph.get("sweep").expect("sweep");
assert!(!node.parked);
assert_eq!(node.max_turns, Some(32));
assert!(matches!(
&operations[0],
Operation::NodeRequeued { amend: Some(_), .. }
));
}
#[test]
fn a_bare_requeue_records_no_amendment_at_all() {
let mut parked = agent("sweep", &[]);
parked.parked = true;
let mut graph = graph_of(vec![parked]);
let operations = compile(
&mut graph,
&Frontier::default(),
&Command::Requeue {
id: "sweep".into(),
amend: Some(Map::new()),
},
)
.expect("a bare requeue is legal");
assert!(matches!(
&operations[0],
Operation::NodeRequeued { amend: None, .. }
));
}
#[test]
fn requeue_refuses_an_unparked_or_unknown_node_and_a_malformed_amendment() {
let mut graph = graph_of(vec![agent("live", &[])]);
assert!(compile(
&mut graph,
&Frontier::default(),
&Command::Requeue {
id: "live".into(),
amend: None
}
)
.unwrap_err()
.to_string()
.contains("not parked"));
assert!(compile(
&mut graph,
&Frontier::default(),
&Command::Requeue {
id: "nowhere".into(),
amend: None
}
)
.unwrap_err()
.to_string()
.contains("no node"));
let mut parked = agent("sweep", &[]);
parked.parked = true;
let mut graph = graph_of(vec![parked]);
let mut amend = Map::new();
amend.insert("max_turns".into(), Value::String("lots".into()));
let message = compile(
&mut graph,
&Frontier::default(),
&Command::Requeue {
id: "sweep".into(),
amend: Some(amend),
},
)
.unwrap_err()
.to_string();
assert!(message.contains("is invalid"), "{message}");
}
#[test]
fn requeue_refuses_a_retired_field_by_name_and_says_where_the_bar_goes() {
let mut parked = agent("contract", &[]);
parked.parked = true;
let mut graph = graph_of(vec![parked]);
let mut amend = Map::new();
amend.insert(
"done_when".into(),
Value::String("the gate is green".into()),
);
let message = compile(
&mut graph,
&Frontier::default(),
&Command::Requeue {
id: "contract".into(),
amend: Some(amend),
},
)
.unwrap_err()
.to_string();
assert!(message.contains("'contract':"), "{message}");
assert!(
message.contains("`done_when` is no longer a plan field"),
"{message}"
);
assert!(
message.contains("`## Acceptance criteria` section of its own task"),
"the refusal does not say where the bar goes: {message}"
);
assert!(!message.contains("unknown field"), "{message}");
}
#[test]
fn attest_needs_a_ready_waiting_action_that_is_not_already_done() {
let graph_node = Node {
id: "approve".into(),
kind: NodeKind::Human,
task: Some("approve it".into()),
..Node::default()
};
let mut graph = graph_of(vec![graph_node]);
assert!(compile(
&mut graph,
&Frontier::default(),
&Command::Attest {
reference: "approve".into()
}
)
.unwrap_err()
.to_string()
.contains("not a ready, waiting human action"));
let waiting = frontier(&[("approve", NodeStatus::Waiting)]);
compile(
&mut graph,
&waiting,
&Command::Attest {
reference: "approve".into(),
},
)
.expect("a waiting action attests");
let mut already = waiting.clone();
already.attestations.insert("approve".into());
assert!(compile(
&mut graph,
&already,
&Command::Attest {
reference: "approve".into()
}
)
.unwrap_err()
.to_string()
.contains("already attested"));
}
#[test]
fn attest_takes_a_node_that_settled_failed_and_names_both_references_when_it_refuses() {
let mut graph = graph_of(vec![agent("build", &[]), agent("ship", &["build"])]);
let failed = frontier(&[("build", NodeStatus::Failed)]);
assert_eq!(
compile(
&mut graph,
&failed,
&Command::Attest {
reference: "build".into(),
},
)
.expect("a failed node attests"),
vec![Operation::HumanAttested {
node: "build".into()
}]
);
let mut again = failed.clone();
again.attestations.insert("build".into());
assert!(compile(
&mut graph,
&again,
&Command::Attest {
reference: "build".into()
}
)
.unwrap_err()
.to_string()
.contains("already attested"));
let message = compile(
&mut graph,
&frontier(&[("build", NodeStatus::Running)]),
&Command::Attest {
reference: "build".into(),
},
)
.unwrap_err()
.to_string();
assert!(
message.contains("not a ready, waiting human action"),
"{message}"
);
assert!(message.contains("node that settled failed"), "{message}");
}
#[test]
fn a_note_is_judged_against_the_graph_and_the_two_fields_that_reach_nobody() {
let mut graph = graph_of(vec![agent("build", &[])]);
let operations = compile(
&mut graph,
&Frontier::default(),
¬e_for("build", "the fixture moved"),
)
.expect("a live node takes a note");
assert!(
operations.is_empty(),
"the compile recorded a note the delivery had not answered yet: {operations:?}"
);
assert_eq!(
graph.get("build").expect("build").context,
None,
"the compile carried a note forward before any delivery had failed"
);
for (frontier_state, command, expected) in [
(Frontier::default(), note_for("nowhere", "hello"), "no node"),
(
Frontier::default(),
note_with("build", "nowhere to go", Deliver::Next, false),
"reaches nobody whatever the run does",
),
(
frontier(&[("build", NodeStatus::Done)]),
note_with("build", "too late", Deliver::Next, true),
"it has settled done",
),
] {
let message = compile(&mut graph, &frontier_state, &command)
.unwrap_err()
.to_string();
assert!(message.contains(expected), "{message:?} lacks {expected:?}");
}
}
#[test]
fn a_carried_note_replays_onto_the_node_and_a_taken_one_leaves_nothing() {
let carried = Operation::NoteDelivered {
node: "build".into(),
addressee: crate::note::Addressee::Worker,
text: "the fixture moved".parse().expect("a usable note"),
criterion: None,
reached: crate::note::Reached::Carried,
};
let mut graph = graph_of(vec![agent("build", &[])]);
apply(&mut graph, &carried);
assert_eq!(
graph.get("build").expect("build").context.as_deref(),
Some("the fixture moved")
);
let taken = Operation::NoteDelivered {
node: "build".into(),
addressee: crate::note::Addressee::Worker,
text: "the fixture moved".parse().expect("a usable note"),
criterion: None,
reached: crate::note::Reached::Worker,
};
let mut untouched = graph_of(vec![agent("build", &[])]);
apply(&mut untouched, &taken);
assert_eq!(
untouched.get("build").expect("build").context,
None,
"a note a running turn read was also queued for the next dispatch"
);
}
#[test]
fn a_context_operation_from_before_this_field_replays_as_deferred() {
let operation: Operation = serde_json::from_value(serde_json::json!({
"kind": "context-added",
"node": "build",
"note": "the fixture moved",
}))
.expect("an operation without a delivery still parses");
let mut graph = graph_of(vec![agent("build", &[])]);
apply(&mut graph, &operation);
assert_eq!(
graph.get("build").expect("build").context.as_deref(),
Some("the fixture moved"),
"an older record stopped attaching its note"
);
}
#[test]
fn amend_binds_the_node_and_a_second_amendment_replaces_the_first() {
let mut graph = graph_of(vec![agent("build", &[])]);
let amend = |text: &str| Command::Amend {
id: "build".into(),
text: text.into(),
};
let first = compile(
&mut graph,
&Frontier::default(),
&amend("leave the comments"),
)
.expect("a live node takes an amendment");
assert_eq!(
graph.get("build").expect("build").amendment.as_deref(),
Some("leave the comments")
);
assert!(
graph
.get("build")
.expect("build")
.rendered_task()
.contains("leave the comments"),
"the amendment is not part of the effective task"
);
assert!(
matches!(&first[0], Operation::TaskAmended { node, text }
if node == "build" && text == "leave the comments"),
"{first:?}"
);
let second = compile(
&mut graph,
&frontier(&[("build", NodeStatus::Running)]),
&amend("restore the comments after all"),
)
.expect("a running node takes one too");
let effective = graph.get("build").expect("build").rendered_task();
assert!(
effective.contains("restore the comments after all"),
"{effective}"
);
assert!(
!effective.contains("leave the comments"),
"the replaced ruling is still binding the judge beside its own correction: {effective}"
);
let mut replayed = graph_of(vec![agent("build", &[])]);
for operation in first.iter().chain(second.iter()) {
apply(&mut replayed, operation);
}
assert_eq!(
replayed.get("build").expect("build").amendment.as_deref(),
Some("restore the comments after all")
);
}
#[test]
fn amend_refuses_an_unknown_node_a_settled_one_and_a_blank_ruling() {
let mut graph = graph_of(vec![agent("build", &[])]);
let before = graph.clone();
for (frontier_state, id, text, expected) in [
(
Frontier::default(),
"nowhere",
"a ruling",
"no node 'nowhere'",
),
(
frontier(&[("build", NodeStatus::Done)]),
"build",
"a ruling",
"settled done",
),
(Frontier::default(), "build", " \n", "cannot be blank"),
] {
let message = compile(
&mut graph,
&frontier_state,
&Command::Amend {
id: id.into(),
text: text.into(),
},
)
.unwrap_err()
.to_string();
assert!(message.contains(expected), "{message:?} lacks {expected:?}");
}
assert_eq!(graph, before, "a refused amendment changed the graph");
}
#[cfg(unix)]
fn scratch(name: &str) -> std::path::PathBuf {
let dir =
std::env::temp_dir().join(format!("onepipeline-edits-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("a scratch root");
dir
}
#[cfg(unix)]
fn validator(dir: &std::path::Path, name: &str, body: &str) -> String {
use std::os::unix::fs::PermissionsExt;
let path = dir.join(name);
std::fs::write(&path, format!("#!/bin/sh\n{body}"))
.expect("the validator program is written");
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))
.expect("it is runnable");
path.to_string_lossy().into_owned()
}
fn validated_by(command: &str) -> Frontier {
Frontier {
node_validator: Some(command.to_string()),
..Frontier::default()
}
}
#[test]
#[cfg(unix)]
fn every_op_that_introduces_or_changes_a_task_is_offered_to_the_validator() {
let dir = scratch("offered");
let seen = dir.join("seen.jsonl");
let accept = validator(
&dir,
"accept.sh",
&format!("cat >> {0}\nprintf '\\n' >> {0}\nexit 0\n", seen.display()),
);
let frontier_state = Frontier {
recorded: [("build".to_string(), NodeStatus::Failed)]
.into_iter()
.collect(),
..validated_by(&accept)
};
let mut graph = graph_of(vec![agent("build", &[]), agent("docs", &[])]);
for command in [
Command::Add {
node: agent("fresh", &[]),
},
Command::Retry {
id: "build".into(),
node: agent("build-2", &[]),
},
Command::Amend {
id: "docs".into(),
text: "the ruling".into(),
},
Command::Cancel {
id: "docs".into(),
reason: None,
},
Command::Requeue {
id: "docs".into(),
amend: Some(
serde_json::json!({"task": "## What\nsomething else"})
.as_object()
.expect("an object")
.clone(),
),
},
Command::Cancel {
id: "fresh".into(),
reason: None,
},
Command::Requeue {
id: "fresh".into(),
amend: Some(
serde_json::json!({"max_turns": 32})
.as_object()
.expect("an object")
.clone(),
),
},
note_for("docs", "a note"),
] {
compile(&mut graph, &frontier_state, &command)
.unwrap_or_else(|e| panic!("the accepting validator refused {command:?}: {e}"));
}
let offered: Vec<String> = std::fs::read_to_string(&seen)
.expect("the validator recorded what it was given")
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| {
let node: Node = serde_json::from_str(line)
.unwrap_or_else(|e| panic!("the node crossed as a plan node: {e} in {line}"));
node.id
})
.collect();
assert_eq!(
offered,
vec!["fresh", "build-2", "docs", "docs"],
"the validator was offered the wrong edits"
);
let amended: Node = serde_json::from_str(
std::fs::read_to_string(&seen)
.expect("readable")
.lines()
.filter(|line| !line.trim().is_empty())
.nth(2)
.expect("the amend's own offering, third of the four"),
)
.expect("it parses");
assert_eq!(amended.amendment.as_deref(), Some("the ruling"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[cfg(unix)]
fn a_validators_stderr_is_bounded_and_stripped_before_it_becomes_a_refusal() {
let dir = scratch("loud");
let loud = validator(
&dir,
"loud.sh",
"cat > /dev/null\n\
printf '\\033[31mrule 3\\033[0m failed\\n' >&2\n\
head -c 100000 /dev/zero | tr '\\0' 'x' >&2\n\
exit 1\n",
);
let mut graph = graph_of(vec![agent("build", &[])]);
let refusal = compile(
&mut graph,
&validated_by(&loud),
&Command::Add {
node: agent("fresh", &[]),
},
)
.expect_err("the validator refused it")
.to_string();
assert!(refusal.contains("rule 3"), "the words were lost: {refusal}");
assert!(
!refusal.contains('\u{1b}') && !refusal.contains('\n'),
"a validator wrote control characters into a refusal: {refusal:?}"
);
assert!(
refusal.len() <= MAX_HOOK_STDERR as usize + 200,
"an unbounded validator wrote {} bytes into a refusal",
refusal.len()
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[cfg(unix)]
fn an_edit_the_validator_refuses_carries_its_own_words_and_changes_nothing() {
let dir = scratch("refused");
let refuse = validator(
&dir,
"refuse.sh",
"cat > /dev/null\n\
echo \"acceptance criterion 3 names a procedure, not a property\" >&2\n\
exit 1\n",
);
let mut graph = graph_of(vec![agent("build", &[])]);
let before = graph.clone();
let refusal = compile(
&mut graph,
&validated_by(&refuse),
&Command::Add {
node: agent("fresh", &[]),
},
)
.expect_err("the validator refused it")
.to_string();
assert!(
refusal.contains("acceptance criterion 3 names a procedure, not a property"),
"the refusal does not carry the validator's own words: {refusal}"
);
assert!(refusal.contains("fresh"), "{refusal}");
assert_eq!(graph, before, "a refused edit reached the graph");
let silent = validator(&dir, "silent.sh", "exit 3\n");
let said = compile(
&mut graph,
&validated_by(&silent),
&Command::Add {
node: agent("fresh", &[]),
},
)
.expect_err("it refused")
.to_string();
assert!(said.contains("exited 3"), "{said}");
assert_eq!(graph, before);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn no_validator_changes_nothing_and_an_unstartable_one_fails_closed() {
let mut graph = graph_of(vec![agent("build", &[])]);
compile(
&mut graph,
&Frontier::default(),
&Command::Add {
node: agent("fresh", &[]),
},
)
.expect("a launch that named no validator adds a node as it always did");
assert!(graph.contains("fresh"));
let before = graph.clone();
let missing = std::env::temp_dir().join("onepipeline-no-such-node-validator");
let refusal = compile(
&mut graph,
&validated_by(&missing.to_string_lossy()),
&Command::Add {
node: agent("second", &[]),
},
)
.expect_err("a validator that cannot be started refuses the edit")
.to_string();
assert!(
refusal.contains("could not be started") && refusal.contains("checked by nothing"),
"{refusal}"
);
assert_eq!(graph, before, "an unchecked node reached the graph");
}
#[test]
#[cfg(unix)]
fn the_reviewer_is_handed_every_changed_node_the_edited_plan_and_the_goal() {
let dir = scratch("reviewed");
let seen = dir.join("envelope.json");
let reviewer = validator(
&dir,
"review.sh",
&format!("cat > {}\nexit 0\n", seen.display()),
);
let launched_with = Plan {
schema_version: PLAN_SCHEMA_VERSION,
goal: Some(crate::plan::Goal {
text: "ship the coverage floor".into(),
}),
name: Some("cover".into()),
concurrency: 4,
tasks: vec![agent("build", &[])],
};
let mut graph = Graph::from_plan(&launched_with);
let commands = vec![
Command::Add {
node: agent("fresh", &["build"]),
},
Command::Amend {
id: "build".into(),
text: "the ruling".into(),
},
Command::Cancel {
id: "fresh".into(),
reason: None,
},
];
for command in &commands {
compile(&mut graph, &Frontier::default(), command).expect("each command compiles");
}
offer_envelope_to_reviewer(Some(&reviewer), &commands, &graph, Some(&launched_with))
.expect("the reviewer accepted the envelope");
let document: Value = serde_json::from_str(
&std::fs::read_to_string(&seen).expect("the reviewer was handed a document"),
)
.expect("it is JSON");
assert_eq!(
document["goal"],
serde_json::json!("ship the coverage floor")
);
assert_eq!(
document["changes"]
.as_array()
.expect("the changes are a list")
.iter()
.map(|change| (
change["op"].as_str().expect("an op").to_string(),
change["node"]["id"].as_str().expect("a node").to_string()
))
.collect::<Vec<_>>(),
vec![
("add".to_string(), "fresh".to_string()),
("amend".to_string(), "build".to_string())
],
"{document}"
);
assert_eq!(
document["changes"][0]["node"]["deps"],
serde_json::json!(["build"])
);
assert_eq!(
document["changes"][1]["node"]["amendment"],
serde_json::json!("the ruling")
);
assert_eq!(document["plan"]["name"], serde_json::json!("cover"));
assert_eq!(
document["plan"]["tasks"]
.as_array()
.expect("the plan carries its tasks")
.iter()
.map(|task| task["id"].as_str().expect("an id").to_string())
.collect::<Vec<_>>(),
vec!["build".to_string(), "fresh".to_string()],
"{document}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[cfg(unix)]
fn a_refused_envelope_carries_the_reviewers_words_and_names_what_it_objected_to() {
let dir = scratch("reviewer-refuses");
let objection = "it repeats the contract seam node 'build' already owns";
let reviewer = validator(
&dir,
"refuse.sh",
&format!(
"cat > /dev/null\nprintf '%s\\n%s\\n' \"{OBJECTION_PREFIX} fresh\" \
\"{objection}\" >&2\nexit 1\n"
),
);
let commands = vec![
Command::Add {
node: agent("fresh", &[]),
},
Command::Drop {
id: "build".into(),
dependents: Dependents::Detach,
},
];
let graph = graph_of(vec![agent("fresh", &[])]);
let refusal = offer_envelope_to_reviewer(Some(&reviewer), &commands, &graph, None)
.expect_err("the reviewer refused the envelope")
.to_string();
assert!(
refusal.contains(objection),
"the words were lost: {refusal}"
);
assert!(
refusal.contains("none of its edits were applied"),
"{refusal}"
);
assert!(
refusal.contains("refused this envelope over node 'fresh',"),
"{refusal}"
);
assert!(
refusal.contains("add 'fresh'") && refusal.contains("drop 'build'"),
"{refusal}"
);
assert!(!refusal.contains(OBJECTION_PREFIX), "{refusal}");
let silent = validator(&dir, "silent.sh", "cat > /dev/null\nexit 4\n");
let said = offer_envelope_to_reviewer(Some(&silent), &commands, &graph, None)
.expect_err("it refused")
.to_string();
assert!(said.contains("exited 4"), "{said}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[cfg(unix)]
fn a_refusal_tells_a_declared_node_from_a_stray_name_and_from_no_declaration() {
let dir = scratch("reviewer-objections");
let commands = vec![
Command::Add {
node: agent("fresh", &[]),
},
Command::Drop {
id: "build".into(),
dependents: Dependents::Detach,
},
];
let graph = graph_of(vec![agent("fresh", &[])]);
for (which, declares, expected) in [
(
"nothing at all",
vec![],
"without declaring the node it objected to",
),
(
"only a blank declaration",
vec![OBJECTION_PREFIX.to_string()],
"without declaring the node it objected to",
),
(
"one node the envelope changes",
vec![format!("{OBJECTION_PREFIX} fresh")],
"over node 'fresh',",
),
(
"two of them, its own way",
vec![
" Objection: build ".to_string(),
format!("{OBJECTION_PREFIX} fresh"),
],
"over nodes 'build', 'fresh',",
),
(
"a name the envelope does not carry",
vec![format!("{OBJECTION_PREFIX} ghost")],
"over the name 'ghost', which no node this envelope changes goes by",
),
(
"one of each",
vec![
format!("{OBJECTION_PREFIX} fresh"),
format!("{OBJECTION_PREFIX} ghost"),
],
"over node 'fresh', and over the name 'ghost', which no node this envelope \
changes goes by",
),
] {
let lines = declares
.iter()
.map(|line| format!("printf '%s\\n' \"{line}\" >&2\n"))
.collect::<String>();
let reviewer = validator(
&dir,
&format!("refuse-{}.sh", which.replace(' ', "-")),
&format!("cat > /dev/null\n{lines}printf 'the seam is wrong\\n' >&2\nexit 1\n"),
);
let refusal = offer_envelope_to_reviewer(Some(&reviewer), &commands, &graph, None)
.expect_err("the reviewer refused the envelope")
.to_string();
assert!(
refusal.contains(expected),
"a reviewer declaring {which} was reported as {refusal}"
);
assert!(refusal.contains("the seam is wrong"), "{refusal}");
assert!(
!refusal.to_lowercase().contains(OBJECTION_PREFIX),
"{refusal}"
);
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn no_reviewer_changes_nothing_and_an_unstartable_one_fails_closed() {
let commands = vec![Command::Add {
node: agent("fresh", &[]),
}];
let graph = graph_of(vec![agent("fresh", &[])]);
for named in [None, Some(" ")] {
offer_envelope_to_reviewer(named, &commands, &graph, None)
.expect("a launch that named no reviewer commits the envelope as it always did");
}
let missing = std::env::temp_dir().join("onepipeline-no-such-envelope-reviewer");
let refusal =
offer_envelope_to_reviewer(Some(&missing.to_string_lossy()), &commands, &graph, None)
.expect_err("a reviewer that cannot be started refuses the envelope")
.to_string();
assert!(
refusal.contains("could not be started") && refusal.contains("reviewed by nothing"),
"{refusal}"
);
}
#[test]
fn complete_journals_a_reason_without_touching_the_graph() {
let mut graph = graph_of(vec![agent("a", &[])]);
let before = graph.clone();
let operations = compile(
&mut graph,
&Frontier::default(),
&Command::Complete {
reason: "publication verified".into(),
},
)
.expect("complete is always legal");
assert_eq!(graph, before);
assert!(matches!(
&operations[0],
Operation::CompletionRequested { reason } if reason == "publication verified"
));
}
#[test]
fn every_compiled_operation_replays_onto_the_same_graph() {
let mut live = graph_of(vec![agent("a", &[]), agent("b", &["a"])]);
let mut replayed = live.clone();
let frontier_state = frontier(&[("a", NodeStatus::Failed)]);
for command in [
Command::Add {
node: agent("c", &["a"]),
},
note_for("b", "a note"),
Command::Retry {
id: "a".into(),
node: agent("a-2", &[]),
},
Command::Cancel {
id: "c".into(),
reason: None,
},
Command::Requeue {
id: "c".into(),
amend: None,
},
Command::Amend {
id: "b".into(),
text: "the ruling".into(),
},
Command::Drop {
id: "c".into(),
dependents: Dependents::Detach,
},
] {
let operations =
compile(&mut live, &frontier_state, &command).expect("each command is legal");
for operation in &operations {
apply(&mut replayed, operation);
}
}
assert_eq!(replayed, live, "replay did not reconstruct the live graph");
}
#[test]
fn replaying_an_operation_against_a_graph_that_lost_its_node_is_a_no_op() {
let mut graph = graph_of(vec![agent("a", &[])]);
for operation in [
Operation::Reparent {
node: "gone".into(),
from: vec![],
to: vec!["a".into()],
},
Operation::EdgeAdded {
from: "a".into(),
to: "gone".into(),
target: None,
},
Operation::EdgeRemoved {
from: "a".into(),
to: "gone".into(),
},
Operation::NodeParked {
node: "gone".into(),
by: Author::Planner,
reason: None,
},
Operation::TaskAmended {
node: "gone".into(),
text: "the ruling".into(),
},
Operation::NodeRequeued {
node: "gone".into(),
amend: None,
},
Operation::ContextAdded {
node: "gone".into(),
note: "n".into(),
delivery: Delivery::Deferred,
},
Operation::HumanAttested {
node: "gone".into(),
},
Operation::CompletionRequested { reason: "r".into() },
] {
apply(&mut graph, &operation);
}
assert_eq!(graph.len(), 1);
}
#[test]
fn a_retry_rekeys_a_dependents_consumes_onto_the_replacement() {
let engine = Node {
repo: Some("owner/engine".into()),
..agent("engine-run-reading", &[])
};
let mut adopt = Node {
adoption: Some(onevcs::Adoption::Published),
..agent("ao-adopt", &["engine-run-reading"])
};
adopt
.consumes
.insert("engine-run-reading".into(), target("crate"));
let plain = agent("audit", &["engine-run-reading"]);
let mut graph = graph_of(vec![engine, adopt, plain]);
let stated = targets_in(&graph);
compile(
&mut graph,
&frontier(&[("engine-run-reading", NodeStatus::Failed)]),
&Command::Retry {
id: "engine-run-reading".into(),
node: Node {
repo: Some("owner/engine".into()),
..agent("engine-run-reading-2", &[])
},
},
)
.expect("a node another node consumes retries");
let adopt = graph.get("ao-adopt").expect("the dependent survived");
assert_eq!(adopt.deps, vec!["engine-run-reading-2".to_string()]);
assert_eq!(
adopt.consumes,
BTreeMap::from([("engine-run-reading-2".to_string(), target("crate"))]),
"the target did not follow the edge onto the replacement"
);
assert!(
graph
.get("audit")
.expect("the other dependent")
.consumes
.is_empty(),
"a dependent that stated no target was given one"
);
assert_eq!(
targets_in(&graph)
.into_iter()
.map(|(_, _, target)| target)
.collect::<BTreeSet<_>>(),
stated
.into_iter()
.map(|(_, _, target)| target)
.collect::<BTreeSet<_>>(),
"the retry altered a release target, or invented one"
);
}
#[test]
fn a_replacement_inherits_the_consumes_it_inherits_deps_with() {
let stated = |id: &str| {
let mut node = agent(id, &["engine", "packager"]);
node.consumes.insert("engine".into(), target("crate"));
node.consumes.insert("packager".into(), target("wheel"));
node
};
let base = || {
graph_of(vec![
agent("engine", &[]),
agent("packager", &[]),
stated("build"),
])
};
let failed = frontier(&[("build", NodeStatus::Failed)]);
let mut graph = base();
compile(
&mut graph,
&failed,
&Command::Retry {
id: "build".into(),
node: agent("build-2", &[]),
},
)
.expect("a replacement stating no deps inherits them");
let inherited = graph.get("build-2").expect("the replacement");
assert_eq!(
inherited.deps,
vec!["engine".to_string(), "packager".to_string()]
);
assert_eq!(inherited.consumes, stated("build").consumes);
let mut graph = base();
let mut own = agent("build-2", &["packager"]);
own.consumes.insert("packager".into(), target("crate"));
compile(
&mut graph,
&failed,
&Command::Retry {
id: "build".into(),
node: own,
},
)
.expect("a replacement stating its own deps keeps them");
let stated_its_own = graph.get("build-2").expect("the replacement");
assert_eq!(stated_its_own.deps, vec!["packager".to_string()]);
assert_eq!(
stated_its_own.consumes,
BTreeMap::from([("packager".to_string(), target("crate"))]),
"the replacement was given the superseded node's targets over its own"
);
}
#[test]
fn dropping_a_consumed_node_takes_its_dependents_target_with_the_edge() {
let mut consumer = agent("ship", &["engine", "packager"]);
consumer.consumes.insert("engine".into(), target("crate"));
consumer.consumes.insert("packager".into(), target("wheel"));
let mut graph = graph_of(vec![agent("engine", &[]), agent("packager", &[]), consumer]);
let stated = targets_in(&graph);
compile(
&mut graph,
&Frontier::default(),
&Command::Drop {
id: "engine".into(),
dependents: Dependents::Detach,
},
)
.expect("a node another node consumes detaches");
let ship = graph.get("ship").expect("the dependent survived");
assert_eq!(ship.deps, vec!["packager".to_string()]);
assert_eq!(
ship.consumes,
BTreeMap::from([("packager".to_string(), target("wheel"))]),
"the dropped dependency's target outlived the dependency"
);
assert!(
targets_in(&graph).is_subset(&stated),
"the drop invented or altered a release target"
);
}
#[test]
fn reparenting_away_from_a_consumed_dep_drops_only_that_target() {
let mut consumer = agent("ship", &["engine", "packager"]);
consumer.consumes.insert("engine".into(), target("crate"));
consumer.consumes.insert("packager".into(), target("wheel"));
let mut graph = graph_of(vec![
agent("engine", &[]),
agent("packager", &[]),
agent("docs", &[]),
consumer,
]);
let stated = targets_in(&graph);
compile(
&mut graph,
&Frontier::default(),
&Command::Reparent {
id: "ship".into(),
deps: vec!["packager".into(), "docs".into()],
},
)
.expect("a node reparents away from a dep it consumes");
let ship = graph.get("ship").expect("the reparented node");
assert_eq!(ship.deps, vec!["packager".to_string(), "docs".to_string()]);
assert_eq!(
ship.consumes,
BTreeMap::from([("packager".to_string(), target("wheel"))]),
"the surviving dep's target moved, or the removed dep's target stayed"
);
assert!(
targets_in(&graph).is_subset(&stated),
"the reparent invented or altered a release target"
);
}
#[test]
fn neither_refusal_this_change_works_around_is_relaxed() {
let mut orphaned = agent("ship", &["engine"]);
orphaned.consumes.insert("packager".into(), target("crate"));
let refusal = graph::validate_node(&orphaned)
.expect_err("a target keyed on something that is not a dep is refused")
.to_string();
assert!(
refusal.contains("`consumes` names 'packager'")
&& refusal.contains("not one of this node's deps"),
"{refusal}"
);
let mut parked = agent("ship", &["engine"]);
parked.parked = true;
let mut graph = graph_of(vec![agent("engine", &[]), parked]);
for key in ["id", "deps"] {
let mut amend = Map::new();
amend.insert(key.to_string(), Value::String("other".into()));
let message = compile(
&mut graph,
&Frontier::default(),
&Command::Requeue {
id: "ship".into(),
amend: Some(amend),
},
)
.unwrap_err()
.to_string();
assert!(message.contains("cannot amend"), "{message}");
}
}
#[test]
fn a_record_written_before_an_edge_carried_a_target_replays_unchanged() {
let mut ship = agent("ship", &["engine", "packager"]);
ship.consumes.insert("engine".into(), target("crate"));
ship.consumes.insert("packager".into(), target("wheel"));
let mut graph = graph_of(vec![
agent("engine", &[]),
agent("packager", &[]),
agent("docs", &[]),
ship,
]);
for operation in [
Operation::EdgeRemoved {
from: "engine".into(),
to: "ship".into(),
},
Operation::EdgeRemoved {
from: "packager".into(),
to: "ship".into(),
},
Operation::EdgeAdded {
from: "packager".into(),
to: "ship".into(),
target: None,
},
Operation::EdgeAdded {
from: "docs".into(),
to: "ship".into(),
target: None,
},
Operation::Reparent {
node: "ship".into(),
from: vec!["engine".into(), "packager".into()],
to: vec!["packager".into(), "docs".into()],
},
] {
apply(&mut graph, &operation);
}
let ship = graph.get("ship").expect("the reparented node");
assert_eq!(ship.deps, vec!["packager".to_string(), "docs".to_string()]);
assert_eq!(
ship.consumes,
BTreeMap::from([("packager".to_string(), target("wheel"))]),
"replaying an older record lost a target it has no way to restore"
);
for operation in [
Operation::EdgeRemoved {
from: "docs".into(),
to: "ship".into(),
},
Operation::NodeDropped {
node: "docs".into(),
dependents: Dependents::Detach,
},
Operation::EdgeRemoved {
from: "packager".into(),
to: "ship".into(),
},
Operation::EdgeAdded {
from: "packager-2".into(),
to: "ship".into(),
target: None,
},
Operation::NodeDropped {
node: "packager".into(),
dependents: Dependents::Detach,
},
] {
apply(&mut graph, &operation);
}
let ship = graph.get("ship").expect("the node both edits moved");
assert_eq!(ship.deps, vec!["packager-2".to_string()]);
assert!(
ship.consumes.is_empty(),
"replaying older records left a target keyed on a node that has gone: {:?}",
ship.consumes
);
}
#[test]
fn an_added_edge_is_not_duplicated_on_replay() {
let mut graph = graph_of(vec![agent("a", &[]), agent("b", &["a"])]);
apply(
&mut graph,
&Operation::EdgeAdded {
from: "a".into(),
to: "b".into(),
target: None,
},
);
assert_eq!(graph.get("b").expect("b").deps, vec!["a".to_string()]);
}
}