use std::collections::{BTreeMap, BTreeSet};
use crate::agent::{Access, AgentName};
use crate::config::Config;
use crate::diagram::{Activity, Live, Scope};
use crate::graph::RouteGraph;
use crate::pipeline::PipelineName;
use crate::route::Join;
const NODE_W: f64 = 168.0;
const NODE_H: f64 = 56.0;
const COL_GAP: f64 = 96.0;
const ROW_GAP: f64 = 32.0;
const MARGIN: f64 = 32.0;
const RETURN_GAP: f64 = 34.0;
const GUTTER_INSET: f64 = 44.0;
const GUTTER_STEP: f64 = 18.0;
const GUTTER_MIN: f64 = 12.0;
const CORNER_MARGIN: f64 = 20.0;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NodeKind {
Pipeline,
Agent,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Shape {
Box,
Gate,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Node {
pub id: String,
pub label: String,
pub subtitle: Option<String>,
pub kind: NodeKind,
pub shape: Shape,
pub activity: Option<Activity>,
pub layer: usize,
pub x: f64,
pub y: f64,
pub w: f64,
pub h: f64,
}
impl Node {
#[must_use]
pub fn exit(&self) -> (f64, f64) {
(self.x + self.w, self.y + self.h / 2.0)
}
#[must_use]
pub fn entry(&self) -> (f64, f64) {
(self.x, self.y + self.h / 2.0)
}
#[must_use]
pub fn centre(&self) -> (f64, f64) {
(self.x + self.w / 2.0, self.y + self.h / 2.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EdgeStyle {
Plain,
Entry,
Joined,
Bypass,
Spawn,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Edge {
pub from: String,
pub to: String,
pub label: Option<String>,
pub style: EdgeStyle,
pub back: bool,
pub from_y: f64,
pub to_y: f64,
pub gutter: Option<f64>,
pub hook_x: Option<f64>,
pub floor: Option<f64>,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Layout {
pub nodes: Vec<Node>,
pub edges: Vec<Edge>,
pub width: f64,
pub height: f64,
}
impl Layout {
#[must_use]
pub fn node(&self, id: &str) -> Option<&Node> {
self.nodes.iter().find(|node| node.id == id)
}
#[must_use]
pub fn build(config: &Config, live: &Live) -> Self {
Self::scoped(config, live, &Scope::Everything)
}
#[must_use]
pub fn scoped(config: &Config, live: &Live, scope: &Scope) -> Self {
let graph = RouteGraph::from_config(config);
let members = scope.pipeline().and_then(|name| {
config
.pipelines
.get(name)
.map(|pipeline| graph.workflow_from(&pipeline.entry))
});
let layers = assign_layers(config, &graph, scope);
let mut layout = Self::default();
layout.place(config, live, &graph, &layers, scope, members.as_ref());
layout.connect(config, &graph, members.as_ref());
layout.order_by_barycentre();
layout.size();
layout
}
fn place(
&mut self,
config: &Config,
live: &Live,
graph: &RouteGraph,
layers: &BTreeMap<AgentName, usize>,
scope: &Scope,
members: Option<&BTreeSet<AgentName>>,
) {
let mut columns: BTreeMap<usize, Vec<Node>> = BTreeMap::new();
for (name, pipeline) in &config.pipelines {
if scope.pipeline().is_some_and(|wanted| wanted != name) {
continue;
}
columns.entry(0).or_default().push(Node {
id: pipeline_id(name),
label: name.as_str().to_owned(),
subtitle: Some(pipeline.trigger.to_string()),
kind: NodeKind::Pipeline,
shape: Shape::Box,
activity: None,
layer: 0,
x: 0.0,
y: 0.0,
w: NODE_W,
h: NODE_H,
});
}
for (name, agent) in &config.agents {
if members.is_some_and(|members| !members.contains(name)) {
continue;
}
let layer = layers.get(name).copied().unwrap_or(0) + 1;
columns.entry(layer).or_default().push(Node {
id: agent_id(name),
label: name.as_str().to_owned(),
subtitle: (agent.access == Access::ReadOnly).then(|| "read-only".to_owned()),
kind: NodeKind::Agent,
shape: if graph.join_for(name).is_some() {
Shape::Gate
} else {
Shape::Box
},
activity: live.activity.get(name).copied(),
layer,
x: 0.0,
y: 0.0,
w: NODE_W,
h: NODE_H,
});
}
for (layer, mut nodes) in columns {
let x = MARGIN + precise(layer) * (NODE_W + COL_GAP);
for (row, node) in nodes.iter_mut().enumerate() {
node.x = x;
node.y = MARGIN + precise(row) * (NODE_H + ROW_GAP);
}
self.nodes.append(&mut nodes);
}
}
fn connect(
&mut self,
config: &Config,
graph: &RouteGraph,
members: Option<&BTreeSet<AgentName>>,
) {
for (name, pipeline) in &config.pipelines {
if self.node(&pipeline_id(name)).is_none() {
continue;
}
self.edges.push(Edge {
from: pipeline_id(name),
to: agent_id(&pipeline.entry),
label: None,
style: EdgeStyle::Entry,
back: false,
from_y: 0.0,
to_y: 0.0,
gutter: None,
hook_x: None,
floor: None,
});
}
let mut drawn = Vec::new();
for route in &config.routes {
for from in &route.from {
for to in &route.to {
if members
.is_some_and(|members| !members.contains(from) || !members.contains(to))
{
continue;
}
let pair = (agent_id(from), agent_id(to));
if drawn.contains(&pair) {
continue;
}
drawn.push(pair.clone());
let (style, label) = if route.is_spawn() {
(EdgeStyle::Spawn, Some("spawn".to_owned()))
} else {
match graph.join_for(to) {
Some(spec) if spec.upstreams.contains(from) => (
EdgeStyle::Joined,
Some(
match spec.join {
Join::All => "all",
Join::Any => "any",
}
.to_owned(),
),
),
Some(_) => (EdgeStyle::Bypass, None),
None => (EdgeStyle::Plain, None),
}
};
let back = self.layer_of(&pair.0) >= self.layer_of(&pair.1);
self.edges.push(Edge {
from: pair.0,
to: pair.1,
label,
style,
back,
from_y: 0.0,
to_y: 0.0,
gutter: None,
hook_x: None,
floor: None,
});
}
}
}
}
fn layer_of(&self, id: &str) -> usize {
self.node(id).map_or(0, |node| node.layer)
}
fn order_by_barycentre(&mut self) {
let positions: BTreeMap<String, f64> = self
.nodes
.iter()
.map(|node| (node.id.clone(), node.centre().1))
.collect();
let mut keys: BTreeMap<String, (f64, String)> = BTreeMap::new();
for node in &self.nodes {
let incoming: Vec<f64> = self
.edges
.iter()
.filter(|edge| edge.to == node.id && !edge.back)
.filter_map(|edge| positions.get(&edge.from).copied())
.collect();
let bary = if incoming.is_empty() {
node.centre().1
} else {
incoming.iter().sum::<f64>() / precise(incoming.len())
};
keys.insert(node.id.clone(), (bary, node.label.clone()));
}
let mut by_layer: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
for (index, node) in self.nodes.iter().enumerate() {
by_layer.entry(node.layer).or_default().push(index);
}
for indices in by_layer.into_values() {
let mut ordered = indices.clone();
ordered.sort_by(|left, right| {
let a = &keys[&self.nodes[*left].id];
let b = &keys[&self.nodes[*right].id];
a.0.total_cmp(&b.0).then_with(|| a.1.cmp(&b.1))
});
let ys: Vec<f64> = indices.iter().map(|i| self.nodes[*i].y).collect();
for (slot, index) in ordered.into_iter().enumerate() {
self.nodes[index].y = ys[slot];
}
}
}
fn size(&mut self) {
let tallest = self
.nodes
.iter()
.map(|node| node.y + node.h)
.fold(0.0_f64, f64::max);
let mut bottoms: BTreeMap<usize, f64> = BTreeMap::new();
for node in &self.nodes {
let bottom = bottoms.entry(node.layer).or_insert(0.0);
*bottom = bottom.max(node.y + node.h);
}
for node in &mut self.nodes {
node.y += (tallest - bottoms[&node.layer]) / 2.0;
}
let deepest = self.route_returns(tallest);
self.assign_ports();
self.width = self
.nodes
.iter()
.map(|node| node.x + node.w)
.fold(0.0_f64, f64::max)
+ MARGIN;
self.height = tallest.max(deepest) + MARGIN;
}
fn assign_ports(&mut self) {
let centres: BTreeMap<String, f64> = self
.nodes
.iter()
.map(|node| (node.id.clone(), node.centre().1))
.collect();
let boxes: BTreeMap<String, (f64, f64)> = self
.nodes
.iter()
.map(|node| (node.id.clone(), (node.y, node.h)))
.collect();
let mut leaving: BTreeMap<String, Vec<usize>> = BTreeMap::new();
let mut arriving: BTreeMap<String, Vec<usize>> = BTreeMap::new();
for (index, edge) in self.edges.iter().enumerate() {
if edge.back {
continue;
}
leaving.entry(edge.from.clone()).or_default().push(index);
arriving.entry(edge.to.clone()).or_default().push(index);
}
for (id, mut indices) in leaving {
indices.sort_by(|left, right| {
let a = centres.get(&self.edges[*left].to).copied().unwrap_or(0.0);
let b = centres.get(&self.edges[*right].to).copied().unwrap_or(0.0);
a.total_cmp(&b)
});
let Some(&(top, height)) = boxes.get(&id) else {
continue;
};
let count = indices.len();
for (slot, index) in indices.into_iter().enumerate() {
self.edges[index].from_y = port(top, height, slot, count);
}
}
for (id, mut indices) in arriving {
indices.sort_by(|left, right| {
let a = centres.get(&self.edges[*left].from).copied().unwrap_or(0.0);
let b = centres
.get(&self.edges[*right].from)
.copied()
.unwrap_or(0.0);
a.total_cmp(&b)
});
let Some(&(top, height)) = boxes.get(&id) else {
continue;
};
let count = indices.len();
for (slot, index) in indices.into_iter().enumerate() {
self.edges[index].to_y = port(top, height, slot, count);
}
}
}
fn route_returns(&mut self, floor_start: f64) -> f64 {
let mut lanes: Vec<(String, String)> = self
.edges
.iter()
.filter(|edge| edge.back)
.map(|edge| (edge.from.clone(), edge.to.clone()))
.collect();
lanes.sort_by_key(|(from, to)| {
let span = self
.node(from)
.zip(self.node(to))
.map_or(0, |(f, t)| f.layer.abs_diff(t.layer));
(span, from.clone(), to.clone())
});
let boxes: BTreeMap<String, (f64, f64)> = self
.nodes
.iter()
.map(|node| (node.id.clone(), (node.x, node.w)))
.collect();
let mut per_target: BTreeMap<String, usize> = BTreeMap::new();
let mut deepest = floor_start;
for (index, key) in lanes.iter().enumerate() {
let depth = floor_start + RETURN_GAP * (precise(index) + 1.0);
deepest = deepest.max(depth);
let seen = per_target.entry(key.1.clone()).or_insert(0);
let nth = *seen;
*seen += 1;
let (left, width) = boxes.get(&key.1).copied().unwrap_or((0.0, NODE_W));
let gutter = (left - GUTTER_INSET - GUTTER_STEP * precise(nth)).max(GUTTER_MIN);
let hook = left + width * 0.25 + width * 0.2 * precise(nth);
if let Some(edge) = self
.edges
.iter_mut()
.find(|edge| edge.back && (edge.from.clone(), edge.to.clone()) == *key)
{
edge.floor = Some(depth);
edge.gutter = Some(gutter);
edge.hook_x = Some(hook.min(left + width - CORNER_MARGIN));
}
}
deepest
}
}
fn assign_layers(config: &Config, graph: &RouteGraph, scope: &Scope) -> BTreeMap<AgentName, usize> {
let sources: Vec<AgentName> = match scope.pipeline() {
Some(name) => config
.pipelines
.get(name)
.map(|pipeline| vec![pipeline.entry.clone()])
.unwrap_or_default(),
None => config
.pipelines
.values()
.map(|pipeline| pipeline.entry.clone())
.chain(
config
.agents
.iter()
.filter(|(_, agent)| agent.entry)
.map(|(name, _)| name.clone()),
)
.collect(),
};
let mut layers: BTreeMap<AgentName, usize> = graph
.distances_from(sources.iter())
.into_iter()
.map(|(name, distance)| (name, distance as usize))
.collect();
for (from, to) in graph.spawn_edges() {
let placed = layers.get(from).copied().unwrap_or(0) + 1;
let entry = layers.entry(to.clone()).or_insert(placed);
*entry = (*entry).max(placed);
}
layers
}
fn port(top: f64, height: f64, slot: usize, count: usize) -> f64 {
if count <= 1 {
return top + height / 2.0;
}
let usable = height * 0.7;
let step = usable / precise(count - 1);
top + (height - usable) / 2.0 + step * precise(slot)
}
fn precise(count: usize) -> f64 {
u32::try_from(count).map_or(f64::from(u32::MAX), f64::from)
}
fn agent_id(name: &AgentName) -> String {
format!("a_{name}")
}
fn pipeline_id(name: &PipelineName) -> String {
format!("p_{name}")
}
#[cfg(test)]
mod tests {
use super::*;
fn config(body: &str) -> Config {
Config::from_toml(body, "layout-test.toml").expect("config parses")
}
fn factory() -> Config {
config(
r#"
[layover]
work_dir = "work"
[defaults]
runner = "claude"
[runners.claude]
command = ["claude", "-p"]
[agents.analyst]
prompt = "analyse"
[agents.developer]
prompt = "develop"
[agents.tester]
prompt = "test"
access = "read-only"
[agents.publisher]
prompt = "publish"
[pipelines.triage]
entry = "analyst"
[[routes]]
from = "analyst"
to = "developer"
[[routes]]
from = "developer"
to = "tester"
[[routes]]
from = "tester"
to = "developer"
join = "all"
[[routes]]
from = "developer"
to = "publisher"
"#,
)
}
#[test]
fn work_flows_left_to_right_one_column_per_hop() {
let layout = Layout::build(&factory(), &Live::default());
assert_eq!(layout.node("p_triage").expect("pipeline").layer, 0);
assert_eq!(layout.node("a_analyst").expect("analyst").layer, 1);
assert_eq!(layout.node("a_developer").expect("developer").layer, 2);
assert_eq!(layout.node("a_tester").expect("tester").layer, 3);
}
#[test]
fn a_loop_does_not_hang_the_layout() {
let layout = Layout::build(&factory(), &Live::default());
assert_eq!(layout.nodes.len(), 5);
assert!(layout.width > 0.0 && layout.height > 0.0);
}
#[test]
fn an_edge_pointing_back_towards_the_entry_is_marked_as_a_return_path() {
let layout = Layout::build(&factory(), &Live::default());
let back = layout
.edges
.iter()
.find(|edge| edge.from == "a_tester" && edge.to == "a_developer")
.expect("the review loop exists");
assert!(back.back, "tester sits right of developer, so this returns");
assert_eq!(back.style, EdgeStyle::Joined);
assert_eq!(back.label.as_deref(), Some("all"));
}
#[test]
fn a_sender_the_barrier_does_not_name_is_marked_as_bypassing_it() {
let config = config(
r#"
[layover]
work_dir = "work"
[defaults]
runner = "claude"
[runners.claude]
command = ["claude", "-p"]
[agents.scanner]
prompt = "scan"
[agents.left]
prompt = "left"
[agents.right]
prompt = "right"
[agents.collector]
prompt = "collect"
[pipelines.go]
entry = "scanner"
[[routes]]
from = ["left", "right"]
to = "collector"
join = "all"
[[routes]]
from = "scanner"
to = "collector"
"#,
);
let layout = Layout::build(&config, &Live::default());
let bypass = layout
.edges
.iter()
.find(|edge| edge.from == "a_scanner" && edge.to == "a_collector")
.expect("scanner may send to collector");
assert_eq!(bypass.style, EdgeStyle::Bypass);
assert_eq!(
bypass.label, None,
"it does not wait, so it has no condition"
);
}
#[test]
fn a_joined_agent_is_drawn_as_a_gate() {
let layout = Layout::build(&factory(), &Live::default());
assert_eq!(
layout.node("a_developer").expect("developer").shape,
Shape::Gate
);
assert_eq!(layout.node("a_analyst").expect("analyst").shape, Shape::Box);
}
#[test]
fn nodes_in_a_column_never_overlap() {
let layout = Layout::build(&factory(), &Live::default());
for layer in 0..4 {
let mut boxes: Vec<(f64, f64)> = layout
.nodes
.iter()
.filter(|node| node.layer == layer)
.map(|node| (node.y, node.y + node.h))
.collect();
boxes.sort_by(|a, b| a.0.total_cmp(&b.0));
for pair in boxes.windows(2) {
assert!(
pair[1].0 >= pair[0].1,
"layer {layer} has overlapping nodes: {pair:?}"
);
}
}
}
#[test]
fn every_node_sits_inside_the_reported_extent() {
let layout = Layout::build(&factory(), &Live::default());
for node in &layout.nodes {
assert!(node.x >= 0.0 && node.y >= 0.0, "{} is off-canvas", node.id);
assert!(node.x + node.w <= layout.width, "{} overflows", node.id);
assert!(node.y + node.h <= layout.height, "{} overflows", node.id);
}
}
#[test]
fn return_paths_sit_inside_the_reported_extent_too() {
let layout = Layout::build(&factory(), &Live::default());
let returns: Vec<&Edge> = layout.edges.iter().filter(|edge| edge.back).collect();
assert!(!returns.is_empty(), "the factory has a review loop to test");
for edge in returns {
let floor = edge.floor.expect("a return path is given a lane");
assert!(
floor <= layout.height,
"{} -> {} dips to {floor} but the drawing is only {} tall",
edge.from,
edge.to,
layout.height
);
}
}
#[test]
fn several_returns_to_one_agent_climb_at_different_points() {
let layout = Layout::build(&review_loop(), &Live::default());
let returns: Vec<f64> = layout
.edges
.iter()
.filter(|edge| edge.back && edge.to == "a_dev")
.filter_map(|edge| edge.gutter)
.collect();
assert!(returns.len() >= 2, "the factory has a review loop");
for pair in returns.windows(2) {
assert!(
(pair[0] - pair[1]).abs() > 1.0,
"two returns share a gutter: {returns:?}"
);
}
let hooks: Vec<f64> = layout
.edges
.iter()
.filter(|edge| edge.back && edge.to == "a_dev")
.filter_map(|edge| edge.hook_x)
.collect();
for pair in hooks.windows(2) {
assert!(
(pair[0] - pair[1]).abs() > 1.0,
"two returns hook in at the same point: {hooks:?}"
);
}
}
#[test]
fn a_return_path_hooks_inside_the_agent_it_returns_to() {
let layout = Layout::build(&review_loop(), &Live::default());
let developer = layout.node("a_dev").expect("dev");
for edge in layout.edges.iter().filter(|edge| edge.back) {
let hook = edge.hook_x.expect("a return path is given a hook");
assert!(
hook > developer.x && hook < developer.x + developer.w,
"the hook must land on the node, not beside it: {hook}"
);
}
}
fn review_loop() -> Config {
config(
r#"
[layover]
work_dir = "work"
[defaults]
runner = "claude"
[runners.claude]
command = ["claude", "-p"]
[agents.dev]
prompt = "develop"
[agents.tester]
prompt = "test"
[agents.reviewer]
prompt = "review"
[pipelines.go]
entry = "dev"
[[routes]]
from = "dev"
to = ["tester", "reviewer"]
[[routes]]
from = ["tester", "reviewer"]
to = "dev"
join = "all"
"#,
)
}
#[test]
fn two_return_paths_are_given_lanes_of_their_own() {
let config = config(
r#"
[layover]
work_dir = "work"
[defaults]
runner = "claude"
[runners.claude]
command = ["claude", "-p"]
[agents.dev]
prompt = "develop"
[agents.tester]
prompt = "test"
[agents.reviewer]
prompt = "review"
[pipelines.go]
entry = "dev"
[[routes]]
from = "dev"
to = ["tester", "reviewer"]
[[routes]]
from = ["tester", "reviewer"]
to = "dev"
join = "all"
"#,
);
let layout = Layout::build(&config, &Live::default());
let mut floors: Vec<f64> = layout
.edges
.iter()
.filter(|edge| edge.back)
.filter_map(|edge| edge.floor)
.collect();
floors.sort_by(f64::total_cmp);
assert_eq!(floors.len(), 2);
assert!(
(floors[1] - floors[0]).abs() > 1.0,
"the two loops share a lane: {floors:?}"
);
}
#[test]
fn a_workflow_is_laid_out_from_its_own_entry_only() {
let config = config(
r#"
[layover]
work_dir = "work"
[defaults]
runner = "claude"
[runners.claude]
command = ["claude", "-p"]
[agents.analyst]
prompt = "analyse"
[agents.publisher]
prompt = "publish"
[agents.follower]
prompt = "follow"
[pipelines.triage]
entry = "analyst"
[pipelines.follow_up]
entry = "follower"
[[routes]]
from = "analyst"
to = "publisher"
[[routes]]
from = "publisher"
to = "follower"
"#,
);
let triage = Layout::scoped(&config, &Live::default(), &Scope::Pipeline("triage".into()));
let publisher = triage.node("a_publisher").expect("publisher");
let follower = triage.node("a_follower").expect("follower");
assert!(
follower.layer > publisher.layer,
"the follower is reached through the publisher here, so it must come after it: \
publisher at {}, follower at {}",
publisher.layer,
follower.layer
);
}
#[test]
fn edges_leaving_one_node_meet_it_at_different_points() {
let layout = Layout::build(&factory(), &Live::default());
let leaving: Vec<f64> = layout
.edges
.iter()
.filter(|edge| edge.from == "a_developer" && !edge.back)
.map(|edge| edge.from_y)
.collect();
assert!(leaving.len() >= 2, "the developer fans out");
for pair in leaving.windows(2) {
assert!(
(pair[0] - pair[1]).abs() > 1.0,
"two edges share an exit point: {leaving:?}"
);
}
}
#[test]
fn a_node_with_one_edge_still_meets_it_in_the_middle() {
let layout = Layout::build(&factory(), &Live::default());
let analyst = layout.node("a_analyst").expect("analyst");
let only = layout
.edges
.iter()
.find(|edge| edge.from == "a_analyst" && !edge.back)
.expect("analyst sends somewhere");
assert!((only.from_y - analyst.centre().1).abs() < 0.001);
}
#[test]
fn a_workflow_can_be_drawn_on_its_own() {
let config = two_workflows();
let sweep = Layout::scoped(&config, &Live::default(), &Scope::Pipeline("sweep".into()));
assert!(sweep.node("p_sweep").is_some());
assert!(sweep.node("a_sweeper").is_some());
assert!(
sweep.node("a_pr_reviewer").is_some(),
"a spawned reviewer is part of the sweep"
);
assert!(sweep.node("p_build").is_none(), "the other way in is not");
assert!(sweep.node("a_developer").is_none());
}
#[test]
fn an_agent_in_two_workflows_appears_in_both() {
let config = two_workflows();
for pipeline in ["build", "release"] {
let drawn =
Layout::scoped(&config, &Live::default(), &Scope::Pipeline(pipeline.into()));
assert!(
drawn.node("a_developer").is_some(),
"developer missing from {pipeline}"
);
}
}
#[test]
fn drawing_everything_is_still_the_default() {
let config = two_workflows();
let all = Layout::build(&config, &Live::default());
assert!(all.node("p_sweep").is_some());
assert!(all.node("p_build").is_some());
assert!(all.node("a_pr_reviewer").is_some());
}
fn two_workflows() -> Config {
config(
r#"
[layover]
work_dir = "work"
[defaults]
runner = "claude"
[runners.claude]
command = ["claude", "-p"]
[agents.sweeper]
prompt = "sweep"
[agents.pr_reviewer]
prompt = "review one"
[agents.developer]
prompt = "develop"
[agents.publisher]
prompt = "publish"
[pipelines.sweep]
entry = "sweeper"
[pipelines.build]
entry = "developer"
[pipelines.release]
entry = "developer"
[[routes]]
from = "sweeper"
to = "pr_reviewer"
mode = "spawn"
[[routes]]
from = "developer"
to = "publisher"
"#,
)
}
#[test]
fn a_spawn_target_is_drawn_right_of_the_agent_that_spawns_it() {
let config = config(
r#"
[layover]
work_dir = "work"
[defaults]
runner = "claude"
[runners.claude]
command = ["claude", "-p"]
[agents.sweeper]
prompt = "sweep"
[agents.pr_reviewer]
prompt = "review one"
[pipelines.sweep]
entry = "sweeper"
[[routes]]
from = "sweeper"
to = "pr_reviewer"
mode = "spawn"
"#,
);
let layout = Layout::build(&config, &Live::default());
let sweeper = layout.node("a_sweeper").expect("sweeper");
let reviewer = layout.node("a_pr_reviewer").expect("reviewer");
assert!(
reviewer.layer > sweeper.layer,
"a spawn should still flow rightwards: sweeper at {}, reviewer at {}",
sweeper.layer,
reviewer.layer
);
let edge = layout
.edges
.iter()
.find(|edge| edge.to == "a_pr_reviewer")
.expect("the spawn edge exists");
assert!(!edge.back, "and must not be drawn as a return path");
}
#[test]
fn an_agent_no_pipeline_can_reach_is_still_drawn() {
let config = config(
r#"
[layover]
work_dir = "work"
[defaults]
runner = "claude"
[runners.claude]
command = ["claude", "-p"]
[agents.reachable]
prompt = "work"
[agents.orphan]
prompt = "nobody routes here"
[pipelines.go]
entry = "reachable"
"#,
);
let layout = Layout::build(&config, &Live::default());
assert!(layout.node("a_orphan").is_some());
}
#[test]
fn live_state_lands_on_the_right_node() {
let live = Live::default().with("developer", Activity::Running);
let layout = Layout::build(&factory(), &live);
assert_eq!(
layout.node("a_developer").expect("developer").activity,
Some(Activity::Running)
);
assert_eq!(layout.node("a_analyst").expect("analyst").activity, None);
}
#[test]
fn an_empty_factory_lays_out_without_panicking() {
let config = config(
r#"
[layover]
work_dir = "work"
[defaults]
runner = "claude"
[runners.claude]
command = ["claude", "-p"]
[agents.only]
prompt = "think"
entry = true
"#,
);
let layout = Layout::build(&config, &Live::default());
assert_eq!(layout.nodes.len(), 1);
assert!(layout.edges.is_empty());
}
}