1use std::collections::{BTreeMap, BTreeSet};
22
23use crate::agent::{Access, AgentName};
24use crate::config::Config;
25use crate::diagram::{Activity, Live, Scope};
26use crate::graph::RouteGraph;
27use crate::pipeline::PipelineName;
28use crate::route::Join;
29
30const NODE_W: f64 = 168.0;
32const NODE_H: f64 = 56.0;
34const COL_GAP: f64 = 96.0;
36const ROW_GAP: f64 = 32.0;
38const MARGIN: f64 = 32.0;
40const RETURN_GAP: f64 = 34.0;
42const GUTTER_INSET: f64 = 44.0;
44const GUTTER_STEP: f64 = 18.0;
46const GUTTER_MIN: f64 = 12.0;
48const CORNER_MARGIN: f64 = 20.0;
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum NodeKind {
54 Pipeline,
56 Agent,
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum Shape {
63 Box,
65 Gate,
67}
68
69#[derive(Debug, Clone, PartialEq)]
71pub struct Node {
72 pub id: String,
74 pub label: String,
76 pub subtitle: Option<String>,
78 pub kind: NodeKind,
80 pub shape: Shape,
82 pub activity: Option<Activity>,
84 pub layer: usize,
86 pub x: f64,
88 pub y: f64,
90 pub w: f64,
92 pub h: f64,
94}
95
96impl Node {
97 #[must_use]
99 pub fn exit(&self) -> (f64, f64) {
100 (self.x + self.w, self.y + self.h / 2.0)
101 }
102
103 #[must_use]
105 pub fn entry(&self) -> (f64, f64) {
106 (self.x, self.y + self.h / 2.0)
107 }
108
109 #[must_use]
111 pub fn centre(&self) -> (f64, f64) {
112 (self.x + self.w / 2.0, self.y + self.h / 2.0)
113 }
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118pub enum EdgeStyle {
119 Plain,
121 Entry,
123 Joined,
125 Bypass,
128 Spawn,
130}
131
132#[derive(Debug, Clone, PartialEq)]
134pub struct Edge {
135 pub from: String,
137 pub to: String,
139 pub label: Option<String>,
141 pub style: EdgeStyle,
143 pub back: bool,
145 pub from_y: f64,
152 pub to_y: f64,
154 pub gutter: Option<f64>,
159 pub hook_x: Option<f64>,
161 pub floor: Option<f64>,
167}
168
169#[derive(Debug, Clone, Default, PartialEq)]
171pub struct Layout {
172 pub nodes: Vec<Node>,
174 pub edges: Vec<Edge>,
176 pub width: f64,
178 pub height: f64,
180}
181
182impl Layout {
183 #[must_use]
185 pub fn node(&self, id: &str) -> Option<&Node> {
186 self.nodes.iter().find(|node| node.id == id)
187 }
188
189 #[must_use]
191 pub fn build(config: &Config, live: &Live) -> Self {
192 Self::scoped(config, live, &Scope::Everything)
193 }
194
195 #[must_use]
197 pub fn scoped(config: &Config, live: &Live, scope: &Scope) -> Self {
198 let graph = RouteGraph::from_config(config);
199 let members = scope.pipeline().and_then(|name| {
200 config
201 .pipelines
202 .get(name)
203 .map(|pipeline| graph.workflow_from(&pipeline.entry))
204 });
205 let layers = assign_layers(config, &graph, scope);
206
207 let mut layout = Self::default();
208 layout.place(config, live, &graph, &layers, scope, members.as_ref());
209 layout.connect(config, &graph, members.as_ref());
210 layout.order_by_barycentre();
211 layout.size();
212 layout
213 }
214
215 fn place(
217 &mut self,
218 config: &Config,
219 live: &Live,
220 graph: &RouteGraph,
221 layers: &BTreeMap<AgentName, usize>,
222 scope: &Scope,
223 members: Option<&BTreeSet<AgentName>>,
224 ) {
225 let mut columns: BTreeMap<usize, Vec<Node>> = BTreeMap::new();
226
227 for (name, pipeline) in &config.pipelines {
228 if scope.pipeline().is_some_and(|wanted| wanted != name) {
229 continue;
230 }
231 columns.entry(0).or_default().push(Node {
232 id: pipeline_id(name),
233 label: name.as_str().to_owned(),
234 subtitle: Some(pipeline.trigger.to_string()),
235 kind: NodeKind::Pipeline,
236 shape: Shape::Box,
237 activity: None,
238 layer: 0,
239 x: 0.0,
240 y: 0.0,
241 w: NODE_W,
242 h: NODE_H,
243 });
244 }
245
246 for (name, agent) in &config.agents {
247 if members.is_some_and(|members| !members.contains(name)) {
248 continue;
249 }
250 let layer = layers.get(name).copied().unwrap_or(0) + 1;
253 columns.entry(layer).or_default().push(Node {
254 id: agent_id(name),
255 label: name.as_str().to_owned(),
256 subtitle: (agent.access == Access::ReadOnly).then(|| "read-only".to_owned()),
257 kind: NodeKind::Agent,
258 shape: if graph.join_for(name).is_some() {
259 Shape::Gate
260 } else {
261 Shape::Box
262 },
263 activity: live.activity.get(name).copied(),
264 layer,
265 x: 0.0,
266 y: 0.0,
267 w: NODE_W,
268 h: NODE_H,
269 });
270 }
271
272 for (layer, mut nodes) in columns {
273 let x = MARGIN + precise(layer) * (NODE_W + COL_GAP);
274 for (row, node) in nodes.iter_mut().enumerate() {
275 node.x = x;
276 node.y = MARGIN + precise(row) * (NODE_H + ROW_GAP);
277 }
278 self.nodes.append(&mut nodes);
279 }
280 }
281
282 fn connect(
284 &mut self,
285 config: &Config,
286 graph: &RouteGraph,
287 members: Option<&BTreeSet<AgentName>>,
288 ) {
289 for (name, pipeline) in &config.pipelines {
290 if self.node(&pipeline_id(name)).is_none() {
291 continue;
292 }
293 self.edges.push(Edge {
294 from: pipeline_id(name),
295 to: agent_id(&pipeline.entry),
296 label: None,
297 style: EdgeStyle::Entry,
298 back: false,
299 from_y: 0.0,
300 to_y: 0.0,
301 gutter: None,
302 hook_x: None,
303 floor: None,
304 });
305 }
306
307 let mut drawn = Vec::new();
308 for route in &config.routes {
309 for from in &route.from {
310 for to in &route.to {
311 if members
312 .is_some_and(|members| !members.contains(from) || !members.contains(to))
313 {
314 continue;
315 }
316 let pair = (agent_id(from), agent_id(to));
317 if drawn.contains(&pair) {
318 continue;
319 }
320 drawn.push(pair.clone());
321
322 let (style, label) = if route.is_spawn() {
329 (EdgeStyle::Spawn, Some("spawn".to_owned()))
330 } else {
331 match graph.join_for(to) {
332 Some(spec) if spec.upstreams.contains(from) => (
333 EdgeStyle::Joined,
334 Some(
335 match spec.join {
336 Join::All => "all",
337 Join::Any => "any",
338 }
339 .to_owned(),
340 ),
341 ),
342 Some(_) => (EdgeStyle::Bypass, None),
343 None => (EdgeStyle::Plain, None),
344 }
345 };
346
347 let back = self.layer_of(&pair.0) >= self.layer_of(&pair.1);
348 self.edges.push(Edge {
349 from: pair.0,
350 to: pair.1,
351 label,
352 style,
353 back,
354 from_y: 0.0,
355 to_y: 0.0,
356 gutter: None,
357 hook_x: None,
358 floor: None,
359 });
360 }
361 }
362 }
363 }
364
365 fn layer_of(&self, id: &str) -> usize {
367 self.node(id).map_or(0, |node| node.layer)
368 }
369
370 fn order_by_barycentre(&mut self) {
376 let positions: BTreeMap<String, f64> = self
377 .nodes
378 .iter()
379 .map(|node| (node.id.clone(), node.centre().1))
380 .collect();
381
382 let mut keys: BTreeMap<String, (f64, String)> = BTreeMap::new();
383 for node in &self.nodes {
384 let incoming: Vec<f64> = self
385 .edges
386 .iter()
387 .filter(|edge| edge.to == node.id && !edge.back)
388 .filter_map(|edge| positions.get(&edge.from).copied())
389 .collect();
390
391 let bary = if incoming.is_empty() {
394 node.centre().1
395 } else {
396 incoming.iter().sum::<f64>() / precise(incoming.len())
397 };
398 keys.insert(node.id.clone(), (bary, node.label.clone()));
399 }
400
401 let mut by_layer: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
402 for (index, node) in self.nodes.iter().enumerate() {
403 by_layer.entry(node.layer).or_default().push(index);
404 }
405
406 for indices in by_layer.into_values() {
407 let mut ordered = indices.clone();
408 ordered.sort_by(|left, right| {
409 let a = &keys[&self.nodes[*left].id];
410 let b = &keys[&self.nodes[*right].id];
411 a.0.total_cmp(&b.0).then_with(|| a.1.cmp(&b.1))
412 });
413
414 let ys: Vec<f64> = indices.iter().map(|i| self.nodes[*i].y).collect();
415 for (slot, index) in ordered.into_iter().enumerate() {
416 self.nodes[index].y = ys[slot];
417 }
418 }
419 }
420
421 fn size(&mut self) {
423 let tallest = self
424 .nodes
425 .iter()
426 .map(|node| node.y + node.h)
427 .fold(0.0_f64, f64::max);
428
429 let mut bottoms: BTreeMap<usize, f64> = BTreeMap::new();
430 for node in &self.nodes {
431 let bottom = bottoms.entry(node.layer).or_insert(0.0);
432 *bottom = bottom.max(node.y + node.h);
433 }
434 for node in &mut self.nodes {
435 node.y += (tallest - bottoms[&node.layer]) / 2.0;
436 }
437
438 let deepest = self.route_returns(tallest);
439 self.assign_ports();
440
441 self.width = self
442 .nodes
443 .iter()
444 .map(|node| node.x + node.w)
445 .fold(0.0_f64, f64::max)
446 + MARGIN;
447 self.height = tallest.max(deepest) + MARGIN;
448 }
449
450 fn assign_ports(&mut self) {
456 let centres: BTreeMap<String, f64> = self
457 .nodes
458 .iter()
459 .map(|node| (node.id.clone(), node.centre().1))
460 .collect();
461 let boxes: BTreeMap<String, (f64, f64)> = self
462 .nodes
463 .iter()
464 .map(|node| (node.id.clone(), (node.y, node.h)))
465 .collect();
466
467 let mut leaving: BTreeMap<String, Vec<usize>> = BTreeMap::new();
468 let mut arriving: BTreeMap<String, Vec<usize>> = BTreeMap::new();
469 for (index, edge) in self.edges.iter().enumerate() {
470 if edge.back {
471 continue;
472 }
473 leaving.entry(edge.from.clone()).or_default().push(index);
474 arriving.entry(edge.to.clone()).or_default().push(index);
475 }
476
477 for (id, mut indices) in leaving {
478 indices.sort_by(|left, right| {
479 let a = centres.get(&self.edges[*left].to).copied().unwrap_or(0.0);
480 let b = centres.get(&self.edges[*right].to).copied().unwrap_or(0.0);
481 a.total_cmp(&b)
482 });
483 let Some(&(top, height)) = boxes.get(&id) else {
484 continue;
485 };
486 let count = indices.len();
487 for (slot, index) in indices.into_iter().enumerate() {
488 self.edges[index].from_y = port(top, height, slot, count);
489 }
490 }
491
492 for (id, mut indices) in arriving {
493 indices.sort_by(|left, right| {
494 let a = centres.get(&self.edges[*left].from).copied().unwrap_or(0.0);
495 let b = centres
496 .get(&self.edges[*right].from)
497 .copied()
498 .unwrap_or(0.0);
499 a.total_cmp(&b)
500 });
501 let Some(&(top, height)) = boxes.get(&id) else {
502 continue;
503 };
504 let count = indices.len();
505 for (slot, index) in indices.into_iter().enumerate() {
506 self.edges[index].to_y = port(top, height, slot, count);
507 }
508 }
509 }
510
511 fn route_returns(&mut self, floor_start: f64) -> f64 {
517 let mut lanes: Vec<(String, String)> = self
518 .edges
519 .iter()
520 .filter(|edge| edge.back)
521 .map(|edge| (edge.from.clone(), edge.to.clone()))
522 .collect();
523 lanes.sort_by_key(|(from, to)| {
525 let span = self
526 .node(from)
527 .zip(self.node(to))
528 .map_or(0, |(f, t)| f.layer.abs_diff(t.layer));
529 (span, from.clone(), to.clone())
530 });
531
532 let boxes: BTreeMap<String, (f64, f64)> = self
533 .nodes
534 .iter()
535 .map(|node| (node.id.clone(), (node.x, node.w)))
536 .collect();
537
538 let mut per_target: BTreeMap<String, usize> = BTreeMap::new();
541
542 let mut deepest = floor_start;
543 for (index, key) in lanes.iter().enumerate() {
544 let depth = floor_start + RETURN_GAP * (precise(index) + 1.0);
545 deepest = deepest.max(depth);
546
547 let seen = per_target.entry(key.1.clone()).or_insert(0);
548 let nth = *seen;
549 *seen += 1;
550
551 let (left, width) = boxes.get(&key.1).copied().unwrap_or((0.0, NODE_W));
552 let gutter = (left - GUTTER_INSET - GUTTER_STEP * precise(nth)).max(GUTTER_MIN);
553 let hook = left + width * 0.25 + width * 0.2 * precise(nth);
554
555 if let Some(edge) = self
556 .edges
557 .iter_mut()
558 .find(|edge| edge.back && (edge.from.clone(), edge.to.clone()) == *key)
559 {
560 edge.floor = Some(depth);
561 edge.gutter = Some(gutter);
562 edge.hook_x = Some(hook.min(left + width - CORNER_MARGIN));
563 }
564 }
565 deepest
566 }
567}
568
569fn assign_layers(config: &Config, graph: &RouteGraph, scope: &Scope) -> BTreeMap<AgentName, usize> {
581 let sources: Vec<AgentName> = match scope.pipeline() {
582 Some(name) => config
583 .pipelines
584 .get(name)
585 .map(|pipeline| vec![pipeline.entry.clone()])
586 .unwrap_or_default(),
587 None => config
588 .pipelines
589 .values()
590 .map(|pipeline| pipeline.entry.clone())
591 .chain(
592 config
593 .agents
594 .iter()
595 .filter(|(_, agent)| agent.entry)
596 .map(|(name, _)| name.clone()),
597 )
598 .collect(),
599 };
600
601 let mut layers: BTreeMap<AgentName, usize> = graph
602 .distances_from(sources.iter())
603 .into_iter()
604 .map(|(name, distance)| (name, distance as usize))
605 .collect();
606
607 for (from, to) in graph.spawn_edges() {
617 let placed = layers.get(from).copied().unwrap_or(0) + 1;
618 let entry = layers.entry(to.clone()).or_insert(placed);
619 *entry = (*entry).max(placed);
620 }
621
622 layers
623}
624
625fn port(top: f64, height: f64, slot: usize, count: usize) -> f64 {
630 if count <= 1 {
631 return top + height / 2.0;
632 }
633 let usable = height * 0.7;
634 let step = usable / precise(count - 1);
635 top + (height - usable) / 2.0 + step * precise(slot)
636}
637
638fn precise(count: usize) -> f64 {
644 u32::try_from(count).map_or(f64::from(u32::MAX), f64::from)
645}
646
647fn agent_id(name: &AgentName) -> String {
649 format!("a_{name}")
650}
651
652fn pipeline_id(name: &PipelineName) -> String {
654 format!("p_{name}")
655}
656
657#[cfg(test)]
658mod tests {
659 use super::*;
660
661 fn config(body: &str) -> Config {
662 Config::from_toml(body, "layout-test.toml").expect("config parses")
663 }
664
665 fn factory() -> Config {
666 config(
667 r#"
668 [layover]
669 work_dir = "work"
670
671 [defaults]
672 runner = "claude"
673
674 [runners.claude]
675 command = ["claude", "-p"]
676
677 [agents.analyst]
678 prompt = "analyse"
679
680 [agents.developer]
681 prompt = "develop"
682
683 [agents.tester]
684 prompt = "test"
685 access = "read-only"
686
687 [agents.publisher]
688 prompt = "publish"
689
690 [pipelines.triage]
691 entry = "analyst"
692
693 [[routes]]
694 from = "analyst"
695 to = "developer"
696
697 [[routes]]
698 from = "developer"
699 to = "tester"
700
701 [[routes]]
702 from = "tester"
703 to = "developer"
704 join = "all"
705
706 [[routes]]
707 from = "developer"
708 to = "publisher"
709 "#,
710 )
711 }
712
713 #[test]
714 fn work_flows_left_to_right_one_column_per_hop() {
715 let layout = Layout::build(&factory(), &Live::default());
716
717 assert_eq!(layout.node("p_triage").expect("pipeline").layer, 0);
718 assert_eq!(layout.node("a_analyst").expect("analyst").layer, 1);
719 assert_eq!(layout.node("a_developer").expect("developer").layer, 2);
720 assert_eq!(layout.node("a_tester").expect("tester").layer, 3);
721 }
722
723 #[test]
724 fn a_loop_does_not_hang_the_layout() {
725 let layout = Layout::build(&factory(), &Live::default());
729
730 assert_eq!(layout.nodes.len(), 5);
731 assert!(layout.width > 0.0 && layout.height > 0.0);
732 }
733
734 #[test]
735 fn an_edge_pointing_back_towards_the_entry_is_marked_as_a_return_path() {
736 let layout = Layout::build(&factory(), &Live::default());
737
738 let back = layout
739 .edges
740 .iter()
741 .find(|edge| edge.from == "a_tester" && edge.to == "a_developer")
742 .expect("the review loop exists");
743
744 assert!(back.back, "tester sits right of developer, so this returns");
745 assert_eq!(back.style, EdgeStyle::Joined);
746 assert_eq!(back.label.as_deref(), Some("all"));
747 }
748
749 #[test]
750 fn a_sender_the_barrier_does_not_name_is_marked_as_bypassing_it() {
751 let config = config(
752 r#"
753 [layover]
754 work_dir = "work"
755
756 [defaults]
757 runner = "claude"
758
759 [runners.claude]
760 command = ["claude", "-p"]
761
762 [agents.scanner]
763 prompt = "scan"
764
765 [agents.left]
766 prompt = "left"
767
768 [agents.right]
769 prompt = "right"
770
771 [agents.collector]
772 prompt = "collect"
773
774 [pipelines.go]
775 entry = "scanner"
776
777 [[routes]]
778 from = ["left", "right"]
779 to = "collector"
780 join = "all"
781
782 [[routes]]
783 from = "scanner"
784 to = "collector"
785 "#,
786 );
787
788 let layout = Layout::build(&config, &Live::default());
789 let bypass = layout
790 .edges
791 .iter()
792 .find(|edge| edge.from == "a_scanner" && edge.to == "a_collector")
793 .expect("scanner may send to collector");
794
795 assert_eq!(bypass.style, EdgeStyle::Bypass);
796 assert_eq!(
797 bypass.label, None,
798 "it does not wait, so it has no condition"
799 );
800 }
801
802 #[test]
803 fn a_joined_agent_is_drawn_as_a_gate() {
804 let layout = Layout::build(&factory(), &Live::default());
805
806 assert_eq!(
807 layout.node("a_developer").expect("developer").shape,
808 Shape::Gate
809 );
810 assert_eq!(layout.node("a_analyst").expect("analyst").shape, Shape::Box);
811 }
812
813 #[test]
814 fn nodes_in_a_column_never_overlap() {
815 let layout = Layout::build(&factory(), &Live::default());
816
817 for layer in 0..4 {
818 let mut boxes: Vec<(f64, f64)> = layout
819 .nodes
820 .iter()
821 .filter(|node| node.layer == layer)
822 .map(|node| (node.y, node.y + node.h))
823 .collect();
824 boxes.sort_by(|a, b| a.0.total_cmp(&b.0));
825
826 for pair in boxes.windows(2) {
827 assert!(
828 pair[1].0 >= pair[0].1,
829 "layer {layer} has overlapping nodes: {pair:?}"
830 );
831 }
832 }
833 }
834
835 #[test]
836 fn every_node_sits_inside_the_reported_extent() {
837 let layout = Layout::build(&factory(), &Live::default());
839
840 for node in &layout.nodes {
841 assert!(node.x >= 0.0 && node.y >= 0.0, "{} is off-canvas", node.id);
842 assert!(node.x + node.w <= layout.width, "{} overflows", node.id);
843 assert!(node.y + node.h <= layout.height, "{} overflows", node.id);
844 }
845 }
846
847 #[test]
848 fn return_paths_sit_inside_the_reported_extent_too() {
849 let layout = Layout::build(&factory(), &Live::default());
853
854 let returns: Vec<&Edge> = layout.edges.iter().filter(|edge| edge.back).collect();
855 assert!(!returns.is_empty(), "the factory has a review loop to test");
856
857 for edge in returns {
858 let floor = edge.floor.expect("a return path is given a lane");
859 assert!(
860 floor <= layout.height,
861 "{} -> {} dips to {floor} but the drawing is only {} tall",
862 edge.from,
863 edge.to,
864 layout.height
865 );
866 }
867 }
868
869 #[test]
870 fn several_returns_to_one_agent_climb_at_different_points() {
871 let layout = Layout::build(&review_loop(), &Live::default());
875 let returns: Vec<f64> = layout
876 .edges
877 .iter()
878 .filter(|edge| edge.back && edge.to == "a_dev")
879 .filter_map(|edge| edge.gutter)
880 .collect();
881
882 assert!(returns.len() >= 2, "the factory has a review loop");
883 for pair in returns.windows(2) {
884 assert!(
885 (pair[0] - pair[1]).abs() > 1.0,
886 "two returns share a gutter: {returns:?}"
887 );
888 }
889
890 let hooks: Vec<f64> = layout
891 .edges
892 .iter()
893 .filter(|edge| edge.back && edge.to == "a_dev")
894 .filter_map(|edge| edge.hook_x)
895 .collect();
896 for pair in hooks.windows(2) {
897 assert!(
898 (pair[0] - pair[1]).abs() > 1.0,
899 "two returns hook in at the same point: {hooks:?}"
900 );
901 }
902 }
903
904 #[test]
905 fn a_return_path_hooks_inside_the_agent_it_returns_to() {
906 let layout = Layout::build(&review_loop(), &Live::default());
907 let developer = layout.node("a_dev").expect("dev");
908
909 for edge in layout.edges.iter().filter(|edge| edge.back) {
910 let hook = edge.hook_x.expect("a return path is given a hook");
911 assert!(
912 hook > developer.x && hook < developer.x + developer.w,
913 "the hook must land on the node, not beside it: {hook}"
914 );
915 }
916 }
917
918 fn review_loop() -> Config {
921 config(
922 r#"
923 [layover]
924 work_dir = "work"
925
926 [defaults]
927 runner = "claude"
928
929 [runners.claude]
930 command = ["claude", "-p"]
931
932 [agents.dev]
933 prompt = "develop"
934
935 [agents.tester]
936 prompt = "test"
937
938 [agents.reviewer]
939 prompt = "review"
940
941 [pipelines.go]
942 entry = "dev"
943
944 [[routes]]
945 from = "dev"
946 to = ["tester", "reviewer"]
947
948 [[routes]]
949 from = ["tester", "reviewer"]
950 to = "dev"
951 join = "all"
952 "#,
953 )
954 }
955
956 #[test]
957 fn two_return_paths_are_given_lanes_of_their_own() {
958 let config = config(
961 r#"
962 [layover]
963 work_dir = "work"
964
965 [defaults]
966 runner = "claude"
967
968 [runners.claude]
969 command = ["claude", "-p"]
970
971 [agents.dev]
972 prompt = "develop"
973
974 [agents.tester]
975 prompt = "test"
976
977 [agents.reviewer]
978 prompt = "review"
979
980 [pipelines.go]
981 entry = "dev"
982
983 [[routes]]
984 from = "dev"
985 to = ["tester", "reviewer"]
986
987 [[routes]]
988 from = ["tester", "reviewer"]
989 to = "dev"
990 join = "all"
991 "#,
992 );
993
994 let layout = Layout::build(&config, &Live::default());
995 let mut floors: Vec<f64> = layout
996 .edges
997 .iter()
998 .filter(|edge| edge.back)
999 .filter_map(|edge| edge.floor)
1000 .collect();
1001 floors.sort_by(f64::total_cmp);
1002
1003 assert_eq!(floors.len(), 2);
1004 assert!(
1005 (floors[1] - floors[0]).abs() > 1.0,
1006 "the two loops share a lane: {floors:?}"
1007 );
1008 }
1009
1010 #[test]
1011 fn a_workflow_is_laid_out_from_its_own_entry_only() {
1012 let config = config(
1018 r#"
1019 [layover]
1020 work_dir = "work"
1021
1022 [defaults]
1023 runner = "claude"
1024
1025 [runners.claude]
1026 command = ["claude", "-p"]
1027
1028 [agents.analyst]
1029 prompt = "analyse"
1030
1031 [agents.publisher]
1032 prompt = "publish"
1033
1034 [agents.follower]
1035 prompt = "follow"
1036
1037 [pipelines.triage]
1038 entry = "analyst"
1039
1040 [pipelines.follow_up]
1041 entry = "follower"
1042
1043 [[routes]]
1044 from = "analyst"
1045 to = "publisher"
1046
1047 [[routes]]
1048 from = "publisher"
1049 to = "follower"
1050 "#,
1051 );
1052
1053 let triage = Layout::scoped(&config, &Live::default(), &Scope::Pipeline("triage".into()));
1054 let publisher = triage.node("a_publisher").expect("publisher");
1055 let follower = triage.node("a_follower").expect("follower");
1056
1057 assert!(
1058 follower.layer > publisher.layer,
1059 "the follower is reached through the publisher here, so it must come after it: \
1060 publisher at {}, follower at {}",
1061 publisher.layer,
1062 follower.layer
1063 );
1064 }
1065
1066 #[test]
1067 fn edges_leaving_one_node_meet_it_at_different_points() {
1068 let layout = Layout::build(&factory(), &Live::default());
1071 let leaving: Vec<f64> = layout
1072 .edges
1073 .iter()
1074 .filter(|edge| edge.from == "a_developer" && !edge.back)
1075 .map(|edge| edge.from_y)
1076 .collect();
1077
1078 assert!(leaving.len() >= 2, "the developer fans out");
1079 for pair in leaving.windows(2) {
1080 assert!(
1081 (pair[0] - pair[1]).abs() > 1.0,
1082 "two edges share an exit point: {leaving:?}"
1083 );
1084 }
1085 }
1086
1087 #[test]
1088 fn a_node_with_one_edge_still_meets_it_in_the_middle() {
1089 let layout = Layout::build(&factory(), &Live::default());
1090 let analyst = layout.node("a_analyst").expect("analyst");
1091 let only = layout
1092 .edges
1093 .iter()
1094 .find(|edge| edge.from == "a_analyst" && !edge.back)
1095 .expect("analyst sends somewhere");
1096
1097 assert!((only.from_y - analyst.centre().1).abs() < 0.001);
1098 }
1099
1100 #[test]
1101 fn a_workflow_can_be_drawn_on_its_own() {
1102 let config = two_workflows();
1106 let sweep = Layout::scoped(&config, &Live::default(), &Scope::Pipeline("sweep".into()));
1107
1108 assert!(sweep.node("p_sweep").is_some());
1109 assert!(sweep.node("a_sweeper").is_some());
1110 assert!(
1111 sweep.node("a_pr_reviewer").is_some(),
1112 "a spawned reviewer is part of the sweep"
1113 );
1114 assert!(sweep.node("p_build").is_none(), "the other way in is not");
1115 assert!(sweep.node("a_developer").is_none());
1116 }
1117
1118 #[test]
1119 fn an_agent_in_two_workflows_appears_in_both() {
1120 let config = two_workflows();
1123
1124 for pipeline in ["build", "release"] {
1125 let drawn =
1126 Layout::scoped(&config, &Live::default(), &Scope::Pipeline(pipeline.into()));
1127 assert!(
1128 drawn.node("a_developer").is_some(),
1129 "developer missing from {pipeline}"
1130 );
1131 }
1132 }
1133
1134 #[test]
1135 fn drawing_everything_is_still_the_default() {
1136 let config = two_workflows();
1137 let all = Layout::build(&config, &Live::default());
1138
1139 assert!(all.node("p_sweep").is_some());
1140 assert!(all.node("p_build").is_some());
1141 assert!(all.node("a_pr_reviewer").is_some());
1142 }
1143
1144 fn two_workflows() -> Config {
1145 config(
1146 r#"
1147 [layover]
1148 work_dir = "work"
1149
1150 [defaults]
1151 runner = "claude"
1152
1153 [runners.claude]
1154 command = ["claude", "-p"]
1155
1156 [agents.sweeper]
1157 prompt = "sweep"
1158
1159 [agents.pr_reviewer]
1160 prompt = "review one"
1161
1162 [agents.developer]
1163 prompt = "develop"
1164
1165 [agents.publisher]
1166 prompt = "publish"
1167
1168 [pipelines.sweep]
1169 entry = "sweeper"
1170
1171 [pipelines.build]
1172 entry = "developer"
1173
1174 [pipelines.release]
1175 entry = "developer"
1176
1177 [[routes]]
1178 from = "sweeper"
1179 to = "pr_reviewer"
1180 mode = "spawn"
1181
1182 [[routes]]
1183 from = "developer"
1184 to = "publisher"
1185 "#,
1186 )
1187 }
1188
1189 #[test]
1190 fn a_spawn_target_is_drawn_right_of_the_agent_that_spawns_it() {
1191 let config = config(
1196 r#"
1197 [layover]
1198 work_dir = "work"
1199
1200 [defaults]
1201 runner = "claude"
1202
1203 [runners.claude]
1204 command = ["claude", "-p"]
1205
1206 [agents.sweeper]
1207 prompt = "sweep"
1208
1209 [agents.pr_reviewer]
1210 prompt = "review one"
1211
1212 [pipelines.sweep]
1213 entry = "sweeper"
1214
1215 [[routes]]
1216 from = "sweeper"
1217 to = "pr_reviewer"
1218 mode = "spawn"
1219 "#,
1220 );
1221
1222 let layout = Layout::build(&config, &Live::default());
1223 let sweeper = layout.node("a_sweeper").expect("sweeper");
1224 let reviewer = layout.node("a_pr_reviewer").expect("reviewer");
1225
1226 assert!(
1227 reviewer.layer > sweeper.layer,
1228 "a spawn should still flow rightwards: sweeper at {}, reviewer at {}",
1229 sweeper.layer,
1230 reviewer.layer
1231 );
1232
1233 let edge = layout
1234 .edges
1235 .iter()
1236 .find(|edge| edge.to == "a_pr_reviewer")
1237 .expect("the spawn edge exists");
1238 assert!(!edge.back, "and must not be drawn as a return path");
1239 }
1240
1241 #[test]
1242 fn an_agent_no_pipeline_can_reach_is_still_drawn() {
1243 let config = config(
1246 r#"
1247 [layover]
1248 work_dir = "work"
1249
1250 [defaults]
1251 runner = "claude"
1252
1253 [runners.claude]
1254 command = ["claude", "-p"]
1255
1256 [agents.reachable]
1257 prompt = "work"
1258
1259 [agents.orphan]
1260 prompt = "nobody routes here"
1261
1262 [pipelines.go]
1263 entry = "reachable"
1264 "#,
1265 );
1266
1267 let layout = Layout::build(&config, &Live::default());
1268
1269 assert!(layout.node("a_orphan").is_some());
1270 }
1271
1272 #[test]
1273 fn live_state_lands_on_the_right_node() {
1274 let live = Live::default().with("developer", Activity::Running);
1275 let layout = Layout::build(&factory(), &live);
1276
1277 assert_eq!(
1278 layout.node("a_developer").expect("developer").activity,
1279 Some(Activity::Running)
1280 );
1281 assert_eq!(layout.node("a_analyst").expect("analyst").activity, None);
1282 }
1283
1284 #[test]
1285 fn an_empty_factory_lays_out_without_panicking() {
1286 let config = config(
1287 r#"
1288 [layover]
1289 work_dir = "work"
1290
1291 [defaults]
1292 runner = "claude"
1293
1294 [runners.claude]
1295 command = ["claude", "-p"]
1296
1297 [agents.only]
1298 prompt = "think"
1299 entry = true
1300 "#,
1301 );
1302
1303 let layout = Layout::build(&config, &Live::default());
1304
1305 assert_eq!(layout.nodes.len(), 1);
1306 assert!(layout.edges.is_empty());
1307 }
1308}