1use rhai::{Array, Dynamic, Engine, FnPtr, ImmutableString, Map, NativeCallContext};
21use serde_json::Value;
22
23use crate::workflow::Document;
24
25const MAX_OPERATIONS: u64 = 2_000_000;
28const MAX_CALL_DEPTH: usize = 32;
29const 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 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
46pub 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
75fn 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
100fn register(engine: &mut Engine) {
104 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 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 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 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 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 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 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 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#[derive(Clone, Default)]
264pub struct Graph {
265 nodes: Array,
266 edges: Array,
267 tail: Vec<ImmutableString>,
269 ty: ImmutableString,
272 input_ty: ImmutableString,
273 out_ty: Option<ImmutableString>,
274 pending: Option<Map>,
276 pending_default: bool,
278 repo: bool,
279 writes: bool,
280 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 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
359fn register_graph(engine: &mut Engine) {
362 engine.register_type_with_name::<Graph>("Graph");
363
364 engine.register_fn("source", Graph::source);
365
366 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 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 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 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 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 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 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 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 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
675fn 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 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 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
797fn 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 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 let e = from_script.estimate("find-issues", 1, 1.0).unwrap();
896 assert_eq!(e.agent_runs, 41);
897 }
898
899 #[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 assert_eq!(e.agent_runs, 6);
934 assert!(w.node("api").is_some());
935 }
936
937 #[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 #[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 #[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 #[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 #[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 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 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 #[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 assert!(w.effects.repo && !w.effects.writes);
1065 }
1066
1067 #[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 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 #[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 #[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}