Skip to main content

layover_core/diagram/
layout.rs

1//! Deciding where each node goes.
2//!
3//! A layered layout, which is the right shape for a route map: work flows from an entry point
4//! towards a terminal agent, and putting each node one column further right than the thing that
5//! wakes it makes that flow the diagram's primary axis.
6//!
7//! # Layers come from breadth-first distance, not longest path
8//!
9//! The textbook layered algorithm assigns layers by longest path from a source, which does not
10//! terminate on a cyclic graph. Route maps are routinely cyclic — a developer sends to a tester
11//! and the tester sends back, which is the review loop that makes the reference factory work —
12//! so longest path is not available.
13//!
14//! Breadth-first distance is, and [`crate::graph::RouteGraph`] already computes it for the
15//! load-time hop check. Reusing it means the diagram's columns and the validator's hop arithmetic
16//! are derived from the same number, so a diagram can never imply a depth that validation
17//! disagrees with.
18//!
19//! Edges that point backwards or sideways are then drawn as return paths, which is what they are.
20
21use 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
30/// Width of a node box.
31const NODE_W: f64 = 168.0;
32/// Height of a node box.
33const NODE_H: f64 = 56.0;
34/// Horizontal gap between columns.
35const COL_GAP: f64 = 96.0;
36/// Vertical gap between nodes in a column.
37const ROW_GAP: f64 = 32.0;
38/// Margin around the whole drawing.
39const MARGIN: f64 = 32.0;
40/// Vertical spacing between the lanes that return paths are routed through.
41const RETURN_GAP: f64 = 34.0;
42/// How far left of a node''s column the first return path climbs.
43const GUTTER_INSET: f64 = 44.0;
44/// How much further left each additional return to the same node climbs.
45const GUTTER_STEP: f64 = 18.0;
46/// Closest to the left edge of the canvas a gutter may be.
47const GUTTER_MIN: f64 = 12.0;
48/// Closest to a node''s right edge that a return path may hook in.
49const CORNER_MARGIN: f64 = 20.0;
50
51/// What a node represents.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum NodeKind {
54    /// A way into the mesh.
55    Pipeline,
56    /// A configured agent.
57    Agent,
58}
59
60/// How a node is drawn.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum Shape {
63    /// An ordinary agent or pipeline.
64    Box,
65    /// An agent guarded by a rendezvous barrier. Drawn as a gate, because that is what it is.
66    Gate,
67}
68
69/// A positioned node.
70#[derive(Debug, Clone, PartialEq)]
71pub struct Node {
72    /// Stable identifier, used to join edges to nodes and for DOM ids.
73    pub id: String,
74    /// The name shown on the node.
75    pub label: String,
76    /// A second line: a trigger for a pipeline, `read-only` for an agent that has it.
77    pub subtitle: Option<String>,
78    /// What this node represents.
79    pub kind: NodeKind,
80    /// How to draw it.
81    pub shape: Shape,
82    /// What it is doing, if anything.
83    pub activity: Option<Activity>,
84    /// Which column it sits in.
85    pub layer: usize,
86    /// Left edge.
87    pub x: f64,
88    /// Top edge.
89    pub y: f64,
90    /// Width.
91    pub w: f64,
92    /// Height.
93    pub h: f64,
94}
95
96impl Node {
97    /// The point an edge should leave from.
98    #[must_use]
99    pub fn exit(&self) -> (f64, f64) {
100        (self.x + self.w, self.y + self.h / 2.0)
101    }
102
103    /// The point an edge should arrive at.
104    #[must_use]
105    pub fn entry(&self) -> (f64, f64) {
106        (self.x, self.y + self.h / 2.0)
107    }
108
109    /// The centre.
110    #[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/// How an edge is drawn.
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118pub enum EdgeStyle {
119    /// An ordinary permitted edge.
120    Plain,
121    /// A pipeline feeding its entry agent.
122    Entry,
123    /// One of the upstreams a barrier names.
124    Joined,
125    /// A permitted sender that the barrier does *not* name, and which therefore wakes the agent
126    /// directly rather than parking at it.
127    Bypass,
128    /// Opens a new itinerary per flight rather than continuing this one.
129    Spawn,
130}
131
132/// A positioned edge.
133#[derive(Debug, Clone, PartialEq)]
134pub struct Edge {
135    /// Identifier of the node it leaves.
136    pub from: String,
137    /// Identifier of the node it arrives at.
138    pub to: String,
139    /// `all` or `any`, for an edge into a barrier.
140    pub label: Option<String>,
141    /// How to draw it.
142    pub style: EdgeStyle,
143    /// True when the edge points back towards the entry, which makes it a return path.
144    pub back: bool,
145    /// Where on the source''s right edge this leaves, as an absolute y.
146    ///
147    /// Edges all left from the node''s centre, so several going to different places overlapped
148    /// for their first stretch and only separated once they had already crossed each other.
149    /// Spreading them down the edge, ordered by where they are going, means they never cross at
150    /// the node they share.
151    pub from_y: f64,
152    /// Where on the target''s left edge this arrives, as an absolute y.
153    pub to_y: f64,
154    /// For a return path, the x it climbs at. `None` for a forward edge.
155    ///
156    /// Distinct per edge even when several return to the same agent. Sharing one gutter put four
157    /// curves on the same vertical line with their labels stacked on top of each other.
158    pub gutter: Option<f64>,
159    /// For a return path, where it hooks into the target''s underside.
160    pub hook_x: Option<f64>,
161    /// For a return path, the depth it dips to. `None` for a forward edge.
162    ///
163    /// Computed here rather than in the renderer because it decides how tall the drawing is, and
164    /// a renderer that invented its own geometry would draw outside the reported extent — which
165    /// is exactly how a review loop ends up clipped off the bottom of the diagram.
166    pub floor: Option<f64>,
167}
168
169/// A laid-out diagram.
170#[derive(Debug, Clone, Default, PartialEq)]
171pub struct Layout {
172    /// Every node, in reading order.
173    pub nodes: Vec<Node>,
174    /// Every edge.
175    pub edges: Vec<Edge>,
176    /// Total width, including margins.
177    pub width: f64,
178    /// Total height, including margins.
179    pub height: f64,
180}
181
182impl Layout {
183    /// Finds a node by identifier.
184    #[must_use]
185    pub fn node(&self, id: &str) -> Option<&Node> {
186        self.nodes.iter().find(|node| node.id == id)
187    }
188
189    /// Lays out a whole factory.
190    #[must_use]
191    pub fn build(config: &Config, live: &Live) -> Self {
192        Self::scoped(config, live, &Scope::Everything)
193    }
194
195    /// Lays out one workflow, or the whole factory.
196    #[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    /// Creates a node for every pipeline and agent, and puts it in its column.
216    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            // Agents no pipeline can reach still have to appear — an unreachable agent is
251            // precisely the thing somebody opened the diagram to find.
252            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    /// Adds the edges, classifying each one.
283    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                    // A barrier constrains only the upstreams it names. Any other permitted
323                    // sender wakes the agent directly, leaving parked flights untouched, so
324                    // labelling that edge with the join condition would state the opposite of
325                    // what happens.
326                    // A spawn is checked first: it opens a new itinerary, so a barrier on the
327                    // receiver cannot apply to it -- validation rejects that combination outright.
328                    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    /// Which column a node is in, or zero if it is not placed.
366    fn layer_of(&self, id: &str) -> usize {
367        self.node(id).map_or(0, |node| node.layer)
368    }
369
370    /// Reorders each column to sit near the things that point at it.
371    ///
372    /// One pass of the barycentre heuristic. It is not optimal — crossing minimisation is
373    /// NP-hard — but on a graph of this size one pass removes most of the obvious tangles, and a
374    /// second pass tends to shuffle nodes without improving anything a human would notice.
375    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            // No incoming edges leaves the node where it was: its own position is the only
392            // information available, and inventing an order would be churn.
393            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    /// Computes the overall extent, centres each column vertically, and routes the return paths.
422    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    /// Spreads each node''s edges along its sides instead of bunching them at the centre.
451    ///
452    /// Ordered by where the other end sits, so two edges leaving the same node never cross each
453    /// other before they have gone anywhere. Only forward edges: a return path leaves from the
454    /// underside and is routed through its own lane already.
455    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    /// Gives each return path its own lane below the drawing, and reports the deepest one.
512    ///
513    /// Lanes rather than one shared depth: two loops at the same height would be drawn on top of
514    /// each other, and a review loop is the structure on a route map most worth being able to
515    /// follow with a finger.
516    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        // Shortest loops innermost, so a long return path never has to cross a short one.
524        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        // How many returns already aim at each target, so edges sharing one can be fanned apart
539        // rather than stacked on a single vertical line.
540        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
569/// Assigns each agent a column by breadth-first distance from the ways in.
570///
571/// Agents nothing can reach are absent from the result, and the caller places them in the first
572/// column rather than dropping them: an unreachable agent is exactly what somebody opened the
573/// diagram to find, so hiding it would defeat the purpose.
574///
575/// When one workflow is being drawn, distance is measured from *that* pipeline's entry and no
576/// other. Seeding every entry regardless of scope put agents that happen to be another pipeline's
577/// way in near the left edge of a diagram they are late in — the reference factory''s follower is
578/// reached through the publisher, but is also the follow-up pipeline''s entry, so it landed in
579/// column two with an edge sweeping back across the whole drawing.
580fn 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    // Spawn targets are absent from the distances above, because breadth-first traversal stops at
608    // a spawn edge — correctly, since the receiver starts a fresh chain with fresh Hops and hop
609    // depth across chains is not a real distance.
610    //
611    // For *drawing*, though, that leaves them at column zero, sitting beside the agent that
612    // spawns them, and the edge between becomes a same-column loop that reads as a stray squiggle
613    // rather than a hand-off. Placing each one right of its spawner keeps the left-to-right flow
614    // that makes the diagram legible. Only the picture is affected: the hop check does its own
615    // seeding and still treats them as entry points.
616    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
625/// Where the `slot`-th of `count` edges should meet a node''s side.
626///
627/// Evenly spaced across the middle 70% of the height, so a single edge still meets the centre and
628/// several never reach the rounded corners.
629fn 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
638/// Widens a count to a float for geometry.
639///
640/// Diagrams have tens of nodes, not quadrillions, so the lossy cast clippy warns about cannot
641/// happen here. Saying so once in a named function is better than scattering allow attributes
642/// through the arithmetic.
643fn precise(count: usize) -> f64 {
644    u32::try_from(count).map_or(f64::from(u32::MAX), f64::from)
645}
646
647/// A stable identifier for an agent node.
648fn agent_id(name: &AgentName) -> String {
649    format!("a_{name}")
650}
651
652/// A stable identifier for a pipeline node.
653fn 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        // Longest-path layering, the textbook approach, does not terminate here: the developer
726        // and the tester point at each other, which is the review loop the reference factory is
727        // built around. Breadth-first distance is what makes cyclic route maps drawable at all.
728        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        // The extent becomes the SVG viewBox. A node outside it is a node nobody can see.
838        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        // The first version of this reserved height for nodes only, and every review loop in the
850        // reference factory was drawn below the viewBox and clipped away. Found by rendering it
851        // and looking, which is the only way that class of bug ever shows up.
852        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        // The normal shape of a review loop: a tester and a reviewer both report back to the
872        // developer. Sharing one gutter put both curves on the same vertical line and stacked
873        // their labels on top of each other, which is what the whole diagram looked like.
874        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    /// A developer fanning out to a tester and a reviewer, both reporting back. The commonest
919    /// shape in a real factory and the one that exposed the shared-gutter problem.
920    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        // Drawn at the same depth they would overlap, and a review loop is the structure on a
959        // route map most worth being able to follow with a finger.
960        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        // Seeding every pipeline entry regardless of scope put agents that happen to be another
1013        // pipeline's way in near the left edge of a diagram they are late in. In the reference
1014        // factory the follower is reached through the publisher but is also the follow-up
1015        // pipeline's entry, so it landed in column two with an edge sweeping back across the
1016        // whole drawing.
1017        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        // They all left from the centre, so several going to different places overlapped for
1069        // their first stretch and only separated after they had already crossed.
1070        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        // A factory holds several pipelines and they are genuinely separate workflows. Drawing
1103        // them together produces one tangle that reads as a single very confused process, which
1104        // is exactly what a reader concludes from it.
1105        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        // The honest answer. The developer really is in both pipelines, and hiding it from one
1121        // would misrepresent the factory to make a tidier picture.
1122        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        // Breadth-first distance stops at a spawn edge, which is right for hop arithmetic and
1192        // wrong for a picture: it left the target at column zero beside its spawner, and the edge
1193        // between them became a same-column loop that read as a stray squiggle. Found by standing
1194        // up a real factory and looking at the dashboard.
1195        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        // An unreachable agent is exactly what somebody opens the diagram to find, so dropping
1244        // it would defeat the purpose of drawing one.
1245        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}