1use std::sync::Arc;
16use std::sync::atomic::{AtomicBool, Ordering};
17use std::time::Duration;
18
19use tokio::sync::Semaphore;
20use tracing::Instrument;
21
22use crate::ast::{Command, Redirect, Value};
23use crate::dispatch::CommandDispatcher;
24use crate::duration::parse_duration;
25use crate::interpreter::ExecResult;
26use crate::tools::{ExecContext, ToolRegistry};
27
28use super::pipeline::{apply_redirects, PipelineRunner};
29
30#[derive(Debug, Clone)]
32pub struct ScatterOptions {
33 pub var_name: String,
35 pub limit: usize,
37 pub timeout: Option<Duration>,
41}
42
43#[derive(Debug, Clone, Default)]
45pub struct GatherOptions {
46 pub lines: bool,
51 pub json: bool,
55}
56
57impl Default for ScatterOptions {
58 fn default() -> Self {
59 Self {
60 var_name: "ITEM".to_string(),
61 limit: 8,
62 timeout: None,
63 }
64 }
65}
66
67#[derive(Debug, Clone)]
75pub struct ScatterItem {
76 pub json: serde_json::Value,
78 pub label: String,
80}
81
82impl ScatterItem {
83 fn new(json: serde_json::Value) -> Self {
84 let full = match &json {
85 serde_json::Value::String(s) => s.clone(),
86 other => other.to_string(),
87 };
88 let label = if full.chars().count() > 64 {
90 let head: String = full.chars().take(64).collect();
91 format!("{head}...")
92 } else {
93 full
94 };
95 Self { json, label }
96 }
97
98 fn from_text_line(line: &str) -> Self {
99 Self::new(serde_json::Value::String(line.to_string()))
100 }
101}
102
103#[derive(Debug, Clone)]
105pub struct ScatterResult {
106 pub item: ScatterItem,
108 pub result: ExecResult,
110 pub timed_out: bool,
112}
113
114pub struct ScatterGatherRunner {
121 tools: Arc<ToolRegistry>,
122 sequential_dispatcher: Arc<dyn CommandDispatcher>,
125}
126
127impl ScatterGatherRunner {
128 pub fn new(
133 tools: Arc<ToolRegistry>,
134 dispatcher: Arc<dyn CommandDispatcher>,
135 ) -> Self {
136 Self { tools, sequential_dispatcher: dispatcher }
137 }
138
139 #[tracing::instrument(level = "info", skip(self, pre_scatter, scatter_opts, parallel, gather_opts, post_gather, ctx), fields(item_count = tracing::field::Empty, parallelism = scatter_opts.limit))]
148 #[allow(clippy::too_many_arguments)]
149 pub async fn run(
150 &self,
151 pre_scatter: &[Command],
152 scatter_opts: ScatterOptions,
153 parallel: &[Command],
154 gather_opts: GatherOptions,
155 gather_redirects: &[Redirect],
156 post_gather: &[Command],
157 ctx: &mut ExecContext,
158 ) -> ExecResult {
159 let runner = PipelineRunner::new(self.tools.clone());
160
161 let (text, data) = if pre_scatter.is_empty() {
164 let data = ctx.take_stdin_data();
168 let text = match ctx.read_stdin_to_text().await {
169 Ok(s) => s.unwrap_or_default(),
170 Err(e) => return ExecResult::failure(2, format!("scatter: {e}")),
171 };
172 (text, data)
173 } else {
174 let result = runner.run_sequential(pre_scatter, ctx, &*self.sequential_dispatcher).await;
175 if !result.ok() {
176 return result;
177 }
178 (result.text_out().into_owned(), result.data)
179 };
180
181 let items = match extract_items(data.as_ref(), &text) {
183 Ok(items) => items,
184 Err(msg) => return ExecResult::failure(1, msg),
185 };
186 if items.is_empty() {
187 return ExecResult::success("");
188 }
189
190 tracing::Span::current().record("item_count", items.len());
191
192 let results = self
194 .run_parallel(&items, &scatter_opts, parallel, ctx)
195 .await;
196
197 let gathered = gather_results(&results, &gather_opts);
201
202 let gathered = apply_redirects(gathered, gather_redirects, ctx).await;
214
215 if post_gather.is_empty() || gathered.code != 0 {
218 gathered
219 } else {
220 ctx.set_stdin_with_data(
221 gathered.text_out().into_owned(),
222 gathered.data.clone(),
223 );
224 runner.run_sequential(post_gather, ctx, &*self.sequential_dispatcher).await
225 }
226 }
227
228 #[tracing::instrument(level = "debug", skip(self, items, opts, commands, base_ctx), fields(worker_count = items.len()))]
237 async fn run_parallel(
238 &self,
239 items: &[ScatterItem],
240 opts: &ScatterOptions,
241 commands: &[Command],
242 base_ctx: &ExecContext,
243 ) -> Vec<ScatterResult> {
244 let semaphore = Arc::new(Semaphore::new(opts.limit));
245 let tools = self.tools.clone();
246 let var_name = opts.var_name.clone();
247
248 let mut handles = Vec::with_capacity(items.len());
250
251 for item in items.iter().cloned() {
252 let permit = semaphore.clone().acquire_owned().await;
253 let tools = tools.clone();
254 let worker_dispatcher = self.sequential_dispatcher.fork_attached().await;
259 let commands = commands.to_vec();
260 let parent_token = base_ctx.cancel.clone();
261 let worker_token = parent_token.child_token();
262
263 let mut worker_ctx = base_ctx.child_for_pipeline();
278 worker_ctx.scope.set(
282 &var_name,
283 crate::interpreter::json_to_value_no_envelope(item.json.clone()),
284 );
285 worker_ctx.cancel = worker_token.clone();
288
289 let timed_out_flag = Arc::new(AtomicBool::new(false));
295 let timer_handle: Option<tokio::task::JoinHandle<()>> = opts.timeout.map(|d| {
296 let cancel = worker_token.clone();
297 let flag = timed_out_flag.clone();
298 tokio::spawn(async move {
299 tokio::time::sleep(d).await;
300 flag.store(true, Ordering::SeqCst);
301 cancel.cancel();
302 })
303 });
304 let timed_out_check = timed_out_flag.clone();
305
306 let worker_span = tracing::debug_span!("scatter_worker", item = %item.label);
307 let handle = tokio::spawn(crate::telemetry::bind_current_context(async move {
311 let _permit = permit; let mut worker_ctx = worker_ctx; let runner = PipelineRunner::new(tools);
317 let mut result =
318 runner.run_sequential(&commands, &mut worker_ctx, &*worker_dispatcher).await;
319
320 if worker_ctx.output_limit.is_enabled() {
329 let _ = crate::output_limit::spill_if_needed(
330 &mut result,
331 &worker_ctx.output_limit,
332 )
333 .await;
334 }
335
336 if let Some(h) = timer_handle {
339 h.abort();
340 }
341
342 let timed_out = timed_out_check.load(Ordering::SeqCst);
343 ScatterResult { item, result, timed_out }
344 }.instrument(worker_span)));
345
346 handles.push(handle);
347 }
348
349 let mut results = Vec::with_capacity(handles.len());
351 for handle in handles {
352 match handle.await {
353 Ok(result) => results.push(result),
354 Err(e) => {
355 results.push(ScatterResult {
356 item: ScatterItem::new(serde_json::Value::String(
357 "<worker panicked>".to_string(),
358 )),
359 result: ExecResult::failure(1, format!("Task panicked: {}", e)),
360 timed_out: false,
361 });
362 }
363 }
364 }
365
366 results
367 }
368}
369
370pub fn extract_items(data: Option<&Value>, text: &str) -> Result<Vec<ScatterItem>, String> {
388 match data {
390 Some(Value::Json(serde_json::Value::Array(arr))) => {
392 let mut items = Vec::with_capacity(arr.len());
393 for (i, elem) in arr.iter().enumerate() {
394 if elem.is_null() {
395 return Err(format!(
396 "scatter: item {i} is null — refusing to bind a worker to null \
397 (filter it out first, e.g. jq 'map(select(. != null))')"
398 ));
399 }
400 items.push(ScatterItem::new(elem.clone()));
401 }
402 return Ok(items);
403 }
404 Some(Value::String(s)) => {
406 return Ok(vec![ScatterItem::new(serde_json::Value::String(s.clone()))])
407 }
408 Some(Value::Int(i)) => return Ok(vec![ScatterItem::new(serde_json::json!(i))]),
409 Some(Value::Float(f)) => return Ok(vec![ScatterItem::new(serde_json::json!(f))]),
410 Some(Value::Bool(b)) => return Ok(vec![ScatterItem::new(serde_json::json!(b))]),
411 Some(Value::Null) => {
412 return Err("scatter: input is null — nothing to fan out".to_string())
413 }
414 Some(Value::Json(serde_json::Value::Object(map))) => {
417 let hint = map
418 .iter()
419 .find(|(_, v)| v.is_array())
420 .map(|(k, _)| format!(" (did you mean jq '.{k}'?)"))
421 .unwrap_or_default();
422 return Err(format!(
423 "scatter: input is a single object, not an array — select the array to \
424 fan out over{hint}"
425 ));
426 }
427 Some(Value::Json(serde_json::Value::Null)) => {
428 return Err("scatter: input is null — nothing to fan out".to_string())
429 }
430 Some(Value::Json(json)) => return Ok(vec![ScatterItem::new(json.clone())]),
432 Some(Value::Bytes(b)) => {
435 return Err(format!(
436 "scatter: input is binary ({} bytes) — decode it to text or JSON first",
437 b.len()
438 ))
439 }
440 None => {}
442 }
443
444 let trimmed = text.trim_end_matches(['\n', '\r']);
447 if trimmed.is_empty() {
448 return Ok(vec![]);
449 }
450 Ok(trimmed
451 .split('\n')
452 .map(|line| line.trim_end_matches('\r'))
453 .filter(|line| !line.is_empty())
454 .map(ScatterItem::from_text_line)
455 .collect())
456}
457
458fn strip_one_trailing_newline(s: &str) -> &str {
461 let s = s.strip_suffix('\n').unwrap_or(s);
462 s.strip_suffix('\r').unwrap_or(s)
463}
464
465fn result_row(i: usize, r: &ScatterResult) -> serde_json::Value {
488 let mut ok = r.result.ok() && !r.timed_out;
489 let mut code = if r.timed_out { 124 } else { r.result.code };
490
491 let (out_text, err_text) = match r.result.try_text_out() {
492 Ok(text) => (
493 strip_one_trailing_newline(&text).to_string(),
494 strip_one_trailing_newline(&r.result.err).to_string(),
495 ),
496 Err(e) => {
497 ok = false;
498 if code == 0 {
499 code = 1;
500 }
501 (
502 String::new(),
503 format!(
504 "binary worker output not representable as text ({} bytes) — \
505 encode it in the worker (base64/xxd)",
506 e.len
507 ),
508 )
509 }
510 };
511
512 let mut row = serde_json::Map::new();
513 row.insert("i".into(), serde_json::json!(i));
514 row.insert("item".into(), r.item.json.clone());
515 row.insert("ok".into(), serde_json::json!(ok));
516 row.insert("code".into(), serde_json::json!(code));
517 row.insert("out".into(), serde_json::json!(out_text));
518 row.insert("err".into(), serde_json::json!(err_text));
519 if let Some(data) = &r.result.data {
520 row.insert("data".into(), kaish_types::value_to_json(data));
521 }
522 if r.timed_out {
523 row.insert("timed_out".into(), serde_json::json!(true));
524 }
525 serde_json::Value::Object(row)
526}
527
528fn gather_results(results: &[ScatterResult], opts: &GatherOptions) -> ExecResult {
543 let is_unrepresentable = |r: &ScatterResult| r.result.try_text_out().is_err();
549
550 let failed: Vec<&ScatterResult> = results
551 .iter()
552 .filter(|r| !r.result.ok() || r.timed_out || is_unrepresentable(r))
553 .collect();
554 let code = if failed.is_empty() { 0 } else { 123 };
555 let err = if failed.is_empty() {
556 String::new()
557 } else {
558 let names = failed
559 .iter()
560 .map(|r| {
561 if is_unrepresentable(r) {
562 format!("{} (binary output not representable as text)", r.item.label)
563 } else {
564 r.item.label.clone()
565 }
566 })
567 .collect::<Vec<_>>()
568 .join(", ");
569 format!("gather: {} of {} worker(s) failed: {names}", failed.len(), results.len())
570 };
571
572 if opts.lines {
573 if !failed.is_empty() {
579 return ExecResult::failure(code, format!("{err} (drop --lines to get per-worker rows)"));
580 }
581 let text = results
582 .iter()
583 .map(|r| strip_one_trailing_newline(&r.result.text_out()).to_string())
584 .collect::<Vec<_>>()
585 .join("\n");
586 return ExecResult::success(text);
587 }
588
589 let rows: Vec<serde_json::Value> =
590 results.iter().enumerate().map(|(i, r)| result_row(i, r)).collect();
591 let text = if opts.json {
592 serde_json::to_string_pretty(&rows).unwrap_or_default()
594 } else {
595 rows.iter().map(|row| row.to_string()).collect::<Vec<_>>().join("\n")
597 };
598 let array = serde_json::Value::Array(rows);
599 ExecResult::from_parts(code, text, err, Some(Value::Json(array)))
600}
601
602fn describe_value(v: &Value) -> String {
606 match v {
607 Value::Null => "null".to_string(),
608 Value::Bool(b) => b.to_string(),
609 Value::Int(n) => n.to_string(),
610 Value::Float(f) => f.to_string(),
611 Value::String(s) => format!("{s:?}"),
612 Value::Json(j) => j.to_string(),
613 Value::Bytes(b) => format!("<{} bytes>", b.len()),
614 }
615}
616
617pub fn parse_scatter_options(args: &crate::tools::ToolArgs) -> Result<ScatterOptions, String> {
626 let mut opts = ScatterOptions::default();
627
628 match args.named.get("as") {
629 None => {}
630 Some(Value::String(name)) => opts.var_name = name.clone(),
631 Some(other) => {
632 return Err(format!(
633 "scatter --as: expected a variable name, got {}",
634 describe_value(other)
635 ))
636 }
637 }
638
639 match args.named.get("limit") {
640 None => {}
641 Some(Value::Int(n)) => opts.limit = clamp_scatter_limit(*n),
642 Some(Value::String(s)) => match s.trim().parse::<i64>() {
645 Ok(n) => opts.limit = clamp_scatter_limit(n),
646 Err(_) => {
647 return Err(format!(
648 "scatter --limit: expected a positive integer, got {}",
649 describe_value(&Value::String(s.clone()))
650 ))
651 }
652 },
653 Some(other) => {
654 return Err(format!(
655 "scatter --limit: expected a positive integer, got {}",
656 describe_value(other)
657 ))
658 }
659 }
660
661 match args.named.get("timeout") {
665 None => {}
666 Some(Value::String(s)) => match parse_duration(s) {
667 Some(d) => opts.timeout = Some(d),
668 None => {
669 return Err(format!(
670 "scatter --timeout: invalid duration {} (try: 30, 5s, 500ms, 2m, 1h)",
671 describe_value(&Value::String(s.clone()))
672 ))
673 }
674 },
675 Some(Value::Int(n)) if *n >= 0 => opts.timeout = Some(Duration::from_secs(*n as u64)),
676 Some(other) => {
677 return Err(format!(
678 "scatter --timeout: expected a non-negative duration, got {}",
679 describe_value(other)
680 ))
681 }
682 }
683
684 Ok(opts)
685}
686
687fn clamp_scatter_limit(requested: i64) -> usize {
691 let clamped = requested.clamp(1, SCATTER_LIMIT_MAX as i64);
692 if requested > SCATTER_LIMIT_MAX as i64 {
693 tracing::warn!(
694 target: "kaish::scatter",
695 requested = requested,
696 ceiling = SCATTER_LIMIT_MAX,
697 "scatter limit clamped to ceiling"
698 );
699 }
700 clamped as usize
701}
702
703pub const SCATTER_LIMIT_MAX: usize = 10_000;
707
708pub fn parse_gather_options(args: &crate::tools::ToolArgs) -> Result<GatherOptions, String> {
716 let mut opts = GatherOptions::default();
717
718 if args.has_flag("lines") {
719 opts.lines = true;
720 }
721
722 if args.has_flag("json") {
723 opts.json = true;
724 }
725
726 Ok(opts)
727}
728
729#[cfg(test)]
730mod tests {
731 use super::*;
732
733 fn labels(items: &[ScatterItem]) -> Vec<String> {
734 items.iter().map(|i| i.label.clone()).collect()
735 }
736
737 fn item(s: &str) -> ScatterItem {
738 ScatterItem::new(serde_json::Value::String(s.to_string()))
739 }
740
741 #[test]
742 fn test_extract_items_structured_json_array() {
743 let data = Value::Json(serde_json::json!(["a", "b", "c"]));
744 let items = extract_items(Some(&data), "").unwrap();
745 assert_eq!(labels(&items), vec!["a", "b", "c"]);
746 }
747
748 #[test]
749 fn test_extract_items_structured_mixed_types_stay_typed() {
750 let data = Value::Json(serde_json::json!([1, "1", true, {"id": 7}]));
753 let items = extract_items(Some(&data), "").unwrap();
754 assert_eq!(items[0].json, serde_json::json!(1));
755 assert_eq!(items[1].json, serde_json::json!("1"));
756 assert_ne!(items[0].json, items[1].json, "1 and \"1\" must not conflate");
757 assert_eq!(items[2].json, serde_json::json!(true));
758 assert_eq!(items[3].json, serde_json::json!({"id": 7}));
759 }
760
761 #[test]
762 fn test_extract_items_null_element_is_loud() {
763 let data = Value::Json(serde_json::json!(["a", null, "c"]));
764 let err = extract_items(Some(&data), "").unwrap_err();
765 assert!(err.contains("null"), "should name the problem: {err}");
766 assert!(err.contains("item 1"), "should name the position: {err}");
767 }
768
769 #[test]
770 fn test_extract_items_single_object_is_loud_with_hint() {
771 let data = Value::Json(serde_json::json!({"jobs": [1, 2]}));
772 let err = extract_items(Some(&data), "").unwrap_err();
773 assert!(err.contains("single object"), "{err}");
774 assert!(err.contains("jq '.jobs'"), "should hint the array key: {err}");
775 }
776
777 #[test]
778 fn test_extract_items_binary_is_loud() {
779 let data = Value::Bytes(vec![0, 1, 2]);
780 let err = extract_items(Some(&data), "").unwrap_err();
781 assert!(err.contains("binary"), "{err}");
782 }
783
784 #[test]
785 fn test_extract_items_structured_string() {
786 let data = Value::String("single".into());
787 let items = extract_items(Some(&data), "").unwrap();
788 assert_eq!(labels(&items), vec!["single"]);
789 }
790
791 #[test]
792 fn test_extract_items_single_line_text() {
793 let items = extract_items(None, "hello").unwrap();
794 assert_eq!(labels(&items), vec!["hello"]);
795 }
796
797 #[test]
798 fn test_extract_items_empty() {
799 let items = extract_items(None, "").unwrap();
800 assert!(items.is_empty());
801 }
802
803 #[test]
804 fn test_extract_items_multiline_fans_out_per_line() {
805 let items = extract_items(None, "one\ntwo\nthree").unwrap();
806 assert_eq!(labels(&items), vec!["one", "two", "three"]);
807 }
808
809 #[test]
810 fn test_extract_items_trailing_newline_no_phantom_item() {
811 let items = extract_items(None, "one\ntwo\n").unwrap();
812 assert_eq!(labels(&items), vec!["one", "two"]);
813 }
814
815 #[test]
816 fn test_extract_items_crlf_per_line() {
817 let items = extract_items(None, "one\r\ntwo\r\n").unwrap();
818 assert_eq!(labels(&items), vec!["one", "two"]);
819 }
820
821 #[test]
822 fn test_extract_items_blank_lines_skipped() {
823 let items = extract_items(None, "a\n\nb").unwrap();
826 assert_eq!(labels(&items), vec!["a", "b"]);
827 }
828
829 #[test]
830 fn test_extract_items_whitespace_within_line_not_split() {
831 let items = extract_items(None, "a b\nc d").unwrap();
832 assert_eq!(labels(&items), vec!["a b", "c d"]);
833 }
834
835 #[test]
836 fn test_extract_items_only_newlines_is_empty() {
837 let items = extract_items(None, "\n\n").unwrap();
838 assert!(items.is_empty());
839 }
840
841 #[test]
842 fn test_extract_items_structured_overrides_text() {
843 let data = Value::Json(serde_json::json!(["x", "y"]));
844 let items = extract_items(Some(&data), "ignored\ntext").unwrap();
845 assert_eq!(labels(&items), vec!["x", "y"]);
846 }
847
848 #[test]
849 fn test_item_label_truncates_on_char_boundary() {
850 let long: String = "é".repeat(100);
852 let it = ScatterItem::new(serde_json::Value::String(long));
853 assert!(it.label.ends_with("..."));
854 assert_eq!(it.label.chars().count(), 67);
855 }
856
857 #[test]
858 fn test_gather_results_jsonl_rows_carry_everything() {
859 let results = vec![
860 ScatterResult {
861 item: item("a"),
862 result: ExecResult::success("result_a\n"),
863 timed_out: false,
864 },
865 ScatterResult {
866 item: item("b"),
867 result: ExecResult::failure(7, "boom\n"),
868 timed_out: false,
869 },
870 ];
871 let out = gather_results(&results, &GatherOptions::default());
872 assert_eq!(out.code, 123, "any failure → 123 (A′)");
873 let rows: Vec<serde_json::Value> = out
874 .text_out()
875 .lines()
876 .map(|l| serde_json::from_str(l).unwrap())
877 .collect();
878 assert_eq!(rows.len(), 2, "every worker gets a row, failures included");
879 assert_eq!(rows[0]["i"], 0);
880 assert_eq!(rows[0]["item"], "a");
881 assert_eq!(rows[0]["ok"], true);
882 assert_eq!(rows[0]["out"], "result_a", "trailing newline stripped");
883 assert_eq!(rows[0]["err"], "", "err always present");
884 assert!(rows[0].get("timed_out").is_none(), "omit-false");
885 assert!(rows[0].get("data").is_none(), "omit-empty");
886 assert_eq!(rows[1]["i"], 1);
887 assert_eq!(rows[1]["ok"], false);
888 assert_eq!(rows[1]["code"], 7);
889 assert_eq!(rows[1]["err"], "boom");
890 assert!(matches!(out.data, Some(Value::Json(serde_json::Value::Array(_)))));
892 }
893
894 #[test]
895 fn test_gather_results_all_ok_is_zero() {
896 let results = vec![ScatterResult {
897 item: item("a"),
898 result: ExecResult::success("x"),
899 timed_out: false,
900 }];
901 let out = gather_results(&results, &GatherOptions::default());
902 assert_eq!(out.code, 0);
903 assert!(out.err.is_empty());
904 }
905
906 #[test]
907 fn test_gather_results_timeout_row_is_124() {
908 let results = vec![ScatterResult {
909 item: item("slow"),
910 result: ExecResult::failure(1, "cancelled"),
911 timed_out: true,
912 }];
913 let out = gather_results(&results, &GatherOptions::default());
914 assert_eq!(out.code, 123);
915 let row: serde_json::Value = serde_json::from_str(out.text_out().lines().next().unwrap()).unwrap();
916 assert_eq!(row["code"], 124, "timeout reports the timeout(1) code");
917 assert_eq!(row["ok"], false);
918 assert_eq!(row["timed_out"], true);
919 }
920
921 #[test]
922 fn test_gather_results_typed_record_item_in_row() {
923 let results = vec![ScatterResult {
924 item: ScatterItem::new(serde_json::json!({"id": 3, "host": "web1"})),
925 result: ExecResult::success("ok"),
926 timed_out: false,
927 }];
928 let out = gather_results(&results, &GatherOptions::default());
929 let row: serde_json::Value = serde_json::from_str(out.text_out().lines().next().unwrap()).unwrap();
930 assert_eq!(row["item"]["id"], 3, "row item is the TYPED value, not a string");
931 }
932
933 #[test]
934 fn test_gather_results_worker_data_rides_the_row() {
935 let mut r = ExecResult::success("text");
936 r.data = Some(Value::Json(serde_json::json!({"k": 1})));
937 let results = vec![ScatterResult { item: item("a"), result: r, timed_out: false }];
938 let out = gather_results(&results, &GatherOptions::default());
939 let row: serde_json::Value = serde_json::from_str(out.text_out().lines().next().unwrap()).unwrap();
940 assert_eq!(row["data"]["k"], 1, "worker .data lands typed in the row");
941 assert_eq!(row["out"], "text", "out stays alongside data");
942 }
943
944 #[test]
945 fn test_gather_results_lines_happy_path() {
946 let results = vec![
947 ScatterResult { item: item("a"), result: ExecResult::success("result_a\n"), timed_out: false },
948 ScatterResult { item: item("b"), result: ExecResult::success("result_b"), timed_out: false },
949 ];
950 let out = gather_results(&results, &GatherOptions { lines: true, ..Default::default() });
951 assert_eq!(out.code, 0);
952 assert_eq!(&*out.text_out(), "result_a\nresult_b");
953 }
954
955 #[test]
956 fn test_gather_results_lines_hard_errors_on_any_failure() {
957 let results = vec![
959 ScatterResult { item: item("a"), result: ExecResult::success("good"), timed_out: false },
960 ScatterResult { item: item("b"), result: ExecResult::failure(1, "boom"), timed_out: false },
961 ];
962 let out = gather_results(&results, &GatherOptions { lines: true, ..Default::default() });
963 assert_eq!(out.code, 123);
964 assert!(out.text_out().is_empty(), "no partial text on --lines failure");
965 assert!(out.err.contains("b"), "names the failed item: {}", out.err);
966 }
967
968 #[test]
969 fn test_parse_scatter_options() {
970 use crate::tools::ToolArgs;
971
972 let mut args = ToolArgs::new();
973 args.named.insert("as".to_string(), Value::String("URL".to_string()));
974 args.named.insert("limit".to_string(), Value::Int(4));
975
976 let opts = parse_scatter_options(&args).unwrap();
977 assert_eq!(opts.var_name, "URL");
978 assert_eq!(opts.limit, 4);
979 }
980
981 #[test]
982 fn test_parse_gather_options() {
983 use crate::tools::ToolArgs;
984
985 let mut args = ToolArgs::new();
986 args.flags.insert("lines".to_string());
987
988 let opts = parse_gather_options(&args).unwrap();
989 assert!(opts.lines);
990 assert!(!parse_gather_options(&ToolArgs::new()).unwrap().lines, "default is JSONL");
991 }
992
993 #[test]
994 fn scatter_limit_clamps_to_ceiling() {
995 use crate::tools::ToolArgs;
996
997 let mut args = ToolArgs::new();
998 args.named.insert("limit".to_string(), Value::Int(999_999));
999 let opts = parse_scatter_options(&args).unwrap();
1000 assert_eq!(opts.limit, SCATTER_LIMIT_MAX);
1001 }
1002
1003 #[test]
1004 fn scatter_limit_raises_zero_to_one() {
1005 use crate::tools::ToolArgs;
1006
1007 let mut args = ToolArgs::new();
1008 args.named.insert("limit".to_string(), Value::Int(0));
1009 let opts = parse_scatter_options(&args).unwrap();
1010 assert_eq!(opts.limit, 1);
1011 }
1012
1013 #[test]
1014 fn scatter_limit_raises_negative_to_one() {
1015 use crate::tools::ToolArgs;
1016
1017 let mut args = ToolArgs::new();
1018 args.named.insert("limit".to_string(), Value::Int(-42));
1019 let opts = parse_scatter_options(&args).unwrap();
1020 assert_eq!(opts.limit, 1);
1021 }
1022
1023 #[test]
1024 fn scatter_limit_preserves_valid_values() {
1025 use crate::tools::ToolArgs;
1026
1027 let mut args = ToolArgs::new();
1028 args.named.insert("limit".to_string(), Value::Int(500));
1029 let opts = parse_scatter_options(&args).unwrap();
1030 assert_eq!(opts.limit, 500);
1031 }
1032
1033 #[test]
1036 fn scatter_limit_wrong_type_is_loud_error() {
1037 use crate::tools::ToolArgs;
1038
1039 let mut args = ToolArgs::new();
1040 args.named.insert("limit".to_string(), Value::String("five".to_string()));
1041 let err = parse_scatter_options(&args).unwrap_err();
1042 assert!(err.contains("--limit"), "{err}");
1043 assert!(err.contains("five"), "{err}");
1044 }
1045
1046 #[test]
1047 fn scatter_limit_bool_is_loud_error() {
1048 use crate::tools::ToolArgs;
1049
1050 let mut args = ToolArgs::new();
1051 args.named.insert("limit".to_string(), Value::Bool(true));
1052 let err = parse_scatter_options(&args).unwrap_err();
1053 assert!(err.contains("--limit"), "{err}");
1054 }
1055
1056 #[test]
1057 fn scatter_limit_numeric_string_coerces() {
1058 use crate::tools::ToolArgs;
1060
1061 let mut args = ToolArgs::new();
1062 args.named.insert("limit".to_string(), Value::String("5".to_string()));
1063 let opts = parse_scatter_options(&args).unwrap();
1064 assert_eq!(opts.limit, 5);
1065 }
1066
1067 #[test]
1068 fn scatter_as_wrong_type_is_loud_error() {
1069 use crate::tools::ToolArgs;
1070
1071 let mut args = ToolArgs::new();
1072 args.named.insert("as".to_string(), Value::Int(42));
1073 let err = parse_scatter_options(&args).unwrap_err();
1074 assert!(err.contains("--as"), "{err}");
1075 assert!(err.contains("42"), "{err}");
1076 }
1077
1078 #[test]
1079 fn scatter_timeout_negative_int_is_loud_error() {
1080 use crate::tools::ToolArgs;
1081
1082 let mut args = ToolArgs::new();
1083 args.named.insert("timeout".to_string(), Value::Int(-5));
1084 let err = parse_scatter_options(&args).unwrap_err();
1085 assert!(err.contains("--timeout"), "{err}");
1086 }
1087
1088 #[test]
1089 fn scatter_timeout_unparseable_string_is_loud_error() {
1090 use crate::tools::ToolArgs;
1091
1092 let mut args = ToolArgs::new();
1093 args.named.insert("timeout".to_string(), Value::String("banana".to_string()));
1094 let err = parse_scatter_options(&args).unwrap_err();
1095 assert!(err.contains("--timeout"), "{err}");
1096 assert!(err.contains("banana"), "{err}");
1097 }
1098
1099 #[test]
1100 fn scatter_timeout_valid_duration_string_parses() {
1101 use crate::tools::ToolArgs;
1102
1103 let mut args = ToolArgs::new();
1104 args.named.insert("timeout".to_string(), Value::String("5s".to_string()));
1105 let opts = parse_scatter_options(&args).unwrap();
1106 assert_eq!(opts.timeout, Some(Duration::from_secs(5)));
1107 }
1108
1109 #[test]
1110 fn scatter_timeout_nonnegative_int_is_seconds() {
1111 use crate::tools::ToolArgs;
1112
1113 let mut args = ToolArgs::new();
1114 args.named.insert("timeout".to_string(), Value::Int(30));
1115 let opts = parse_scatter_options(&args).unwrap();
1116 assert_eq!(opts.timeout, Some(Duration::from_secs(30)));
1117 }
1118
1119 fn binary_result(invalid_utf8: Vec<u8>) -> ExecResult {
1122 ExecResult::success_bytes(invalid_utf8)
1123 }
1124
1125 #[test]
1126 fn gather_row_goes_loud_not_lossy_on_binary_out() {
1127 let results = vec![ScatterResult {
1130 item: item("bin"),
1131 result: binary_result(vec![0xFF, 0xFE, 0x00, 0x01]),
1132 timed_out: false,
1133 }];
1134 let out = gather_results(&results, &GatherOptions::default());
1135 assert_eq!(out.code, 123, "a binary row flips the overall exit code too");
1136 let row: serde_json::Value =
1137 serde_json::from_str(out.text_out().lines().next().unwrap()).unwrap();
1138 assert_eq!(row["ok"], false, "binary output must not be silently ok:true");
1139 assert_ne!(row["code"], 0, "must carry a nonzero code");
1140 assert!(row["out"].as_str().unwrap().is_empty(), "no lossy text in out");
1141 let err_text = row["err"].as_str().unwrap();
1142 assert!(err_text.contains("binary"), "{err_text}");
1143 assert!(!err_text.contains('\u{FFFD}'), "must not carry U+FFFD: {err_text}");
1144 }
1145
1146 #[test]
1147 fn gather_lines_hard_errors_on_binary_out() {
1148 let results = vec![
1151 ScatterResult { item: item("a"), result: ExecResult::success("good"), timed_out: false },
1152 ScatterResult {
1153 item: item("bin"),
1154 result: binary_result(vec![0xFF, 0xFE]),
1155 timed_out: false,
1156 },
1157 ];
1158 let out = gather_results(&results, &GatherOptions { lines: true, ..Default::default() });
1159 assert_eq!(out.code, 123);
1160 assert!(out.text_out().is_empty(), "no partial/lossy text on binary --lines failure");
1161 assert!(!out.err.contains('\u{FFFD}'), "must not carry U+FFFD: {}", out.err);
1162 assert!(out.err.contains("binary") || out.err.contains("bin"), "{}", out.err);
1163 }
1164
1165 fn ctx_with_memory_fs() -> ExecContext {
1168 use crate::vfs::{MemoryFs, VfsRouter};
1169 use std::sync::Arc;
1170 let mut vfs = VfsRouter::new();
1171 vfs.mount("/", MemoryFs::new());
1172 ExecContext::new(Arc::new(vfs))
1173 }
1174
1175 #[test]
1176 fn worker_ctx_inherits_parent_watchdog() {
1177 use crate::watchdog::Watchdog;
1178 use std::sync::Arc;
1179
1180 let mut parent = ctx_with_memory_fs();
1181 parent.watchdog = Some(Arc::new(Watchdog::new(Duration::from_secs(30))));
1182
1183 let worker_ctx = parent.child_for_pipeline();
1187 assert!(
1188 worker_ctx.watchdog.is_some(),
1189 "worker must carry the parent's script watchdog, not None"
1190 );
1191
1192 let from_scratch =
1195 ExecContext::with_backend_and_scope(parent.backend.clone(), parent.scope.clone());
1196 assert!(
1197 from_scratch.watchdog.is_none(),
1198 "the abandoned from-scratch path is exactly why the worker lost its watchdog"
1199 );
1200 }
1201
1202 #[tokio::test]
1205 async fn worker_spills_over_the_shared_output_limit() {
1206 use crate::output_limit::{spill_if_needed, OutputLimitConfig};
1207
1208 let mut cfg = OutputLimitConfig::agent().in_memory();
1210 cfg.set_limit(Some(64));
1211
1212 let mut parent = ctx_with_memory_fs();
1213 parent.output_limit = cfg;
1214
1215 let worker_ctx = parent.child_for_pipeline();
1220 assert!(worker_ctx.output_limit.is_enabled(), "budget must reach the worker");
1221
1222 let mut result = ExecResult::success("x".repeat(4096));
1224 assert!(worker_ctx.output_limit.is_enabled());
1225 let _ = spill_if_needed(&mut result, &worker_ctx.output_limit).await;
1226
1227 assert!(result.did_spill, "worker output over the limit must spill, not stay resident");
1228 assert!(
1229 result.text_out().len() < 4096,
1230 "spilled output must be truncated, not the full payload: {} bytes",
1231 result.text_out().len()
1232 );
1233 }
1234}