layover-core 0.23.1

Domain types for Layover: factory configuration, route graph, itinerary accounting and rendezvous barriers.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
//! Turning a [`Layout`] into SVG.
//!
//! Inline SVG rather than a canvas or a JavaScript graph library: it needs no runtime, renders
//! before any script has parsed, survives the browser's zoom, and every node is a DOM element
//! that CSS can colour and a pointer can hit. For a diagram of a few dozen nodes there is nothing
//! a library would add except its own weight.

use std::fmt::Write as _;

use crate::diagram::layout::{Edge, EdgeStyle, Layout, Node, NodeKind, Shape};

/// How far below a node the hook begins.
const HOOK: f64 = 26.0;
/// How far above the lane an edge label sits.
const LABEL_LIFT: f64 = 6.0;

/// Renders a layout as an SVG fragment.
///
/// A fragment, not a document: it is embedded in the dashboard page, so it inherits the page's
/// styles and font. The `viewBox` makes it scale to whatever box it is put in.
#[must_use]
pub fn render(layout: &Layout) -> String {
    let mut out = String::new();
    let _ = writeln!(
        out,
        r#"<svg class="routemap" viewBox="0 0 {:.0} {:.0}" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Factory route map">"#,
        layout.width, layout.height
    );

    out.push_str(ARROWHEADS);

    // Edges first so that nodes sit on top of them: a line ending under a box reads as arriving
    // at it, whereas a box under a line reads as being crossed out.
    for edge in &layout.edges {
        render_edge(&mut out, layout, edge);
    }
    for node in &layout.nodes {
        render_node(&mut out, node);
    }

    out.push_str("</svg>\n");
    out
}

/// Marker definitions, one per edge style so each arrowhead matches its line.
const ARROWHEADS: &str = r#"  <defs>
    <marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
      <path d="M 0 0 L 10 5 L 0 10 z" class="arrowhead"/>
    </marker>
    <marker id="arrow-bypass" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
      <path d="M 0 0 L 10 5 L 0 10 z" class="arrowhead bypass"/>
    </marker>
  </defs>
"#;

/// Draws one node, with its label and whatever it is doing.
fn render_node(out: &mut String, node: &Node) {
    let kind = match node.kind {
        NodeKind::Pipeline => "pipeline",
        NodeKind::Agent => "agent",
    };
    let state = node
        .activity
        .map_or(String::new(), |activity| format!(" {}", activity.class()));

    let _ = writeln!(
        out,
        r#"  <g class="node {kind}{state}" id="{}" tabindex="0">"#,
        escape(&node.id)
    );

    match node.shape {
        Shape::Box => {
            let _ = writeln!(
                out,
                r#"    <rect x="{:.1}" y="{:.1}" width="{:.1}" height="{:.1}" rx="10"/>"#,
                node.x, node.y, node.w, node.h
            );
        }
        Shape::Gate => {
            // A hexagon reads as a gate, which is what a barrier is: work queues at it until a
            // condition is met. A plain box would make it look like any other agent.
            let notch = 14.0;
            let (x, y, w, h) = (node.x, node.y, node.w, node.h);
            let _ = writeln!(
                out,
                r#"    <polygon points="{:.1},{:.1} {:.1},{:.1} {:.1},{:.1} {:.1},{:.1} {:.1},{:.1} {:.1},{:.1}"/>"#,
                x + notch,
                y,
                x + w - notch,
                y,
                x + w,
                y + h / 2.0,
                x + w - notch,
                y + h,
                x + notch,
                y + h,
                x,
                y + h / 2.0
            );
        }
    }

    let (cx, cy) = node.centre();
    let baseline = if node.subtitle.is_some() {
        cy - 4.0
    } else {
        cy + 5.0
    };
    let _ = writeln!(
        out,
        r#"    <text class="label" x="{cx:.1}" y="{baseline:.1}" text-anchor="middle">{}</text>"#,
        escape(&node.label)
    );
    if let Some(subtitle) = &node.subtitle {
        let _ = writeln!(
            out,
            r#"    <text class="sub" x="{cx:.1}" y="{:.1}" text-anchor="middle">{}</text>"#,
            cy + 13.0,
            escape(subtitle)
        );
    }

    out.push_str("  </g>\n");
}

