use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use crate::channel::{Command, Dependents};
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,
},
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,
},
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,
},
TaskAmended {
node: String,
text: String,
},
ContextAdded {
node: String,
note: String,
#[serde(default)]
delivery: Delivery,
},
}
#[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 in_flight: BTreeMap<String, LiveDispatch>,
pub node_validator: Option<String>,
}
#[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,
command: &Command,
) -> Result<Vec<Operation>> {
compile_with(graph, frontier, command, Delivery::Deferred)
}
pub fn compile_with(
graph: &mut Graph,
frontier: &Frontier,
command: &Command,
delivery: Delivery,
) -> Result<Vec<Operation>> {
let mut candidate = graph.clone();
let operations = compile_into(&mut candidate, frontier, command, delivery)?;
if !matches!(
command,
Command::Complete { .. } | Command::Attest { .. } | Command::Finding { .. }
) {
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)
}
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_VALIDATOR_STDERR: u64 = crate::event::MAX_PAYLOAD_TEXT_BYTES as u64;
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 mut child = std::process::Command::new(validator)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(|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
))
})?;
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_VALIDATOR_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(|e| {
refuse(format!(
"{op}: the node validator '{validator}' did not answer for node '{}' ({e}), so the \
edit was not applied",
node.id
))
})?;
if status.success() {
return Ok(());
}
let said = crate::views::one_line(&String::from_utf8_lossy(&stderr))
.trim()
.to_string();
Err(refuse(format!(
"{op}: the node validator refused node '{}': {}",
node.id,
match said.is_empty() {
true => format!(
"it exited {} and said nothing on stderr",
status
.code()
.map_or_else(|| "without a status".to_string(), |code| code.to_string())
),
false => said,
}
)))
}
fn compile_into(
graph: &mut Graph,
frontier: &Frontier,
command: &Command,
delivery: Delivery,
) -> 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 } => compile_cancel(graph, frontier, id),
Command::Requeue { id, amend } => compile_requeue(graph, frontier, id, amend.as_ref()),
Command::Attest { reference } => compile_attest(frontier, reference),
Command::Complete { reason } => Ok(vec![Operation::CompletionRequested {
reason: reason.clone(),
}]),
Command::Context { id, note, .. } => compile_context(graph, frontier, id, note, delivery),
Command::Amend { id, text } => compile_amend(graph, frontier, id, text),
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(),
});
}
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 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(),
}));
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();
}
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);
}
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();
}
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(),
});
}
graph.insert(replacement.clone());
for dependent in &direct {
if let Some(node) = graph.get_mut(dependent) {
for dep in &mut node.deps {
if dep == id {
dep.clone_from(&replacement.id);
}
}
}
operations.push(Operation::EdgeRemoved {
from: id.to_string(),
to: dependent.clone(),
});
operations.push(Operation::EdgeAdded {
from: replacement.id.clone(),
to: dependent.clone(),
});
}
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, id: &str) -> Result<Vec<Operation>> {
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(),
}])
}
fn compile_requeue(
graph: &mut Graph,
frontier: &Frontier,
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")));
}
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(),
}])
}
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_context(
graph: &mut Graph,
frontier: &Frontier,
id: &str,
note: &str,
delivery: Delivery,
) -> Result<Vec<Operation>> {
if !graph.contains(id) {
return Err(refuse(format!("context: no node '{id}'")));
}
if note.trim().is_empty() {
return Err(refuse("context: the note cannot be empty"));
}
if frontier.recorded.get(id) == Some(&NodeStatus::Done) {
return Err(refuse(format!(
"context: node '{id}' has settled done, so nothing will read the note"
)));
}
if delivery == Delivery::Deferred {
if let Some(node) = graph.get_mut(id) {
node.context = Some(note.to_string());
}
}
Ok(vec![Operation::ContextAdded {
node: id.to_string(),
note: note.to_string(),
delivery,
}])
}
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);
}
Operation::Reparent { node, to, .. } => {
if let Some(node) = graph.get_mut(node) {
node.deps.clone_from(to);
}
}
Operation::EdgeRemoved { from, to } => {
if let Some(node) = graph.get_mut(to) {
node.deps.retain(|dep| dep != from);
}
}
Operation::EdgeAdded { from, to } => {
if let Some(node) = graph.get_mut(to) {
if !node.deps.contains(from) {
node.deps.push(from.clone());
}
}
}
Operation::NodeParked { node } => {
if let Some(node) = graph.get_mut(node) {
node.parked = true;
}
}
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::HumanAttested { .. }
| Operation::CompletionRequested { .. }
| Operation::FindingRaised { .. }
| Operation::RetryRequested { .. } => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::plan::{NodeKind, Plan, Resume, PLAN_SCHEMA_VERSION};
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 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 {
Command::Context {
id: id.into(),
note: note.into(),
deliver: crate::channel::Deliver::default(),
}
}
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() },
)
.expect("a pending node parks");
assert!(graph.get("sweep").expect("sweep").parked);
let message = compile(
&mut graph,
&Frontier::default(),
&Command::Cancel { id: "sweep".into() },
)
.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() },
)
.unwrap_err()
.to_string();
assert!(message.contains("not pending or running"), "{message}");
assert!(compile(
&mut graph,
&Frontier::default(),
&Command::Cancel {
id: "nowhere".into()
}
)
.unwrap_err()
.to_string()
.contains("no node"));
}
#[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 context_reaches_a_node_that_can_still_be_dispatched() {
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_eq!(
graph.get("build").expect("build").context.as_deref(),
Some("the fixture moved")
);
assert!(
matches!(
&operations[0],
Operation::ContextAdded {
delivery: Delivery::Deferred,
..
}
),
"a note nobody delivered is owed to the next dispatch: {operations:?}"
);
for (frontier_state, command, expected) in [
(
frontier(&[("build", NodeStatus::Done)]),
note_for("build", "too late"),
"settled done",
),
(
Frontier::default(),
note_for("build", " "),
"cannot be empty",
),
(Frontier::default(), note_for("nowhere", "hello"), "no node"),
] {
let message = compile(&mut graph, &frontier_state, &command)
.unwrap_err()
.to_string();
assert!(message.contains(expected), "{message:?} lacks {expected:?}");
}
}
#[test]
fn a_note_delivered_live_leaves_nothing_on_the_node_for_a_later_dispatch() {
let mut graph = graph_of(vec![agent("build", &[])]);
let operations = compile_with(
&mut graph,
&frontier(&[("build", NodeStatus::Running)]),
¬e_for("build", "the fixture moved"),
Delivery::Live,
)
.expect("a running node takes a note into its turn");
assert_eq!(
graph.get("build").expect("build").context,
None,
"a live note was also queued for the next dispatch"
);
assert!(
matches!(
&operations[0],
Operation::ContextAdded {
delivery: Delivery::Live,
note,
..
} if note == "the fixture moved"
),
"{operations:?}"
);
let mut replayed = graph_of(vec![agent("build", &[])]);
apply(&mut replayed, &operations[0]);
assert_eq!(replayed.get("build").expect("build").context, None);
}
#[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() },
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() },
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_VALIDATOR_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]
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() },
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(),
},
Operation::EdgeRemoved {
from: "a".into(),
to: "gone".into(),
},
Operation::NodeParked {
node: "gone".into(),
},
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 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(),
},
);
assert_eq!(graph.get("b").expect("b").deps, vec!["a".to_string()]);
}
}