Skip to main content

pma_core/
script.rs

1//! Workflow documents written as [Rhai](https://rhai.rs) scripts.
2//!
3//! A script builds the same document JSON states, and `Document::parse` then
4//! reads it: the graph a script produces is type-checked, cost-bounded and
5//! stored exactly as a hand-written one is. What a script adds is variables,
6//! functions, loops and comments -- a library of twelve near-identical review
7//! workflows is a loop rather than twelve copies.
8//!
9//! The script runs once, when a document is read. It never runs during a pass,
10//! never sees a unit, and decides no guard: were it to, the worst case could
11//! not be computed before activation, and a proposed revision would be code
12//! rather than data. A revision therefore stores the generated document, and
13//! `--emit-json` prints it, so what was activated is readable without the
14//! script.
15//!
16//! The engine is built without a clock and without modules (`no_time` and
17//! `no_module` in `Cargo.toml`), with `eval` disabled and every limit set, so
18//! the same script yields the same document on every machine.
19
20use rhai::{Array, Dynamic, Engine, FnPtr, ImmutableString, Map, NativeCallContext};
21use serde_json::Value;
22
23use crate::workflow::Document;
24
25/// Operations one document may cost. A builder walks its own lists; a runaway
26/// loop is stopped rather than waited on.
27const MAX_OPERATIONS: u64 = 2_000_000;
28const MAX_CALL_DEPTH: usize = 32;
29/// Expression nesting, at the top level and inside a function. A builder nests
30/// a map inside a call inside a loop inside a function, which Rhai's default
31/// for a function body does not allow.
32const MAX_EXPR_DEPTH: usize = 96;
33const MAX_STRING: usize = 64 * 1024;
34const MAX_ARRAY: usize = 10_000;
35const MAX_MAP: usize = 10_000;
36
37impl Document {
38    /// Reads a document a script builds. `name` appears in an error.
39    pub fn from_script(text: &str, name: &str) -> Result<Document, String> {
40        let json = script_json(text, name)?;
41        let text = serde_json::to_string(&json).map_err(|e| format!("{name}: {e}"))?;
42        Document::parse(&text).map_err(|e| format!("{name}: {e}"))
43    }
44}
45
46/// The document a script returns, as JSON. Kept separate from `from_script` so
47/// `--emit-json` can print it without validating twice.
48pub fn script_json(text: &str, name: &str) -> Result<Value, String> {
49    let engine = engine();
50    let value: Dynamic = engine
51        .eval(text)
52        .map_err(|e| format!("{name}: {}", e.to_string().trim_end_matches('.')))?;
53    let json = to_json(&value).map_err(|e| format!("{name}: {e}"))?;
54    if !json.is_object() {
55        return Err(format!(
56            "{name}: a script's last expression is the document, an object with \
57             `types` and `workflow`; this one is {}",
58            kind(&json)
59        ));
60    }
61    Ok(json)
62}
63
64fn kind(v: &Value) -> &'static str {
65    match v {
66        Value::Null => "nothing",
67        Value::Bool(_) => "a boolean",
68        Value::Number(_) => "a number",
69        Value::String(_) => "a string",
70        Value::Array(_) => "a list",
71        Value::Object(_) => "an object",
72    }
73}
74
75/// An engine that can build a document and do nothing else. The clock and the
76/// module system are compiled out; `eval` is disabled here, because a script
77/// that writes its own script is not reviewable.
78fn engine() -> Engine {
79    let mut engine = Engine::new();
80    engine.disable_symbol("eval");
81    engine.set_max_operations(MAX_OPERATIONS);
82    engine.set_max_call_levels(MAX_CALL_DEPTH);
83    engine.set_max_expr_depths(MAX_EXPR_DEPTH, MAX_EXPR_DEPTH);
84    engine.set_max_string_size(MAX_STRING);
85    engine.set_max_array_size(MAX_ARRAY);
86    engine.set_max_map_size(MAX_MAP);
87    register(&mut engine);
88    register_graph(&mut engine);
89    register_composition(&mut engine);
90    engine
91}
92
93fn map(pairs: &[(&str, Dynamic)]) -> Map {
94    pairs
95        .iter()
96        .map(|(k, v)| ((*k).into(), v.clone()))
97        .collect()
98}
99
100/// Constructors for the shapes a document repeats: field declarations,
101/// parameters, edges and comparisons. Everything else is an object map, which
102/// Rhai writes as `#{...}`.
103fn register(engine: &mut Engine) {
104    // Field declarations.
105    engine.register_fn("line", |max: i64| {
106        map(&[("type", "line".into()), ("max", max.into())])
107    });
108    engine.register_fn("lines", |max: i64| {
109        map(&[("type", "lines".into()), ("max", max.into())])
110    });
111    engine.register_fn("choice", |values: Array| {
112        map(&[("type", "enum".into()), ("values", values.into())])
113    });
114    engine.register_fn("list", || map(&[("type", "list".into())]));
115    engine.register_fn("number", || map(&[("type", "int".into())]));
116    engine.register_fn("flag", || map(&[("type", "bool".into())]));
117
118    // Modifiers, so `req(unique(line(200)))` reads as one declaration.
119    engine.register_fn("req", |mut field: Map| {
120        field.insert("required".into(), true.into());
121        field
122    });
123    engine.register_fn("unique", |mut field: Map| {
124        field.insert("unique".into(), "normalised".into());
125        field
126    });
127
128    // Parameters. A bound takes the second form, because a parameter used as
129    // one must declare its maximum.
130    engine.register_fn("param", |ty: ImmutableString, default: Dynamic| {
131        map(&[("type", ty.into()), ("default", default)])
132    });
133    engine.register_fn(
134        "bounded",
135        |ty: ImmutableString, default: Dynamic, max: i64| {
136            map(&[
137                ("type", ty.into()),
138                ("default", default),
139                ("max", max.into()),
140            ])
141        },
142    );
143
144    // Nodes: a name and an op, merged with whatever else the node states.
145    engine.register_fn(
146        "node",
147        |name: ImmutableString, op: ImmutableString, mut rest: Map| {
148            rest.insert("name".into(), name.into());
149            rest.insert("op".into(), op.into());
150            rest
151        },
152    );
153
154    // `in`, `while` and `with` are Rhai keywords, so a document key that
155    // collides with one gets a constructor rather than asking every author to
156    // quote it.
157    engine.register_fn(
158        "node",
159        |name: ImmutableString, op: ImmutableString, input: ImmutableString, mut rest: Map| {
160            rest.insert("name".into(), name.into());
161            rest.insert("op".into(), op.into());
162            rest.insert("in".into(), input.into());
163            rest
164        },
165    );
166    engine.register_fn(
167        "call_to",
168        |name: ImmutableString, input: ImmutableString, workflow: ImmutableString, with: Map| {
169            map(&[
170                ("name", name.into()),
171                ("op", "call".into()),
172                ("in", input.into()),
173                ("workflow", workflow.into()),
174                ("with", with.into()),
175            ])
176        },
177    );
178    engine.register_fn("retry", |max: Dynamic, predicate: ImmutableString| {
179        map(&[("max", max), ("while", predicate.into())])
180    });
181    engine.register_fn(
182        "retry",
183        |max: Dynamic, predicate: ImmutableString, escalate_to: ImmutableString| {
184            map(&[
185                ("max", max),
186                ("while", predicate.into()),
187                (
188                    "escalate",
189                    Map::from_iter([("model".into(), escalate_to.into())]).into(),
190                ),
191            ])
192        },
193    );
194
195    // Edges.
196    engine.register_fn("edge", |from: ImmutableString, to: ImmutableString| {
197        map(&[("from", from.into()), ("to", to.into())])
198    });
199    engine.register_fn(
200        "edge_when",
201        |from: ImmutableString, to: ImmutableString, when: Map| {
202            map(&[
203                ("from", from.into()),
204                ("to", to.into()),
205                ("when", when.into()),
206            ])
207        },
208    );
209    engine.register_fn(
210        "edge_default",
211        |from: ImmutableString, to: ImmutableString| {
212            map(&[
213                ("from", from.into()),
214                ("to", to.into()),
215                ("default", true.into()),
216            ])
217        },
218    );
219    engine.register_fn(
220        "edge_lap",
221        |from: ImmutableString, to: ImmutableString, when: Map, laps: Dynamic| {
222            map(&[
223                ("from", from.into()),
224                ("to", to.into()),
225                ("when", when.into()),
226                ("max_laps", laps),
227            ])
228        },
229    );
230
231    // Comparisons, for a guard on a number.
232    for (name, op) in [
233        ("eq", "=="),
234        ("lt", "<"),
235        ("le", "<="),
236        ("gt", ">"),
237        ("ge", ">="),
238    ] {
239        let op = op.to_string();
240        engine.register_fn(name, move |n: Dynamic| {
241            let mut m = Map::new();
242            m.insert(op.as_str().into(), n);
243            m
244        });
245    }
246
247    // The document itself, so a script ends with one call rather than a map
248    // whose two keys are easy to misspell.
249    engine.register_fn("document", |types: Map, workflows: Array| {
250        map(&[("types", types.into()), ("workflow", workflows.into())])
251    });
252    engine.register_fn("document", |workflows: Array| {
253        map(&[("workflow", workflows.into())])
254    });
255}
256
257// ------------------------------------------------------------------- a graph
258
259/// A graph under construction, and the value a script passes from one
260/// combinator to the next. It carries its own output port, so a stage is wired
261/// by application rather than by repeating a node's name in an edge list, and a
262/// reusable piece of a graph is an ordinary function from `Graph` to `Graph`.
263#[derive(Clone, Default)]
264pub struct Graph {
265    nodes: Array,
266    edges: Array,
267    /// The nodes a new stage reads from. Several after a `fan`, one otherwise.
268    tail: Vec<ImmutableString>,
269    /// The type at the tail, which fills a node's `in` and a preserving node's
270    /// `emits`.
271    ty: ImmutableString,
272    input_ty: ImmutableString,
273    out_ty: Option<ImmutableString>,
274    /// A guard for the next edges, from `when`.
275    pending: Option<Map>,
276    /// Whether the next edges are the default, from `otherwise`.
277    pending_default: bool,
278    repo: bool,
279    writes: bool,
280    /// Set by `invoke`: a callee's effects are not visible from here, so the
281    /// workflow must state its own rather than have them inferred wrongly.
282    opaque: bool,
283}
284
285type Built<T> = Result<T, Box<rhai::EvalAltResult>>;
286
287fn fail<T>(message: String) -> Built<T> {
288    Err(message.into())
289}
290
291impl Graph {
292    fn source(ty: ImmutableString) -> Graph {
293        Graph {
294            tail: vec!["@input".into()],
295            ty: ty.clone(),
296            input_ty: ty,
297            ..Graph::default()
298        }
299    }
300
301    /// Adds a node, an edge from every tail into it, and makes it the tail.
302    fn stage(mut self, name: &str, mut node: Map) -> Built<Graph> {
303        if self.tail.is_empty() {
304            return fail(format!(
305                "`{name}` has nothing to read: the graph has already reached `@output`"
306            ));
307        }
308        if self.nodes.iter().any(|n| {
309            n.clone()
310                .try_cast::<Map>()
311                .and_then(|m| m.get("name").cloned())
312                .and_then(|v| v.try_cast::<ImmutableString>())
313                .is_some_and(|n| n == name)
314        }) {
315            return fail(format!("node `{name}` is added twice"));
316        }
317        node.insert("name".into(), name.into());
318        self.nodes.push(node.into());
319        let guard = self.pending.take();
320        let default = std::mem::take(&mut self.pending_default);
321        for from in std::mem::take(&mut self.tail) {
322            self.edges
323                .push(edge_map(&from, name, guard.clone(), default, None));
324        }
325        self.tail = vec![name.into()];
326        Ok(self)
327    }
328}
329
330fn edge_map(
331    from: &str,
332    to: &str,
333    when: Option<Map>,
334    default: bool,
335    max_laps: Option<Dynamic>,
336) -> Dynamic {
337    let mut e = Map::new();
338    e.insert("from".into(), from.into());
339    e.insert("to".into(), to.into());
340    if let Some(g) = when {
341        e.insert("when".into(), g.into());
342    }
343    if default {
344        e.insert("default".into(), true.into());
345    }
346    if let Some(l) = max_laps {
347        e.insert("max_laps".into(), l);
348    }
349    e.into()
350}
351
352fn node_map(op: &str, input: &str, pairs: &[(&str, Dynamic)]) -> Map {
353    let mut m = map(pairs);
354    m.insert("op".into(), op.into());
355    m.insert("in".into(), input.into());
356    m
357}
358
359/// The combinators. One per primitive, plus the plumbing a graph needs: a
360/// guard, a default path, a fan-out and its barrier.
361fn register_graph(engine: &mut Engine) {
362    engine.register_type_with_name::<Graph>("Graph");
363
364    engine.register_fn("source", Graph::source);
365
366    // `map`, at its three bounds.
367    engine.register_fn(
368        "expand",
369        |g: Graph,
370         name: ImmutableString,
371         emits: ImmutableString,
372         max_units: Dynamic,
373         task: ImmutableString|
374         -> Built<Graph> {
375            let node = node_map(
376                "map",
377                &g.ty,
378                &[
379                    ("out", "0..n".into()),
380                    ("emits", emits.clone().into()),
381                    ("max_units", max_units),
382                    ("task", task.into()),
383                ],
384            );
385            let mut g = g.stage(&name, node)?;
386            g.ty = emits;
387            Ok(g)
388        },
389    );
390    engine.register_fn(
391        "transform",
392        |g: Graph, name: ImmutableString, writes: Array, task: ImmutableString| -> Built<Graph> {
393            let node = node_map(
394                "map",
395                &g.ty,
396                &[
397                    ("out", "1".into()),
398                    ("emits", g.ty.clone().into()),
399                    ("writes", writes.into()),
400                    ("task", task.into()),
401                ],
402            );
403            g.stage(&name, node)
404        },
405    );
406    engine.register_fn(
407        "filter",
408        |g: Graph, name: ImmutableString, writes: Array, task: ImmutableString| -> Built<Graph> {
409            let node = node_map(
410                "map",
411                &g.ty,
412                &[
413                    ("out", "0..1".into()),
414                    ("emits", g.ty.clone().into()),
415                    ("writes", writes.into()),
416                    ("task", task.into()),
417                ],
418            );
419            g.stage(&name, node)
420        },
421    );
422
423    // The same three, decided by a rule rather than a model.
424    engine.register_fn(
425        "rule_expand",
426        |g: Graph,
427         name: ImmutableString,
428         emits: ImmutableString,
429         rule: ImmutableString,
430         max_units: Dynamic|
431         -> Built<Graph> {
432            let node = node_map(
433                "map",
434                &g.ty,
435                &[
436                    ("out", "0..n".into()),
437                    ("via", "rule".into()),
438                    ("rule", rule.into()),
439                    ("emits", emits.clone().into()),
440                    ("max_units", max_units),
441                ],
442            );
443            let mut g = g.stage(&name, node)?;
444            g.ty = emits;
445            Ok(g)
446        },
447    );
448    engine.register_fn(
449        "rule_filter",
450        |g: Graph, name: ImmutableString, rule: ImmutableString| -> Built<Graph> {
451            let node = node_map(
452                "map",
453                &g.ty,
454                &[
455                    ("out", "0..1".into()),
456                    ("via", "rule".into()),
457                    ("rule", rule.into()),
458                    ("emits", g.ty.clone().into()),
459                ],
460            );
461            g.stage(&name, node)
462        },
463    );
464
465    // `reduce`: the barrier after a fan, or a rule over one bag.
466    engine.register_fn(
467        "join",
468        |g: Graph, name: ImmutableString, group_by: Array| -> Built<Graph> {
469            let node = node_map(
470                "reduce",
471                &g.ty,
472                &[
473                    ("via", "rule".into()),
474                    ("rule", "dedupe".into()),
475                    ("emits", g.ty.clone().into()),
476                    ("group_by", group_by.into()),
477                ],
478            );
479            g.stage(&name, node)
480        },
481    );
482    engine.register_fn(
483        "join_by",
484        |g: Graph, name: ImmutableString, group_by: Array, rule: ImmutableString| -> Built<Graph> {
485            let node = node_map(
486                "reduce",
487                &g.ty,
488                &[
489                    ("via", "rule".into()),
490                    ("rule", rule.into()),
491                    ("emits", g.ty.clone().into()),
492                    ("group_by", group_by.into()),
493                ],
494            );
495            g.stage(&name, node)
496        },
497    );
498    engine.register_fn(
499        "reduce_with",
500        |g: Graph, name: ImmutableString, group_by: Array, task: ImmutableString| -> Built<Graph> {
501            let node = node_map(
502                "reduce",
503                &g.ty,
504                &[
505                    ("emits", g.ty.clone().into()),
506                    ("group_by", group_by.into()),
507                    ("task", task.into()),
508                ],
509            );
510            g.stage(&name, node)
511        },
512    );
513
514    // `edit`, the only primitive that changes a repository.
515    engine.register_fn(
516        "edit",
517        |g: Graph,
518         name: ImmutableString,
519         check: ImmutableString,
520         task: ImmutableString|
521         -> Built<Graph> {
522            let node = node_map(
523                "edit",
524                &g.ty,
525                &[("check", check.into()), ("task", task.into())],
526            );
527            let mut g = g.stage(&name, node)?;
528            g.repo = true;
529            Ok(g)
530        },
531    );
532
533    // `check`, which annotates a verdict a guard may read.
534    engine.register_fn(
535        "check",
536        |g: Graph, name: ImmutableString, rule: ImmutableString| -> Built<Graph> {
537            let node = node_map("check", &g.ty, &[("rule", rule.into())]);
538            g.stage(&name, node)
539        },
540    );
541
542    // `emit`, which writes outside the repository and returns its input.
543    engine.register_fn(
544        "emit_todo",
545        |g: Graph, name: ImmutableString, action: ImmutableString, fields: Map| -> Built<Graph> {
546            let node = node_map(
547                "emit",
548                &g.ty,
549                &[
550                    ("sink", "todo".into()),
551                    ("action", action.into()),
552                    ("map", fields.into()),
553                ],
554            );
555            let mut g = g.stage(&name, node)?;
556            g.writes = true;
557            Ok(g)
558        },
559    );
560    engine.register_fn(
561        "emit_note",
562        |g: Graph, name: ImmutableString, fields: Map| -> Built<Graph> {
563            let node = node_map(
564                "emit",
565                &g.ty,
566                &[
567                    ("sink", "note".into()),
568                    ("action", "add".into()),
569                    ("map", fields.into()),
570                ],
571            );
572            let mut g = g.stage(&name, node)?;
573            g.writes = true;
574            Ok(g)
575        },
576    );
577
578    // A call to another workflow. Named `invoke` because Rhai's own `call`
579    // applies a function pointer, and a method that shadowed it would report
580    // the wrong error. The callee's return type is stated here and checked
581    // against the callee where the document is read.
582    engine.register_fn(
583        "invoke",
584        |g: Graph,
585         name: ImmutableString,
586         workflow: ImmutableString,
587         with: Map,
588         emits: ImmutableString|
589         -> Built<Graph> {
590            let node = node_map(
591                "call",
592                &g.ty,
593                &[("workflow", workflow.into()), ("with", with.into())],
594            );
595            let mut g = g.stage(&name, node)?;
596            g.ty = emits;
597            g.opaque = true;
598            Ok(g)
599        },
600    );
601
602    // A retry belongs to the node just added, so it reads as a property of the
603    // stage rather than a fifth argument to it.
604    engine.register_fn(
605        "retrying",
606        |g: Graph, max: Dynamic, predicate: ImmutableString| -> Built<Graph> {
607            retrying(g, max, predicate, None)
608        },
609    );
610    engine.register_fn(
611        "retrying",
612        |g: Graph,
613         max: Dynamic,
614         predicate: ImmutableString,
615         model: ImmutableString|
616         -> Built<Graph> { retrying(g, max, predicate, Some(model)) },
617    );
618
619    // Guards, the default path, and a bounded back edge.
620    engine.register_fn("when", |mut g: Graph, guard: Map| {
621        g.pending = Some(guard);
622        g
623    });
624    engine.register_fn(
625        "lap",
626        |mut g: Graph, back_to: ImmutableString, guard: Map, max_laps: Dynamic| -> Built<Graph> {
627            let [from] = &g.tail[..] else {
628                return fail("a lap edge needs one node to start from".into());
629            };
630            g.edges
631                .push(edge_map(from, &back_to, Some(guard), false, Some(max_laps)));
632            Ok(g)
633        },
634    );
635
636    engine.register_fn("output", |mut g: Graph| -> Built<Graph> {
637        if g.tail.is_empty() {
638            return fail("the graph has already reached `@output`".into());
639        }
640        let guard = g.pending.take();
641        let default = std::mem::take(&mut g.pending_default);
642        for from in std::mem::take(&mut g.tail) {
643            g.edges
644                .push(edge_map(&from, "@output", guard.clone(), default, None));
645        }
646        g.out_ty = Some(g.ty.clone());
647        Ok(g)
648    });
649}
650
651fn retrying(
652    mut g: Graph,
653    max: Dynamic,
654    predicate: ImmutableString,
655    model: Option<ImmutableString>,
656) -> Built<Graph> {
657    let Some(last) = g.nodes.last_mut() else {
658        return fail("`retrying` describes the stage before it, and there is none".into());
659    };
660    let Some(mut node) = last.clone().try_cast::<Map>() else {
661        return fail("`retrying` describes a stage".into());
662    };
663    let mut retry = map(&[("max", max), ("while", predicate.into())]);
664    if let Some(m) = model {
665        retry.insert(
666            "escalate".into(),
667            Map::from_iter([("model".into(), m.into())]).into(),
668        );
669    }
670    node.insert("retry".into(), retry.into());
671    *last = node.into();
672    Ok(g)
673}
674
675/// A fan-out and the workflow a graph becomes. Both take a script function, so
676/// a branch of the graph is a value like any other.
677fn register_composition(engine: &mut Engine) {
678    engine.register_fn(
679        "fan",
680        |ctx: NativeCallContext, g: Graph, branches: Array| -> Built<Graph> {
681            if branches.is_empty() {
682                return fail("`fan` needs at least one branch".into());
683            }
684            let mut out = Graph {
685                tail: Vec::new(),
686                pending: None,
687                pending_default: false,
688                ..g.clone()
689            };
690            for branch in branches {
691                let Some(f) = branch.try_cast::<FnPtr>() else {
692                    return fail("`fan` takes functions from a graph to a graph".into());
693                };
694                let start = Graph {
695                    nodes: Array::new(),
696                    edges: Array::new(),
697                    tail: g.tail.clone(),
698                    ty: g.ty.clone(),
699                    pending: g.pending.clone(),
700                    ..Graph::default()
701                };
702                let done: Graph = f.call_within_context(&ctx, (start,))?;
703                out.nodes.extend(done.nodes);
704                out.edges.extend(done.edges);
705                out.tail.extend(done.tail);
706                out.repo |= done.repo;
707                out.writes |= done.writes;
708                out.opaque |= done.opaque;
709                out.ty = done.ty;
710            }
711            Ok(out)
712        },
713    );
714
715    // A branch off the current stage, taking the units its guards refused. The
716    // main path is unchanged, which is what a lap edge's terminal path needs.
717    engine.register_fn(
718        "otherwise",
719        |ctx: NativeCallContext, g: Graph, branch: FnPtr| -> Built<Graph> {
720            let start = Graph {
721                tail: g.tail.clone(),
722                ty: g.ty.clone(),
723                pending_default: true,
724                ..Graph::default()
725            };
726            let done: Graph = branch.call_within_context(&ctx, (start,))?;
727            let mut out = g;
728            out.nodes.extend(done.nodes);
729            out.edges.extend(done.edges);
730            out.repo |= done.repo;
731            out.writes |= done.writes;
732            out.opaque |= done.opaque;
733            Ok(out)
734        },
735    );
736
737    engine.register_fn(
738        "workflow",
739        |name: ImmutableString, g: Graph, opts: Map| -> Built<Map> {
740            if let Some(key) = opts
741                .keys()
742                .find(|k| !["params", "caps", "effects"].contains(&k.as_str()))
743            {
744                return fail(format!(
745                    "workflow `{name}`: unknown option `{key}`; expected params, caps or effects"
746                ));
747            }
748            let mut w = Map::new();
749            w.insert("name".into(), name.clone().into());
750            w.insert("in".into(), g.input_ty.clone().into());
751            if let Some(out) = &g.out_ty {
752                w.insert("out".into(), out.clone().into());
753            }
754            // Effects are asserted when stated and inferred otherwise. Either
755            // way the stored document declares them, so a caller reads the
756            // signature rather than the graph.
757            let effects = match opts.get("effects") {
758                Some(stated) => stated.clone(),
759                None if g.opaque => {
760                    return fail(format!(
761                        "workflow `{name}`: it invokes another workflow, whose effects are not \
762                         visible here, so state its own: effects: [\"repo\"], [\"writes\"], \
763                         both, or []"
764                    ));
765                }
766                None => {
767                    let mut list = Array::new();
768                    if g.repo {
769                        list.push("repo".into());
770                    }
771                    if g.writes {
772                        list.push("writes".into());
773                    }
774                    list.into()
775                }
776            };
777            w.insert("effects".into(), effects);
778            w.insert(
779                "params".into(),
780                opts.get("params")
781                    .cloned()
782                    .unwrap_or_else(|| Map::new().into()),
783            );
784            let Some(caps) = opts.get("caps") else {
785                return fail(format!(
786                    "workflow `{name}`: no `caps`; state max_units and max_edits per instance"
787                ));
788            };
789            w.insert("caps".into(), caps.clone());
790            w.insert("nodes".into(), g.nodes.into());
791            w.insert("edges".into(), g.edges.into());
792            Ok(w)
793        },
794    );
795}
796
797/// A script's value as JSON. Only the six shapes JSON has are accepted, so a
798/// document cannot carry a Rhai value nothing downstream could read.
799fn to_json(d: &Dynamic) -> Result<Value, String> {
800    if d.is_unit() {
801        return Ok(Value::Null);
802    }
803    if let Some(b) = d.clone().try_cast::<bool>() {
804        return Ok(Value::Bool(b));
805    }
806    if let Some(n) = d.clone().try_cast::<i64>() {
807        return Ok(Value::from(n));
808    }
809    if let Some(f) = d.clone().try_cast::<f64>() {
810        return serde_json::Number::from_f64(f)
811            .map(Value::Number)
812            .ok_or_else(|| format!("{f} is not a number JSON can hold"));
813    }
814    if let Some(s) = d.clone().try_cast::<ImmutableString>() {
815        return Ok(Value::String(s.into_owned()));
816    }
817    if let Some(a) = d.clone().try_cast::<Array>() {
818        return a
819            .iter()
820            .map(to_json)
821            .collect::<Result<Vec<_>, _>>()
822            .map(Value::Array);
823    }
824    if let Some(m) = d.clone().try_cast::<Map>() {
825        let mut out = serde_json::Map::new();
826        for (k, v) in &m {
827            out.insert(k.to_string(), to_json(v)?);
828        }
829        return Ok(Value::Object(out));
830    }
831    Err(format!(
832        "a document cannot hold a {}; use a string, number, boolean, list or map",
833        d.type_name()
834    ))
835}
836
837#[cfg(test)]
838mod tests {
839    use super::*;
840
841    /// The same workflow the JSON tests use, written as a script.
842    const FIND_ISSUES: &str = r#"
843        // Two reviewers would differ only in their prompt, so the nodes are
844        // built rather than copied.
845        let review = node("review", "map", #{
846            "in": "project", out: "0..n", emits: "finding",
847            max_units: "{$breadth}", doc: "{$review_doc}",
848            task: "Review `{name}`. Write prose to {doc} and findings to {out}.",
849        });
850
851        let confirm = node("confirm", "map", #{
852            "in": "finding", out: "0..1", emits: "finding", writes: ["reason"],
853            task: "Confirm each unit in {in}. Drop what you cannot prove, with a `reason`.",
854        });
855
856        let dedupe = node("dedupe", "reduce", #{
857            "in": "finding", emits: "finding", via: "rule", rule: "dedupe",
858            group_by: ["title"],
859        });
860
861        document(#{
862            finding: #{ fields: #{
863                severity: req(choice(["critical", "high"])),
864                title: req(unique(line(200))),
865                detail: lines(40),
866                reason: line(200),
867            }},
868        }, [#{
869            name: "find-issues",
870            "in": "project", out: "finding", effects: [],
871            params: #{
872                review_doc: param("name", "REVIEW.md"),
873                breadth: bounded("int", 20, 40),
874            },
875            caps: #{ max_units: 60, max_edits: 0 },
876            nodes: [review, confirm, dedupe],
877            edges: [
878                edge("@input", "review"),
879                edge("review", "confirm"),
880                edge("confirm", "dedupe"),
881                edge("dedupe", "@output"),
882            ],
883        }])
884    "#;
885
886    #[test]
887    fn a_script_builds_the_same_document_json_states() {
888        let from_script = Document::from_script(FIND_ISSUES, "find.rhai").unwrap();
889        let w = from_script.workflow("find-issues").unwrap();
890        assert_eq!(w.input, "project");
891        assert_eq!(w.output.as_deref(), Some("finding"));
892        assert_eq!(w.nodes.len(), 3);
893        assert_eq!(w.params["breadth"].max, Some(40));
894        // And it costs what the graph costs, not what the script did.
895        let e = from_script.estimate("find-issues", 1, 1.0).unwrap();
896        assert_eq!(e.agent_runs, 41);
897    }
898
899    /// The reason to have a script at all: one loop where a document repeats.
900    #[test]
901    fn a_loop_builds_a_node_per_dimension() {
902        let script = r#"
903            let dimensions = ["bugs", "tests", "api"];
904            let nodes = [];
905            let edges = [];
906            for d in dimensions {
907                nodes.push(node(d, "map", #{
908                    "in": "project", out: "0..n", emits: "finding", max_units: 10,
909                    task: "Review {name} for " + d + ". Write findings to {out}.",
910                }));
911                edges.push(edge("@input", d));
912                edges.push(edge(d, "merge"));
913            }
914            nodes.push(node("merge", "reduce", #{
915                "in": "finding", emits: "finding", via: "rule", rule: "dedupe",
916                group_by: ["title"],
917            }));
918            edges.push(edge("merge", "@output"));
919
920            document(#{ finding: #{ fields: #{
921                title: req(line(200)),
922            }}}, [#{
923                name: "ensemble", "in": "project", out: "finding", effects: [],
924                caps: #{ max_units: 80, max_edits: 0 },
925                nodes: nodes, edges: edges,
926            }])
927        "#;
928        let doc = Document::from_script(script, "ensemble.rhai").unwrap();
929        let w = doc.workflow("ensemble").unwrap();
930        assert_eq!(w.nodes.len(), 4, "three reviewers and a reducer");
931        let e = doc.estimate("ensemble", 2, 1.0).unwrap();
932        // Three reviewers over two projects, then a rule that costs nothing.
933        assert_eq!(e.agent_runs, 6);
934        assert!(w.node("api").is_some());
935    }
936
937    /// One checker, whichever form the document arrived in.
938    #[test]
939    fn a_script_is_held_to_the_same_refusals() {
940        let broken = FIND_ISSUES.replace(r#"max_units: "{$breadth}","#, "");
941        let e = Document::from_script(&broken, "find.rhai").unwrap_err();
942        assert!(e.contains("`0..n` map must state `max_units`"), "{e}");
943        assert!(e.starts_with("find.rhai:"), "{e}");
944    }
945
946    #[test]
947    fn a_script_must_end_with_a_document() {
948        let e = Document::from_script("40 + 2", "n.rhai").unwrap_err();
949        assert!(e.contains("last expression is the document"), "{e}");
950        assert!(e.contains("a number"), "{e}");
951    }
952
953    /// The module system is compiled out, so a script cannot reach the
954    /// filesystem or another script whatever it writes.
955    #[test]
956    fn a_script_cannot_import_anything() {
957        let e = Document::from_script(r#"import "fs" as fs; fs::read("/etc/passwd")"#, "i.rhai")
958            .unwrap_err();
959        assert!(e.to_lowercase().contains("import"), "{e}");
960    }
961
962    /// `eval` would let a document carry a script that builds another one,
963    /// which is not reviewable.
964    #[test]
965    fn a_script_cannot_eval() {
966        let e = Document::from_script(r#"eval("40 + 2")"#, "e.rhai").unwrap_err();
967        assert!(!e.is_empty(), "eval must not succeed");
968        assert!(
969            e.contains("eval") || e.contains("last expression is the document"),
970            "{e}"
971        );
972    }
973
974    /// A document is read before anything runs, so a script that will not stop
975    /// must be stopped rather than waited on.
976    #[test]
977    fn a_runaway_script_is_terminated() {
978        let e = Document::from_script("let n = 0; loop { n += 1; } n", "loop.rhai").unwrap_err();
979        assert!(e.to_lowercase().contains("operation"), "{e}");
980    }
981
982    /// The clock is compiled out: two readings of one script must not differ.
983    #[test]
984    fn a_script_cannot_read_the_clock() {
985        let e = Document::from_script("timestamp()", "t.rhai").unwrap_err();
986        assert!(!e.is_empty(), "timestamp must not resolve");
987    }
988
989    /// The combinator form of the same workflow: a stage is wired by
990    /// application, so no edge is written down and no node name is repeated.
991    const ENSEMBLE: &str = r#"
992        fn reviewer(node, what) {
993            |g| g.expand(node, "finding", "{$breadth}",
994                         "Review `{name}` for " + what + ". Write findings to {out}.")
995        }
996
997        let graph = source("project")
998            .fan([
999                reviewer("bugs",  "correctness bugs"),
1000                reviewer("tests", "missing tests"),
1001                reviewer("api",   "interfaces that are easy to use incorrectly"),
1002            ])
1003            .join("merge", ["title"])
1004            .filter("confirm", ["reason"], "Confirm each unit in {in}.")
1005            .output();
1006
1007        document(#{ finding: #{ fields: #{
1008            title: req(unique(line(200))),
1009            reason: line(200),
1010        }}}, [
1011            workflow("ensemble-review", graph, #{
1012                params: #{ breadth: bounded("int", 10, 20) },
1013                caps:   #{ max_units: 80, max_edits: 0 },
1014            }),
1015        ])
1016    "#;
1017
1018    #[test]
1019    fn a_fan_and_a_join_wire_themselves() {
1020        let doc = Document::from_script(ENSEMBLE, "e.rhai").unwrap();
1021        let w = doc.workflow("ensemble-review").unwrap();
1022        assert_eq!(
1023            w.nodes.iter().map(|n| n.name.as_str()).collect::<Vec<_>>(),
1024            ["bugs", "tests", "api", "merge", "confirm"]
1025        );
1026        // Three branches from the input, three into the barrier, then one each.
1027        let from = |name: &str| {
1028            w.edges
1029                .iter()
1030                .filter(|e| match &e.from {
1031                    crate::workflow::From::Input => name == "@input",
1032                    crate::workflow::From::Node(n) => n == name,
1033                })
1034                .count()
1035        };
1036        assert_eq!(from("@input"), 3);
1037        assert_eq!((from("bugs"), from("tests"), from("api")), (1, 1, 1));
1038        assert_eq!(w.edges.len(), 8);
1039        assert_eq!(w.input, "project");
1040        assert_eq!(w.output.as_deref(), Some("finding"));
1041    }
1042
1043    /// A reusable piece of a graph is a function, so two of them compose by
1044    /// application and nothing in either names the other.
1045    #[test]
1046    fn pipelines_compose_as_functions() {
1047        let script = r#"
1048            fn find(g) { g.expand("review", "finding", 5, "Review {name}.") }
1049            fn fix(g)  { g.edit("patch", "verify", "Fix {title}.") }
1050
1051            document(#{ finding: #{ fields: #{ title: req(line(200)) }}}, [
1052                workflow("both", fix(find(source("project"))).output(), #{
1053                    caps: #{ max_units: 20, max_edits: 5 },
1054                }),
1055            ])
1056        "#;
1057        let doc = Document::from_script(script, "c.rhai").unwrap();
1058        let w = doc.workflow("both").unwrap();
1059        assert_eq!(
1060            w.nodes.iter().map(|n| n.name.as_str()).collect::<Vec<_>>(),
1061            ["review", "patch"]
1062        );
1063        // Effects follow from the graph rather than from a declaration.
1064        assert!(w.effects.repo && !w.effects.writes);
1065    }
1066
1067    /// A lap edge and the terminal path it requires both leave one stage, and
1068    /// `retrying` describes the stage before it.
1069    #[test]
1070    fn a_lap_its_default_path_and_a_retry_come_from_the_stage() {
1071        let script = r#"
1072            let graph = source("finding")
1073                .edit("fix", "verify", "Fix {title}.")
1074                .retrying("{$laps}", "@verify != passed")
1075                .transform("audit", ["verdict"], "Judge {title}.")
1076                .lap("fix", #{ verdict: ["reject"] }, "{$laps}")
1077                .otherwise(|g| g.emit_todo("handoff", "add", #{ text: "{title}", priority: "high" }))
1078                .when(#{ verdict: ["accept"] })
1079                .output();
1080
1081            document(#{ finding: #{ fields: #{
1082                title: req(line(200)),
1083                verdict: choice(["accept", "reject"]),
1084            }}}, [
1085                workflow("audited", graph, #{
1086                    params: #{ laps: bounded("int", 2, 3) },
1087                    caps: #{ max_units: 20, max_edits: 6 },
1088                }),
1089            ])
1090        "#;
1091        let doc = Document::from_script(script, "l.rhai").unwrap();
1092        let w = doc.workflow("audited").unwrap();
1093        let audit = w.node("audit").unwrap();
1094        let crate::workflow::Op::Map(_) = &audit.op else {
1095            panic!("audit is a map")
1096        };
1097        let leaving: Vec<_> = w
1098            .edges
1099            .iter()
1100            .filter(|e| e.from == crate::workflow::From::Node("audit".into()))
1101            .collect();
1102        assert_eq!(leaving.len(), 3, "output, lap and default");
1103        assert_eq!(leaving.iter().filter(|e| e.max_laps.is_some()).count(), 1);
1104        assert_eq!(leaving.iter().filter(|e| e.default).count(), 1);
1105        // The retry belongs to `fix`, not to the graph.
1106        let crate::workflow::Op::Edit(fix) = &w.node("fix").unwrap().op else {
1107            panic!("fix is an edit")
1108        };
1109        assert!(fix.retry.is_some());
1110        assert!(w.effects.repo && w.effects.writes, "the handoff writes");
1111    }
1112
1113    /// A callee's effects are not visible from a caller's graph, so the
1114    /// caller states them rather than having an empty set inferred.
1115    #[test]
1116    fn a_workflow_that_invokes_another_states_its_own_effects() {
1117        let script = r#"
1118            document(#{ finding: #{ fields: #{ title: req(line(200)) }}}, [
1119                workflow("inner", source("project")
1120                    .edit("touch", "verify", "Change something in {name}.")
1121                    .output(), #{ caps: #{ max_units: 4, max_edits: 2 }}),
1122                workflow("outer", source("project")
1123                    .invoke("inner_call", "inner", #{}, "project")
1124                    .output(), #{ caps: #{ max_units: 4, max_edits: 2 }}),
1125            ])
1126        "#;
1127        let e = Document::from_script(script, "i.rhai").unwrap_err();
1128        assert!(e.contains("invokes another workflow"), "{e}");
1129        let fixed = script.replace(
1130            r#"#{ caps: #{ max_units: 4, max_edits: 2 }}),
1131            ])"#,
1132            r#"#{ effects: ["repo"], caps: #{ max_units: 4, max_edits: 2 }}),
1133            ])"#,
1134        );
1135        let doc = Document::from_script(&fixed, "i.rhai").unwrap();
1136        assert!(doc.workflow("outer").unwrap().effects.repo);
1137    }
1138
1139    #[test]
1140    fn a_builder_refuses_a_stage_it_cannot_wire() {
1141        let twice = r#"
1142            document(#{}, [workflow("w", source("project")
1143                .expand("a", "project", 2, "One {name}.")
1144                .expand("a", "project", 2, "Two {name}.")
1145                .output(), #{ caps: #{ max_units: 4, max_edits: 0 }})])
1146        "#;
1147        assert!(
1148            Document::from_script(twice, "t.rhai")
1149                .unwrap_err()
1150                .contains("added twice")
1151        );
1152        let after = r#"
1153            document(#{}, [workflow("w", source("project")
1154                .expand("a", "project", 2, "One {name}.")
1155                .output()
1156                .expand("b", "project", 2, "Two {name}.")
1157                , #{ caps: #{ max_units: 4, max_edits: 0 }})])
1158        "#;
1159        assert!(
1160            Document::from_script(after, "a.rhai")
1161                .unwrap_err()
1162                .contains("already reached `@output`")
1163        );
1164    }
1165
1166    /// A graph is not a document: it must go through `workflow`, which is
1167    /// where caps and the signature are stated.
1168    #[test]
1169    fn a_workflow_states_its_caps() {
1170        let e = Document::from_script(
1171            r#"document(#{}, [workflow("w", source("project")
1172                 .expand("a", "project", 2, "{name}").output(), #{})])"#,
1173            "w.rhai",
1174        )
1175        .unwrap_err();
1176        assert!(e.contains("no `caps`"), "{e}");
1177    }
1178
1179    #[test]
1180    fn only_json_shapes_reach_the_document() {
1181        let e = Document::from_script(r#"document(#{}, [|| 1])"#, "f.rhai").unwrap_err();
1182        assert!(e.contains("cannot hold"), "{e}");
1183    }
1184}