use std::collections::{BTreeMap, BTreeSet};
use onevcs::provenance::SUBJECT_LIMIT;
use serde::{Deserialize, Serialize};
use crate::controls::NodeControls;
use crate::error::{Error, Result};
use crate::plan::{Node, NodeKind, Plan, Step};
use crate::refusal::Refusal;
pub const STEP_SEPARATOR: char = '/';
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct NodeRef(String);
impl NodeRef {
pub(crate) fn of(node: &Node) -> Option<Self> {
let id = node.id.trim();
(!id.is_empty()).then(|| Self(id.to_string()))
}
pub(crate) fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum NodeStatus {
Pending,
Ready,
Running,
Waiting,
Blocked,
Parked,
Cancelled,
Done,
CompleteDraft,
Failed,
Skipped,
}
impl NodeStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Pending => "pending",
Self::Ready => "ready",
Self::Running => "running",
Self::Waiting => "waiting",
Self::Blocked => "blocked",
Self::Parked => "parked",
Self::Cancelled => "cancelled",
Self::Done => "done",
Self::CompleteDraft => "complete-but-draft",
Self::Failed => "failed",
Self::Skipped => "skipped",
}
}
pub fn parse(text: &str) -> Option<Self> {
Some(match text {
"pending" => Self::Pending,
"ready" => Self::Ready,
"running" => Self::Running,
"waiting" => Self::Waiting,
"blocked" => Self::Blocked,
"parked" => Self::Parked,
"cancelled" => Self::Cancelled,
"done" => Self::Done,
"complete-but-draft" => Self::CompleteDraft,
"failed" => Self::Failed,
"skipped" => Self::Skipped,
_ => return None,
})
}
pub fn is_settled(self) -> bool {
matches!(
self,
Self::Done
| Self::Failed
| Self::Skipped
| Self::Waiting
| Self::Blocked
| Self::Parked
| Self::Cancelled
)
}
pub fn is_dispatchable(self) -> bool {
!matches!(self, Self::Done | Self::Parked)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Landing {
Landed,
Unlanded,
}
impl Landing {
pub fn as_str(self) -> &'static str {
match self {
Self::Landed => "landed",
Self::Unlanded => "unlanded",
}
}
pub fn parse(text: &str) -> Option<Self> {
match text {
"landed" => Some(Self::Landed),
"unlanded" => Some(Self::Unlanded),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum GraphState {
Complete,
Waiting,
Failed,
}
impl GraphState {
pub fn as_str(self) -> &'static str {
match self {
Self::Complete => "complete",
Self::Waiting => "waiting",
Self::Failed => "failed",
}
}
pub fn exit_code(self) -> i32 {
match self {
Self::Complete => crate::error::EXIT_SUCCESS,
_ => crate::error::EXIT_QUEUED,
}
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Graph {
order: Vec<String>,
nodes: BTreeMap<String, Node>,
pub concurrency: u32,
}
impl Graph {
pub fn with_concurrency(concurrency: u32) -> Self {
Self {
concurrency,
..Self::default()
}
}
pub fn from_plan(plan: &Plan) -> Self {
let mut graph = Self::with_concurrency(plan.concurrency);
for node in &plan.tasks {
graph.insert(node.clone());
}
graph
}
pub fn to_plan(&self, source: &Plan) -> Plan {
Plan {
schema_version: source.schema_version,
goal: source.goal.clone(),
name: source.name.clone(),
concurrency: self.concurrency,
tasks: self.iter().cloned().collect(),
}
}
pub fn insert(&mut self, node: Node) {
if !self.nodes.contains_key(&node.id) {
self.order.push(node.id.clone());
}
self.nodes.insert(node.id.clone(), node);
}
pub fn remove(&mut self, id: &str) -> Option<Node> {
self.order.retain(|existing| existing != id);
self.nodes.remove(id)
}
pub fn get(&self, id: &str) -> Option<&Node> {
self.nodes.get(id)
}
pub fn get_mut(&mut self, id: &str) -> Option<&mut Node> {
self.nodes.get_mut(id)
}
pub fn contains(&self, id: &str) -> bool {
self.nodes.contains_key(id)
}
pub fn iter(&self) -> impl Iterator<Item = &Node> {
self.order.iter().filter_map(|id| self.nodes.get(id))
}
pub fn ids(&self) -> impl Iterator<Item = &String> {
self.order.iter()
}
pub fn len(&self) -> usize {
self.order.len()
}
pub fn is_empty(&self) -> bool {
self.order.is_empty()
}
pub fn dependents_of(&self, id: &str) -> Vec<String> {
self.iter()
.filter(|node| node.deps.iter().any(|dep| dep == id))
.map(|node| node.id.clone())
.collect()
}
}
pub fn is_cross_dag(reference: &str) -> bool {
crate::crossdag::is_reference(reference)
}
pub fn validate(plan: &Plan) -> Result<()> {
check(plan).map_err(Error::from)
}
pub(crate) fn check(plan: &Plan) -> std::result::Result<(), Refusal> {
if plan.tasks.is_empty() {
return Err(Refusal::plain("a plan needs at least one node").field("tasks"));
}
check_edited(plan)?;
check_declared_version(plan)
}
fn check_declared_version(plan: &Plan) -> std::result::Result<(), Refusal> {
for node in &plan.tasks {
let named = |what: String| Refusal::node(&node.id, what);
if node.body.is_some() && plan.schema_version < crate::plan::PLAN_SCHEMA_VERSION {
return Err(named(crate::plan::body_is_newer(plan.schema_version)).field("body"));
}
if plan.schema_version >= crate::plan::PLAN_SCHEMA_VERSION
&& node.repo.is_some()
&& node.title.is_none()
{
return Err(named(crate::plan::TITLE_IS_REQUIRED.to_owned()).field("title"));
}
}
Ok(())
}
pub fn validate_edited(plan: &Plan) -> Result<()> {
check_edited(plan).map_err(Error::from)
}
pub(crate) fn check_edited(plan: &Plan) -> std::result::Result<(), Refusal> {
let read = crate::plan::PLAN_SCHEMA_VERSIONS_READ;
if !read.contains(&plan.schema_version) {
let known = read
.iter()
.map(u32::to_string)
.collect::<Vec<_>>()
.join(", ");
return Err(Refusal::plain(format!(
"plan schema_version {} is not one this build reads ({known})",
plan.schema_version
))
.field("schema_version"));
}
if plan.concurrency == 0 {
return Err(Refusal::plain("concurrency must be at least 1").field("concurrency"));
}
if let Some(goal) = &plan.goal {
if goal.text.trim().is_empty() {
return Err(Refusal::plain("a goal needs non-empty text").field("goal"));
}
}
let mut seen = BTreeSet::new();
for node in &plan.tasks {
if node.id.trim().is_empty() {
return Err(Refusal::plain("every node needs a non-empty id").field("id"));
}
if !seen.insert(node.id.clone()) {
return Err(
Refusal::about(&node.id, format!("duplicate node id '{}'", node.id)).field("id"),
);
}
check_node(node)?;
}
for node in &plan.tasks {
for dep in &node.deps {
if dep == &node.id {
return Err(Refusal::about(
&node.id,
format!("node '{}' depends on itself", node.id),
)
.field("deps"));
}
if is_cross_dag(dep) {
continue;
}
if crate::crossdag::is_malformed(dep) {
return Err(Refusal::about(
&node.id,
format!(
"node '{}' depends on '{dep}', which is a malformed cross-DAG \
reference; expected '{}'",
node.id,
crate::crossdag::SYNTAX
),
)
.field("deps"));
}
if !seen.contains(dep) {
return Err(Refusal::about(
&node.id,
format!(
"node '{}' depends on '{dep}', which is not in the plan",
node.id
),
)
.field("deps"));
}
}
}
if let Some(cycle) = find_cycle(&plan.tasks) {
return Err(Refusal::plain(format!("dependency cycle: {cycle}")).field("deps"));
}
Ok(())
}
pub(crate) const RESERVED_PERSONA: &str = "`pr-author` is the persona this crate dispatches a change request's drafting under, so a node's own worker cannot run as it";
pub fn validate_node(node: &Node) -> Result<()> {
check_node(node).map_err(Error::from)
}
pub(crate) fn check_node(node: &Node) -> std::result::Result<(), Refusal> {
let named = |what: &str| Refusal::node(&node.id, what);
if node.persona.as_deref() == Some(crate::lifecycle::PR_AUTHOR_PERSONA) {
return Err(named(RESERVED_PERSONA).field("persona"));
}
if let (Some(_), Some(title)) = (&node.repo, &node.title) {
validate_title(title).map_err(|why| named(&why).field("title"))?;
}
if node
.amendment
.as_ref()
.is_some_and(|text| text.trim().is_empty())
{
return Err(named(
"`amendment` is present and says nothing — give it the ruling it carries, or leave \
it out",
)
.field("amendment"));
}
for consumed in node.consumes.keys() {
if !node.deps.iter().any(|dep| dep == consumed) {
return Err(named(&format!(
"`consumes` names '{consumed}', which is not one of this node's deps"
))
.field("consumes"));
}
}
if node.kind == NodeKind::Human {
if node.id.contains(STEP_SEPARATOR) {
return Err(named("a human id cannot contain '/', which addresses a step").field("id"));
}
if node.task.as_ref().is_none_or(|t| t.trim().is_empty()) {
return Err(named("a human node needs task prose").field("task"));
}
if node.persona.is_some() || node.max_turns.is_some() {
return Err(
named("a human node has no dispatch, so no persona or turn budget").field(
if node.persona.is_some() {
"persona"
} else {
"max_turns"
},
),
);
}
if node.repo.is_some() || node.steps.is_some() || node.expects_no_diff {
return Err(named("a human node has no execution fields").field(
if node.repo.is_some() {
"repo"
} else if node.steps.is_some() {
"steps"
} else {
"expects_no_diff"
},
));
}
if node.context.is_some() {
return Err(named(
"a planner note is addressed to a dispatch, and a human node has none",
)
.field("context"));
}
return Ok(());
}
if node.expects_no_diff {
if node.task.as_ref().is_none_or(|t| t.trim().is_empty()) {
return Err(named("an expects_no_diff node needs task prose").field("task"));
}
if node.persona.is_some() || node.max_turns.is_some() {
return Err(named(
"expects_no_diff settles without a dispatch, so it takes no persona or turn budget",
)
.field(if node.persona.is_some() {
"persona"
} else {
"max_turns"
}));
}
if node.steps.is_some() {
return Err(named("expects_no_diff and steps cannot both be set").field("steps"));
}
return Ok(());
}
NodeControls::of_node(node)
.and_then(|controls| controls.overrides())
.map_err(|why| named(&why).field("max_turns"))?;
match (&node.repo, &node.steps) {
(None, Some(_)) => Err(named("steps run on one branch, so they need a repo").field("repo")),
(None, None) => {
if node.persona.is_none() {
return Err(named("a direct agent node needs a persona").field("persona"));
}
if node.task.as_ref().is_none_or(|t| t.trim().is_empty()) {
return Err(named("a direct agent node needs task prose").field("task"));
}
Ok(())
}
(Some(_), Some(steps)) => {
if node.persona.is_some() || node.task.is_some() || node.max_turns.is_some() {
return Err(named(
"a node with steps takes its persona, task, and turn budget from them",
)
.field(if node.persona.is_some() {
"persona"
} else if node.task.is_some() {
"task"
} else {
"max_turns"
}));
}
check_steps(node, steps)
}
(Some(_), None) => {
if node.persona.is_none() {
return Err(named("a lifecycle node needs a persona or steps").field("persona"));
}
if node.task.as_ref().is_none_or(|t| t.trim().is_empty()) {
return Err(named("a lifecycle node needs task prose").field("task"));
}
Ok(())
}
}
}
fn validate_title(title: &str) -> std::result::Result<(), String> {
let title = title.trim();
if title.is_empty() {
return Err("the title is blank, and a publication needs a subject".to_owned());
}
if title.len() > SUBJECT_LIMIT {
return Err(format!(
"the title is {} characters, over the {SUBJECT_LIMIT}-character limit onevcs \
holds a publication subject to",
title.len()
));
}
Ok(())
}
fn check_steps(node: &Node, steps: &[Step]) -> std::result::Result<(), Refusal> {
let named = |what: String| Refusal::node(&node.id, what).field("steps");
if steps.is_empty() {
return Err(named("a steps list cannot be empty".into()));
}
let mut seen = BTreeSet::new();
for step in steps {
if step.id.trim().is_empty() {
return Err(named("every step needs a non-empty id".into()));
}
if !seen.insert(step.id.clone()) {
return Err(named(format!("duplicate step id '{}'", step.id)));
}
if step.kind == NodeKind::Human {
if step.id.contains(STEP_SEPARATOR) {
return Err(named(format!(
"human step '{}' cannot contain '/', which addresses it",
step.id
)));
}
if step.persona.is_some() || step.max_turns.is_some() || step.expects_no_diff {
return Err(named(format!(
"human step '{}' has no dispatch, so no persona, turn budget, or \
expects_no_diff",
step.id
)));
}
} else if step.expects_no_diff {
if step.persona.is_some() || step.max_turns.is_some() {
return Err(named(format!(
"step '{}': expects_no_diff settles without a dispatch",
step.id
)));
}
} else {
if step.persona.is_none() {
return Err(named(format!("agent step '{}' needs a persona", step.id)));
}
if step.persona.as_deref() == Some(crate::lifecycle::PR_AUTHOR_PERSONA) {
return Err(named(format!("step '{}': {RESERVED_PERSONA}", step.id)));
}
NodeControls::of_step(step)
.and_then(|controls| controls.overrides())
.map_err(|why| named(format!("step '{}': {why}", step.id)))?;
}
if step.task.as_ref().is_none_or(|t| t.trim().is_empty()) {
return Err(named(format!("step '{}' needs task prose", step.id)));
}
}
for step in steps {
for dep in &step.deps {
if dep == &step.id {
return Err(named(format!("step '{}' depends on itself", step.id)));
}
if !seen.contains(dep) {
return Err(named(format!(
"step '{}' depends on '{dep}', which is not a step of this node",
step.id
)));
}
}
}
Ok(())
}
fn find_cycle(nodes: &[Node]) -> Option<String> {
#[derive(Clone, Copy, PartialEq)]
enum Mark {
Open,
Closed,
}
let deps: BTreeMap<&str, Vec<&str>> = nodes
.iter()
.map(|node| {
(
node.id.as_str(),
node.deps
.iter()
.filter(|dep| !is_cross_dag(dep))
.map(String::as_str)
.collect(),
)
})
.collect();
let mut marks: BTreeMap<&str, Mark> = BTreeMap::new();
let mut path: Vec<&str> = Vec::new();
for root in nodes.iter().map(|n| n.id.as_str()) {
if marks.contains_key(root) {
continue;
}
let mut stack: Vec<(&str, usize)> = vec![(root, 0)];
marks.insert(root, Mark::Open);
path.push(root);
while let Some((node, index)) = stack.pop() {
let children = deps.get(node).map(Vec::as_slice).unwrap_or_default();
if index < children.len() {
stack.push((node, index + 1));
let child = children[index];
match marks.get(child) {
Some(Mark::Open) => {
let start = path.iter().position(|n| *n == child).unwrap_or(0);
let mut cycle: Vec<&str> = path[start..].to_vec();
cycle.push(child);
return Some(cycle.join(" -> "));
}
Some(Mark::Closed) => {}
None => {
marks.insert(child, Mark::Open);
path.push(child);
stack.push((child, 0));
}
}
} else {
marks.insert(node, Mark::Closed);
path.pop();
}
}
}
None
}
pub fn derive(
graph: &Graph,
recorded: &BTreeMap<String, NodeStatus>,
resolved_cross_dag: &dyn Fn(&str) -> Option<NodeStatus>,
) -> BTreeMap<String, NodeStatus> {
let mut statuses: BTreeMap<String, NodeStatus> = BTreeMap::new();
for node in graph.iter() {
if node.parked {
statuses.insert(node.id.clone(), NodeStatus::Parked);
continue;
}
if let Some(recorded) = recorded.get(&node.id) {
if !matches!(recorded, NodeStatus::Blocked | NodeStatus::Skipped) {
statuses.insert(node.id.clone(), *recorded);
}
}
}
loop {
let mut changed = false;
for node in graph.iter() {
if statuses.contains_key(&node.id) {
continue;
}
let Some(status) = eligibility(graph, node, &statuses, resolved_cross_dag) else {
continue;
};
statuses.insert(node.id.clone(), status);
changed = true;
}
if !changed {
break;
}
}
for node in graph.iter() {
statuses
.entry(node.id.clone())
.or_insert(NodeStatus::Pending);
}
statuses
}
fn eligibility(
graph: &Graph,
node: &Node,
statuses: &BTreeMap<String, NodeStatus>,
resolved_cross_dag: &dyn Fn(&str) -> Option<NodeStatus>,
) -> Option<NodeStatus> {
let mut all_done = true;
let mut failed = false;
let mut gated = false;
for dep in &node.deps {
let status = if is_cross_dag(dep) {
match resolved_cross_dag(dep) {
Some(status) => status,
None => {
all_done = false;
gated = true;
continue;
}
}
} else if graph.contains(dep) {
match statuses.get(dep) {
Some(status) => *status,
None => {
all_done = false;
continue;
}
}
} else {
continue;
};
match status {
NodeStatus::Done => {}
_ if skips_dependents(status) => {
failed = true;
all_done = false;
}
NodeStatus::Waiting
| NodeStatus::Blocked
| NodeStatus::Parked
| NodeStatus::Cancelled => {
gated = true;
all_done = false;
}
_ => all_done = false,
}
}
if failed {
return Some(NodeStatus::Skipped);
}
if gated {
return Some(NodeStatus::Blocked);
}
if !all_done {
return None;
}
if node.parked {
return Some(NodeStatus::Parked);
}
if node.kind == NodeKind::Human {
return Some(NodeStatus::Waiting);
}
Some(NodeStatus::Ready)
}
fn skips_dependents(status: NodeStatus) -> bool {
matches!(status, NodeStatus::Failed | NodeStatus::Skipped)
}
pub fn skipped_by(
graph: &Graph,
statuses: &BTreeMap<String, NodeStatus>,
id: &str,
) -> Vec<(String, NodeStatus)> {
if statuses.get(id) != Some(&NodeStatus::Skipped) {
return Vec::new();
}
let Some(node) = graph.get(id) else {
return Vec::new();
};
node.deps
.iter()
.filter_map(|dep| {
let status = *statuses.get(dep)?;
skips_dependents(status).then(|| (dep.clone(), status))
})
.collect()
}
pub fn state_of(statuses: &BTreeMap<String, NodeStatus>) -> GraphState {
if statuses
.values()
.any(|s| matches!(s, NodeStatus::Failed | NodeStatus::Skipped))
{
GraphState::Failed
} else if statuses.values().any(|s| {
matches!(
s,
NodeStatus::Waiting | NodeStatus::Blocked | NodeStatus::Parked | NodeStatus::Cancelled
)
}) {
GraphState::Waiting
} else if statuses.values().all(|s| *s == NodeStatus::Done) {
GraphState::Complete
} else {
GraphState::Waiting
}
}
pub fn is_terminal(statuses: &BTreeMap<String, NodeStatus>) -> bool {
statuses.values().all(|s| s.is_settled())
}
pub fn unblocks(graph: &Graph, id: &str) -> Vec<String> {
graph.dependents_of(id)
}
#[cfg(test)]
mod tests {
fn every_status() -> Vec<NodeStatus> {
fn after(status: NodeStatus) -> Option<NodeStatus> {
match status {
NodeStatus::Pending => Some(NodeStatus::Ready),
NodeStatus::Ready => Some(NodeStatus::Running),
NodeStatus::Running => Some(NodeStatus::Waiting),
NodeStatus::Waiting => Some(NodeStatus::Blocked),
NodeStatus::Blocked => Some(NodeStatus::Parked),
NodeStatus::Parked => Some(NodeStatus::Cancelled),
NodeStatus::Cancelled => Some(NodeStatus::Done),
NodeStatus::Done => Some(NodeStatus::CompleteDraft),
NodeStatus::CompleteDraft => Some(NodeStatus::Failed),
NodeStatus::Failed => Some(NodeStatus::Skipped),
NodeStatus::Skipped => None,
}
}
let mut every = vec![NodeStatus::Pending];
while let Some(next) = after(*every.last().expect("the walk starts at one status")) {
assert!(
!every.contains(&next),
"the walk over NodeStatus reaches {next:?} twice, so it names no order and \
whatever follows it is never reached"
);
every.push(next);
}
every
}
#[test]
fn the_draft_settlement_vocabulary_is_what_the_divergence_record_names() {
let docs = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("docs");
let record = std::fs::read_to_string(docs.join("contract-divergences.md"))
.expect("the divergence record ships");
let entry = record
.split("\n## ")
.find(|entry| entry.starts_with("51."))
.expect("the record still carries entry 51");
let block: serde_json::Value = entry
.split("```json")
.nth(1)
.and_then(|rest| rest.split("```").next())
.and_then(|block| serde_json::from_str(block).ok())
.expect("entry 51 carries the json block this test drives");
let contract =
std::fs::read_to_string(docs.join("contract.md")).expect("the contract ships");
let statuses: Vec<String> = serde_json::from_value(block["node_statuses"].clone())
.expect("entry 51 names the node statuses it adds");
let undocumented: Vec<String> = every_status()
.iter()
.map(|status| status.as_str().to_string())
.filter(|word| !contract.contains(word.as_str()))
.collect();
assert_eq!(
undocumented, statuses,
"the statuses this build carries that docs/contract.md does not name are not entry \
51's"
);
for word in &statuses {
assert_eq!(
NodeStatus::parse(word).map(NodeStatus::as_str),
Some(word.as_str()),
"`{word}` does not round-trip through the status this build writes"
);
}
let outcomes: Vec<String> = serde_json::from_value(block["outcomes"].clone())
.expect("entry 51 names the outcome it adds");
assert_eq!(
outcomes,
vec![crate::vcs::DRAFTED.to_string()],
"entry 51 names a different outcome than a drafted publication settles on"
);
for word in &outcomes {
assert!(
!contract.contains(word.as_str()),
"the contract names `{word}`, so it is no divergence"
);
}
}
use super::*;
use crate::plan::{Goal, PLAN_SCHEMA_VERSION};
#[test]
fn a_node_identity_comes_from_a_node_and_a_blank_id_is_not_one() {
let named = Node {
id: " service ".into(),
..Node::default()
};
assert_eq!(
NodeRef::of(&named).as_ref().map(NodeRef::as_str),
Some("service"),
"an identity is the node's own id, trimmed"
);
for blank in ["", " ", "\t\n"] {
let unnamed = Node {
id: blank.into(),
..Node::default()
};
assert_eq!(
NodeRef::of(&unnamed),
None,
"a node with no id yielded an identity: {blank:?}"
);
}
let plan = Plan {
schema_version: PLAN_SCHEMA_VERSION,
name: Some("blank".into()),
concurrency: 1,
goal: None,
tasks: vec![Node {
id: " ".into(),
task: Some("## What\nwork".into()),
persona: Some("engineer".into()),
..Node::default()
}],
};
assert!(validate(&plan).is_err(), "a blank node id was accepted");
}
#[test]
fn a_status_serialises_as_the_word_it_is_written_and_read_as() {
for status in [
NodeStatus::Pending,
NodeStatus::Ready,
NodeStatus::Running,
NodeStatus::Waiting,
NodeStatus::Blocked,
NodeStatus::Parked,
NodeStatus::Cancelled,
NodeStatus::Done,
NodeStatus::Failed,
NodeStatus::Skipped,
] {
let json = serde_json::to_string(&status).expect("a status serialises");
assert_eq!(json, format!("\"{}\"", status.as_str()));
assert_eq!(NodeStatus::parse(status.as_str()), Some(status));
assert_eq!(
serde_json::from_str::<NodeStatus>(&json).expect("it reads back"),
status
);
}
}
#[test]
fn a_graph_state_serialises_as_the_word_it_is_rendered_as() {
for state in [
GraphState::Complete,
GraphState::Waiting,
GraphState::Failed,
] {
let json = serde_json::to_string(&state).expect("a state serialises");
assert_eq!(json, format!("\"{}\"", state.as_str()));
assert_eq!(
serde_json::from_str::<GraphState>(&json).expect("it reads back"),
state
);
}
}
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 human(id: &str, deps: &[&str]) -> Node {
Node {
id: id.into(),
kind: NodeKind::Human,
task: Some("approve it".into()),
deps: deps.iter().map(|d| (*d).to_string()).collect(),
..Node::default()
}
}
fn plan_of(tasks: Vec<Node>) -> Plan {
Plan {
schema_version: PLAN_SCHEMA_VERSION,
goal: None,
name: Some("test".into()),
concurrency: 4,
tasks,
}
}
fn no_cross_dag(_: &str) -> Option<NodeStatus> {
None
}
#[test]
fn a_legal_mixed_plan_validates() {
let plan = plan_of(vec![
agent("build", &[]),
human("approve", &["build"]),
Node {
id: "publish".into(),
repo: Some("owner/repo".into()),
persona: Some("engineer".into()),
task: Some("## What\nship".into()),
title: Some("feat: ship it".into()),
deps: vec!["approve".into()],
..Node::default()
},
]);
validate(&plan).expect("the plan is legal");
}
#[test]
fn a_self_edge_and_a_cycle_are_both_refused() {
let mut plan = plan_of(vec![agent("a", &["a"])]);
let message = validate(&plan).unwrap_err().to_string();
assert!(message.contains("depends on itself"), "{message}");
plan = plan_of(vec![agent("a", &["b"]), agent("b", &["a"])]);
let message = validate(&plan).unwrap_err().to_string();
assert!(message.contains("cycle"), "{message}");
}
#[test]
fn a_deep_chain_does_not_overflow_the_cycle_walk() {
let mut tasks: Vec<Node> = Vec::new();
for index in 0..20_000u32 {
let deps: Vec<&str> = Vec::new();
let mut node = agent(&format!("n{index}"), &deps);
if index > 0 {
node.deps = vec![format!("n{}", index - 1)];
}
tasks.push(node);
}
validate(&plan_of(tasks)).expect("a 20k-node chain is legal");
}
#[test]
fn a_dangling_dependency_is_refused_but_a_cross_dag_one_is_not() {
let plan = plan_of(vec![agent("a", &["nowhere"])]);
let message = validate(&plan).unwrap_err().to_string();
assert!(message.contains("not in the plan"), "{message}");
let plan = plan_of(vec![agent("a", &["run:other#build"])]);
validate(&plan).expect("a cross-DAG reference is not a missing node");
}
#[test]
fn every_node_shape_rule_the_contract_states_is_enforced() {
let cases: &[(Node, &str)] = &[
(
Node {
id: "no-persona".into(),
task: Some("t".into()),
..Node::default()
},
"needs a persona",
),
(
Node {
id: "with/slash".into(),
kind: NodeKind::Human,
task: Some("t".into()),
..Node::default()
},
"cannot contain '/'",
),
(
Node {
id: "human-persona".into(),
kind: NodeKind::Human,
task: Some("t".into()),
persona: Some("engineer".into()),
..Node::default()
},
"no persona or turn budget",
),
(
Node {
id: "human-context".into(),
kind: NodeKind::Human,
task: Some("t".into()),
context: Some("a note".into()),
..Node::default()
},
"has none",
),
(
Node {
id: "nodiff-persona".into(),
expects_no_diff: true,
task: Some("t".into()),
persona: Some("engineer".into()),
..Node::default()
},
"takes no persona or turn budget",
),
(
Node {
id: "steps-no-repo".into(),
steps: Some(vec![Step {
id: "one".into(),
persona: Some("engineer".into()),
task: Some("t".into()),
..Step::default()
}]),
..Node::default()
},
"need a repo",
),
(
Node {
id: "steps-and-task".into(),
repo: Some("o/r".into()),
task: Some("t".into()),
steps: Some(vec![Step {
id: "one".into(),
persona: Some("engineer".into()),
task: Some("t".into()),
..Step::default()
}]),
..Node::default()
},
"takes its persona, task, and turn budget from them",
),
(
Node {
id: "step-no-persona".into(),
repo: Some("o/r".into()),
steps: Some(vec![Step {
id: "one".into(),
task: Some("t".into()),
..Step::default()
}]),
..Node::default()
},
"needs a persona",
),
(
Node {
id: "human-budget".into(),
kind: NodeKind::Human,
task: Some("t".into()),
max_turns: Some(45),
..Node::default()
},
"no persona or turn budget",
),
(
Node {
id: "nodiff-budget".into(),
expects_no_diff: true,
task: Some("t".into()),
max_turns: Some(45),
..Node::default()
},
"takes no persona or turn budget",
),
(
Node {
id: "steps-and-budget".into(),
repo: Some("o/r".into()),
max_turns: Some(45),
steps: Some(vec![Step {
id: "one".into(),
persona: Some("engineer".into()),
task: Some("t".into()),
..Step::default()
}]),
..Node::default()
},
"takes its persona, task, and turn budget from them",
),
(
Node {
id: "human-step-budget".into(),
repo: Some("o/r".into()),
steps: Some(vec![Step {
id: "sign-off".into(),
kind: NodeKind::Human,
task: Some("t".into()),
max_turns: Some(45),
..Step::default()
}]),
..Node::default()
},
"no persona, turn budget, or expects_no_diff",
),
(
Node {
id: "step-cycle".into(),
repo: Some("o/r".into()),
steps: Some(vec![Step {
id: "one".into(),
persona: Some("engineer".into()),
task: Some("t".into()),
deps: vec!["one".into()],
..Step::default()
}]),
..Node::default()
},
"depends on itself",
),
];
for (node, expected) in cases {
let message = validate_node(node).unwrap_err().to_string();
assert!(
message.contains(expected),
"node '{}': expected {expected:?} in {message:?}",
node.id
);
}
}
#[test]
fn a_title_the_publication_would_refuse_is_refused_before_anything_is_dispatched() {
let titled = |title: &str| Node {
title: Some(title.to_owned()),
repo: Some("owner/repo".into()),
..agent("publish", &[])
};
let over = "t".repeat(SUBJECT_LIMIT + 1);
let message = validate(&plan_of(vec![titled(&over)]))
.unwrap_err()
.to_string();
assert!(message.contains("node 'publish'"), "{message}");
assert!(
message.contains(&format!("{} characters", SUBJECT_LIMIT + 1)),
"the refusal does not say how long the title is: {message}"
);
assert!(
message.contains(&format!("{SUBJECT_LIMIT}-character limit")),
"the refusal does not name the limit: {message}"
);
validate(&plan_of(vec![titled(&"t".repeat(SUBJECT_LIMIT))]))
.expect("a title at the limit is publishable, so the plan is legal");
validate(&plan_of(vec![titled(&format!(
" {} ",
"t".repeat(SUBJECT_LIMIT)
))]))
.expect("the surrounding spacing was counted against the limit");
let message = validate(&plan_of(vec![titled(" ")]))
.unwrap_err()
.to_string();
assert!(message.contains("node 'publish'"), "{message}");
assert!(message.contains("blank"), "{message}");
validate(&plan_of(vec![Node {
title: Some(over),
..agent("direct", &[])
}]))
.expect("a title on a node that never publishes was held to the publication's limit");
}
#[test]
fn a_title_a_planner_would_actually_write_is_left_alone() {
let title = "feat(plan): refuse a node title that the publication would not commit under, \
before it is dispatched";
assert!(
title.len() < SUBJECT_LIMIT,
"this is only an ordinary title while it is inside the bound: {} characters",
title.len()
);
validate(&plan_of(vec![Node {
title: Some(title.to_owned()),
repo: Some("owner/repo".into()),
..agent("publish", &[])
}]))
.expect("an ordinary title was refused");
}
#[test]
fn every_version_this_build_reads_validates_and_no_other_does() {
for version in crate::plan::PLAN_SCHEMA_VERSIONS_READ {
let mut plan = plan_of(vec![agent("a", &[])]);
plan.schema_version = version;
validate(&plan)
.unwrap_or_else(|why| panic!("a version {version} plan is a document: {why}"));
}
let mut unknown = plan_of(vec![agent("a", &[])]);
unknown.schema_version = 99;
let message = validate(&unknown).unwrap_err().to_string();
assert!(message.contains("schema_version 99"), "{message}");
for version in crate::plan::PLAN_SCHEMA_VERSIONS_READ {
assert!(
message.contains(&version.to_string()),
"the refusal does not name version {version}, which this build reads: {message}"
);
}
}
#[test]
fn the_plan_envelope_itself_is_validated() {
let mut plan = plan_of(vec![agent("a", &[])]);
plan.schema_version = 99;
assert!(validate(&plan)
.unwrap_err()
.to_string()
.contains("schema_version"));
let mut plan = plan_of(vec![agent("a", &[])]);
plan.concurrency = 0;
assert!(validate(&plan)
.unwrap_err()
.to_string()
.contains("concurrency"));
let mut plan = plan_of(vec![]);
plan.tasks = vec![];
assert!(validate(&plan)
.unwrap_err()
.to_string()
.contains("at least one node"));
let mut plan = plan_of(vec![agent("a", &[]), agent("a", &[])]);
plan.tasks[1].id = "a".into();
assert!(validate(&plan)
.unwrap_err()
.to_string()
.contains("duplicate"));
let mut plan = plan_of(vec![agent("a", &[])]);
plan.goal = Some(Goal { text: " ".into() });
assert!(validate(&plan)
.unwrap_err()
.to_string()
.contains("non-empty text"));
}
#[test]
fn a_ready_frontier_is_everything_whose_dependencies_are_done() {
let graph = Graph::from_plan(&plan_of(vec![
agent("a", &[]),
agent("b", &[]),
agent("c", &["a", "b"]),
]));
let mut recorded = BTreeMap::new();
recorded.insert("a".to_string(), NodeStatus::Done);
let statuses = derive(&graph, &recorded, &no_cross_dag);
assert_eq!(statuses["a"], NodeStatus::Done);
assert_eq!(statuses["b"], NodeStatus::Ready);
assert_eq!(statuses["c"], NodeStatus::Pending);
}
#[test]
fn a_waiting_human_blocks_and_a_failure_skips_the_same_descendant() {
let graph = Graph::from_plan(&plan_of(vec![
agent("build", &[]),
human("approve", &["build"]),
agent("ship", &["approve"]),
]));
let mut recorded = BTreeMap::new();
recorded.insert("build".to_string(), NodeStatus::Done);
let statuses = derive(&graph, &recorded, &no_cross_dag);
assert_eq!(statuses["approve"], NodeStatus::Waiting);
assert_eq!(statuses["ship"], NodeStatus::Blocked);
assert_eq!(state_of(&statuses), GraphState::Waiting);
let graph = Graph::from_plan(&plan_of(vec![
agent("build", &[]),
human("approve", &[]),
agent("ship", &["approve", "build"]),
]));
let mut recorded = BTreeMap::new();
recorded.insert("build".to_string(), NodeStatus::Failed);
let statuses = derive(&graph, &recorded, &no_cross_dag);
assert_eq!(statuses["approve"], NodeStatus::Waiting);
assert_eq!(statuses["ship"], NodeStatus::Skipped);
assert_eq!(state_of(&statuses), GraphState::Failed);
}
#[test]
fn a_parked_node_blocks_its_dependents_rather_than_skipping_them() {
let mut parked = agent("sweep", &[]);
parked.parked = true;
let graph = Graph::from_plan(&plan_of(vec![parked, agent("after", &["sweep"])]));
let statuses = derive(&graph, &BTreeMap::new(), &no_cross_dag);
assert_eq!(statuses["sweep"], NodeStatus::Parked);
assert_eq!(statuses["after"], NodeStatus::Blocked);
assert_eq!(state_of(&statuses), GraphState::Waiting);
assert!(is_terminal(&statuses));
}
#[test]
fn a_recorded_gate_is_discarded_and_re_derived() {
let graph = Graph::from_plan(&plan_of(vec![
human("approve", &[]),
agent("ship", &["approve"]),
]));
let mut recorded = BTreeMap::new();
recorded.insert("ship".to_string(), NodeStatus::Blocked);
recorded.insert("approve".to_string(), NodeStatus::Done);
let statuses = derive(&graph, &recorded, &no_cross_dag);
assert_eq!(statuses["ship"], NodeStatus::Ready);
}
#[test]
fn an_unresolved_cross_dag_reference_blocks_its_consumer() {
let graph = Graph::from_plan(&plan_of(vec![agent("consume", &["run:other#build"])]));
let statuses = derive(&graph, &BTreeMap::new(), &no_cross_dag);
assert_eq!(statuses["consume"], NodeStatus::Blocked);
let done = |_: &str| Some(NodeStatus::Done);
let statuses = derive(&graph, &BTreeMap::new(), &done);
assert_eq!(statuses["consume"], NodeStatus::Ready);
let failed = |_: &str| Some(NodeStatus::Failed);
let statuses = derive(&graph, &BTreeMap::new(), &failed);
assert_eq!(statuses["consume"], NodeStatus::Skipped);
}
#[test]
fn every_skipped_node_names_the_dependencies_that_skipped_it() {
let graph = Graph::from_plan(&plan_of(vec![
agent("build", &[]),
agent("lint", &[]),
agent("ship", &["build", "lint"]),
agent("announce", &["ship"]),
]));
let mut recorded = BTreeMap::new();
recorded.insert("build".to_string(), NodeStatus::Failed);
let statuses = derive(&graph, &recorded, &no_cross_dag);
assert_eq!(statuses["ship"], NodeStatus::Skipped);
assert_eq!(statuses["announce"], NodeStatus::Skipped);
assert_eq!(
skipped_by(&graph, &statuses, "ship"),
vec![("build".to_string(), NodeStatus::Failed)]
);
assert_eq!(
skipped_by(&graph, &statuses, "announce"),
vec![("ship".to_string(), NodeStatus::Skipped)]
);
assert!(skipped_by(&graph, &statuses, "lint").is_empty());
assert!(skipped_by(&graph, &statuses, "nowhere").is_empty());
let mut parked = agent("sweep", &["build"]);
parked.parked = true;
let graph = Graph::from_plan(&plan_of(vec![agent("build", &[]), parked]));
let statuses = derive(&graph, &recorded, &no_cross_dag);
assert_eq!(statuses["sweep"], NodeStatus::Parked);
assert!(skipped_by(&graph, &statuses, "sweep").is_empty());
}
#[test]
fn a_dropped_dependency_detaches_rather_than_blocking() {
let mut graph = Graph::from_plan(&plan_of(vec![agent("a", &[]), agent("b", &["a"])]));
graph.remove("a");
let statuses = derive(&graph, &BTreeMap::new(), &no_cross_dag);
assert_eq!(statuses["b"], NodeStatus::Ready);
}
#[test]
fn a_complete_graph_is_complete_and_exits_zero() {
let graph = Graph::from_plan(&plan_of(vec![agent("a", &[])]));
let mut recorded = BTreeMap::new();
recorded.insert("a".to_string(), NodeStatus::Done);
let statuses = derive(&graph, &recorded, &no_cross_dag);
assert_eq!(state_of(&statuses), GraphState::Complete);
assert_eq!(GraphState::Complete.exit_code(), 0);
assert_eq!(GraphState::Waiting.exit_code(), 1);
assert_eq!(GraphState::Failed.exit_code(), 1);
}
#[test]
fn a_graph_still_running_has_not_settled() {
let graph = Graph::from_plan(&plan_of(vec![agent("a", &[])]));
let mut recorded = BTreeMap::new();
recorded.insert("a".to_string(), NodeStatus::Running);
let statuses = derive(&graph, &recorded, &no_cross_dag);
assert!(!is_terminal(&statuses));
assert_eq!(state_of(&statuses), GraphState::Waiting);
}
#[test]
fn statuses_round_trip_through_their_written_word() {
for status in [
NodeStatus::Pending,
NodeStatus::Ready,
NodeStatus::Running,
NodeStatus::Waiting,
NodeStatus::Blocked,
NodeStatus::Parked,
NodeStatus::Cancelled,
NodeStatus::Done,
NodeStatus::Failed,
NodeStatus::Skipped,
] {
assert_eq!(NodeStatus::parse(status.as_str()), Some(status));
}
assert_eq!(NodeStatus::parse("invented"), None);
}
#[test]
fn a_landing_round_trips_through_its_word_and_an_unknown_one_is_no_landing() {
for landing in [Landing::Landed, Landing::Unlanded] {
assert_eq!(Landing::parse(landing.as_str()), Some(landing));
assert_eq!(
serde_json::to_value(landing).expect("a landing serialises"),
serde_json::Value::String(landing.as_str().to_string()),
"`serde` and `as_str` disagree about how to spell {landing:?}"
);
assert_eq!(
serde_json::from_value::<Landing>(serde_json::json!(landing.as_str()))
.expect("the rendered word reads back"),
landing
);
}
for unreadable in ["", "invented", "done", "merged", "Landed"] {
assert_eq!(
Landing::parse(unreadable),
None,
"{unreadable:?} was read as a landing this build understands"
);
}
assert_eq!(NodeStatus::parse(Landing::Landed.as_str()), None);
assert_eq!(Landing::parse(NodeStatus::Done.as_str()), None);
}
#[test]
fn a_graph_keeps_the_order_the_plan_wrote_and_reports_dependents() {
let mut graph = Graph::from_plan(&plan_of(vec![
agent("first", &[]),
agent("second", &["first"]),
]));
graph.insert(agent("third", &["first"]));
assert_eq!(
graph.ids().cloned().collect::<Vec<_>>(),
vec!["first", "second", "third"]
);
assert_eq!(unblocks(&graph, "first"), vec!["second", "third"]);
assert_eq!(graph.len(), 3);
assert!(!graph.is_empty());
graph.insert(agent("second", &[]));
assert_eq!(
graph.ids().cloned().collect::<Vec<_>>(),
vec!["first", "second", "third"]
);
assert!(graph.get_mut("second").is_some());
assert_eq!(graph.remove("second").map(|n| n.id), Some("second".into()));
assert!(!graph.contains("second"));
assert!(Graph::default().is_empty());
}
#[test]
fn consumes_naming_something_this_node_does_not_depend_on_is_refused() {
let named = |on: &str| {
let mut node = agent("consumer", &["engine"]);
node.consumes.insert(
on.to_string(),
"crate".parse().expect("a release target name"),
);
plan_of(vec![agent("engine", &[]), node])
};
let refusal = validate(&named("packager")).unwrap_err().to_string();
assert!(
refusal.contains("node 'consumer'")
&& refusal.contains("`consumes` names 'packager'")
&& refusal.contains("not one of this node's deps"),
"{refusal}"
);
validate(&named("engine")).expect("a target for a dependency it has");
let mut across = agent("consumer", &["run:other#upstream"]);
across.consumes.insert(
"run:other#upstream".to_string(),
"crate".parse().expect("a release target name"),
);
validate(&plan_of(vec![across])).expect("a target for a cross-DAG dependency");
}
#[test]
fn a_graph_renders_back_as_the_plan_it_came_from() {
let source = plan_of(vec![agent("a", &[])]);
let graph = Graph::from_plan(&source);
let round_trip = graph.to_plan(&source);
assert_eq!(round_trip, source);
}
}