/// Draws one edge, as a curve from the right of its source to the left of its target.
fn render_edge(out: &mut String, layout: &Layout, edge: &Edge) {
    let (Some(from), Some(to)) = (layout.node(&edge.from), layout.node(&edge.to)) else {
        return;
    };

    let class = match edge.style {
        EdgeStyle::Plain => "edge",
        EdgeStyle::Entry => "edge entry",
        EdgeStyle::Joined => "edge joined",
        EdgeStyle::Bypass => "edge bypass",
        EdgeStyle::Spawn => "edge spawn",
    };
    let marker = if matches!(edge.style, EdgeStyle::Bypass | EdgeStyle::Spawn) {
        "arrow-bypass"
    } else {
        "arrow"
    };

    let (path, label_at) = if edge.back {
        return_path(from, to, edge)
    } else {
        forward_path(from, to, edge.from_y, edge.to_y)
    };

    let _ = writeln!(
        out,
        r#"  <path class="{class}" d="{path}" marker-end="url(#{marker})"/>"#
    );

    if let Some(label) = &edge.label {
        let _ = writeln!(
            out,
            r#"  <text class="edgelabel" x="{:.1}" y="{:.1}" text-anchor="middle">{}</text>"#,
            label_at.0,
            label_at.1,
            escape(label)
        );
    }
}

/// A cubic curve rightwards, flattening into a straight line when the ends are level.
///
/// The y of each end comes from the layout rather than the node centre, so several edges sharing
/// a node leave and arrive at different points along its side instead of bunching.
fn forward_path(from: &Node, to: &Node, from_y: f64, to_y: f64) -> (String, (f64, f64)) {
    let (x1, y1) = (from.exit().0, from_y);
    let (x2, y2) = (to.entry().0, to_y);
    let bend = ((x2 - x1) * 0.45).max(24.0);

    (
        format!(
            "M {x1:.1} {y1:.1} C {:.1} {y1:.1} {:.1} {y2:.1} {x2:.1} {y2:.1}",
            x1 + bend,
            x2 - bend
        ),
        (f64::midpoint(x1, x2), f64::midpoint(y1, y2) - 8.0),
    )
}

/// A return path: out of the bottom, back leftwards through its own lane, and up into the
/// target's underside.
///
/// Drawn below everything rather than straight through the columns it crosses. A review loop is
/// the most important structure on a diagram like this, and routing it through the middle of the
/// forward flow is what makes these graphs unreadable.
///
/// The lane depth comes from the layout, which is what guarantees the curve stays inside the
/// reported extent instead of being clipped off the bottom.
fn return_path(from: &Node, to: &Node, edge: &Edge) -> (String, (f64, f64)) {
    let (fx, fy) = (f64::midpoint(from.x, from.x + from.w), from.y + from.h);
    let ty = to.y + to.h;
    let floor = edge.floor.unwrap_or(fy.max(ty) + 34.0);

    // Climb in a gutter left of the target's column rather than straight up into its underside.
    // Columns stack several agents, so a vertical segment on the column centre runs through
    // whichever boxes sit below the target, and a line passing through a node reads as an edge
    // touching it.
    //
    // The gutter and the hook are per edge, not per target. Several returns to one agent — which
    // is the normal shape of a review loop — shared a single vertical line, so four curves
    // overlapped for their whole climb and their labels stacked on top of one another.
    let gutter = edge.gutter.unwrap_or_else(|| (to.x - 44.0).max(12.0));
    let corner = edge.hook_x.unwrap_or(to.x + 26.0);

    (
        format!(
            "M {fx:.1} {fy:.1} \
             C {fx:.1} {floor:.1} {gutter:.1} {floor:.1} {gutter:.1} {:.1} \
             Q {gutter:.1} {ty:.1} {corner:.1} {ty:.1}",
            ty + HOOK
        ),
        (f64::midpoint(fx, gutter), floor - LABEL_LIFT),
    )
}

/// Escapes text going into SVG markup.
fn escape(raw: &str) -> String {
    raw.replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::config::Config;
    use crate::diagram::{Activity, Live};

    fn factory() -> Config {
        Config::from_toml(
            r#"
            [layover]
            work_dir = "work"

            [defaults]
            runner = "claude"

            [runners.claude]
            command = ["claude", "-p"]

            [agents.analyst]
            prompt = "analyse"
            access = "read-only"

            [agents.developer]
            prompt = "develop"

            [agents.tester]
            prompt = "test"

            [pipelines.triage]
            entry = "analyst"

            [[routes]]
            from = "analyst"
            to = "developer"

            [[routes]]
            from = "developer"
            to = "tester"

            [[routes]]
            from = "tester"
            to = "developer"
            join = "all"
            "#,
            "svg-test.toml",
        )
        .expect("config parses")
    }

    fn svg() -> String {
        render(&Layout::build(&factory(), &Live::default()))
    }

    #[test]
    fn the_fragment_is_a_sized_svg_element() {
        let svg = svg();

        assert!(
            svg.starts_with(r#"<svg class="routemap" viewBox="0 0 "#),
            "{svg}"
        );
        assert!(svg.trim_end().ends_with("</svg>"));
    }

    #[test]
    fn every_node_becomes_an_addressable_element() {
        // Each node is a DOM element with an id, which is what lets the page colour it, focus it
        // and hang a click on it without a graph library in the middle.
        let svg = svg();

        for id in ["p_triage", "a_analyst", "a_developer", "a_tester"] {
            assert!(svg.contains(&format!(r#"id="{id}""#)), "{id} missing");
        }
    }

    #[test]
    fn a_barrier_is_drawn_as_a_gate_and_an_ordinary_agent_as_a_box() {
        let svg = svg();

        assert!(
            svg.contains("<polygon"),
            "the joined developer needs a gate"
        );
        assert_eq!(svg.matches("<polygon").count(), 1);
        assert!(svg.contains("<rect"));
    }

    #[test]
    fn edges_are_drawn_before_nodes() {
        // A line ending under a box reads as arriving at it. A box under a line reads as being
        // crossed out.
        let svg = svg();
        let first_path = svg.find(r#"<path class="edge"#).expect("has edges");
        let first_node = svg.find(r#"<g class="node"#).expect("has nodes");

        assert!(first_path < first_node);
    }

    #[test]
    fn a_return_path_is_routed_below_rather_than_through_the_columns() {
        // The review loop is the most important structure on a route map, and drawing it through
        // the middle of the forward flow is what makes these diagrams unreadable.
        let layout = Layout::build(&factory(), &Live::default());
        let tester = layout.node("a_tester").expect("tester");
        let back = layout
            .edges
            .iter()
            .find(|edge| edge.back)
            .expect("the review loop exists");
        let (path, _) = return_path(tester, layout.node("a_developer").expect("developer"), back);

        let floor: f64 = path
            .split_whitespace()
            .filter_map(|token| token.parse::<f64>().ok())
            .fold(0.0, f64::max);

        assert!(
            floor > tester.y + tester.h,
            "the return path should dip below the nodes it passes"
        );
    }

    #[test]
    fn live_state_becomes_a_class_the_stylesheet_can_colour() {
        let live = Live::default()
            .with("developer", Activity::Running)
            .with("analyst", Activity::Failed);
        let svg = render(&Layout::build(&factory(), &live));

        assert!(
            svg.contains(r#"class="node agent running" id="a_developer""#),
            "{svg}"
        );
        assert!(svg.contains(r#"class="node agent failed" id="a_analyst""#));
        assert!(svg.contains(r#"class="node agent" id="a_tester""#));
    }

    #[test]
    fn a_join_condition_is_written_on_the_edge() {
        let svg = svg();

        assert!(svg.contains(r#"class="edgelabel""#));
        assert!(svg.contains(">all</text>"));
    }

    #[test]
    fn read_only_access_is_written_under_the_name() {
        let svg = svg();

        assert!(svg.contains(">read-only</text>"));
    }

    #[test]
    fn a_return_path_climbs_in_the_gutter_rather_than_through_the_column() {
        // Columns stack several agents, so a vertical segment on the column centre runs through
        // whichever boxes sit below the target, and a line passing through a node reads as an
        // edge touching it. Found by rendering the reference factory and looking at it: a return
        // path from the tester crossed the investigator and implied a route that does not exist.
        let layout = Layout::build(&factory(), &Live::default());
        let developer = layout.node("a_developer").expect("developer");
        let tester = layout.node("a_tester").expect("tester");
        let back = layout
            .edges
            .iter()
            .find(|edge| edge.back && edge.to == "a_developer")
            .expect("the review loop exists");
        let (path, _) = return_path(tester, developer, back);

        let gutter = back.gutter.expect("a return path is given a gutter");
        assert!(
            gutter < developer.x,
            "the ascent should happen left of the target's column: {gutter} vs {}",
            developer.x
        );
        assert!(path.contains(&format!("{gutter:.1}")), "{path}");
        assert!(
            !path.contains(&format!("{:.1} {:.1}", developer.centre().0, 400.0)),
            "nothing should be routed up the column centre: {path}"
        );
    }

    #[test]
    fn markup_in_a_name_cannot_escape_into_the_document() {
        // Names are validated, so this cannot happen today. It is guarded anyway because the
        // cost of being wrong is script injection into the operator's dashboard, and the cost of
        // the guard is one function.
        assert_eq!(
            escape(r#"<script>alert("x")</script>"#),
            "&lt;script&gt;alert(&quot;x&quot;)&lt;/script&gt;"
        );
    }

    #[test]
    fn an_edge_to_a_node_that_is_not_there_is_skipped_rather_than_drawn_wrong() {
        let mut layout = Layout::build(&factory(), &Live::default());
        layout.edges.push(Edge {
            from: "a_analyst".to_owned(),
            to: "a_ghost".to_owned(),
            label: None,
            style: EdgeStyle::Plain,
            back: false,
            from_y: 0.0,
            to_y: 0.0,
            gutter: None,
            hook_x: None,
            floor: None,
        });

        let svg = render(&layout);

        assert!(!svg.contains("a_ghost"));
    }
}