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();
169 let text = match ctx.read_stdin_to_text().await {
170 Ok(s) => s.unwrap_or_default(),
171 Err(e) => return ExecResult::failure(2, format!("scatter: {e}")),
172 };
173 (text, data)
174 } else {
175 let mut result = runner.run_sequential(pre_scatter, ctx, &*self.sequential_dispatcher).await;
176 crate::output_limit::apply_spill_contract(&mut result, &ctx.output_limit).await;
193 if !result.ok() {
194 return result;
195 }
196 (result.text_out().into_owned(), result.data)
197 };
198
199 let items = match extract_items(data.as_ref(), &text) {
201 Ok(items) => items,
202 Err(msg) => return ExecResult::failure(1, msg),
203 };
204 if items.is_empty() {
205 return ExecResult::success("");
206 }
207
208 tracing::Span::current().record("item_count", items.len());
209
210 let results = self
212 .run_parallel(&items, &scatter_opts, parallel, ctx)
213 .await;
214
215 let gathered = gather_results(&results, &gather_opts);
219
220 let gathered = apply_redirects(gathered, gather_redirects, ctx, &*self.sequential_dispatcher).await;
232
233 if post_gather.is_empty() || gathered.code != 0 {
236 gathered
237 } else {
238 ctx.set_stdin_with_data(
239 gathered.text_out().into_owned(),
240 gathered.data.clone(),
241 );
242 runner.run_sequential(post_gather, ctx, &*self.sequential_dispatcher).await
243 }
244 }
245
246 #[tracing::instrument(level = "debug", skip(self, items, opts, commands, base_ctx), fields(worker_count = items.len()))]
255 async fn run_parallel(
256 &self,
257 items: &[ScatterItem],
258 opts: &ScatterOptions,
259 commands: &[Command],
260 base_ctx: &ExecContext,
261 ) -> Vec<ScatterResult> {
262 let semaphore = Arc::new(Semaphore::new(opts.limit));
263 let tools = self.tools.clone();
264 let var_name = opts.var_name.clone();
265
266 let mut handles = Vec::with_capacity(items.len());
268
269 for item in items.iter().cloned() {
270 let permit = semaphore.clone().acquire_owned().await;
271 let tools = tools.clone();
272 let worker_dispatcher = self.sequential_dispatcher.fork_attached().await;
277 let commands = commands.to_vec();
278 let parent_token = base_ctx.cancel.clone();
279 let worker_token = parent_token.child_token();
280
281 let mut worker_ctx = base_ctx.child_for_pipeline();
296 worker_ctx.scope.set(
300 &var_name,
301 crate::interpreter::json_to_value_no_envelope(item.json.clone()),
302 );
303 worker_ctx.cancel = worker_token.clone();
306
307 let timed_out_flag = Arc::new(AtomicBool::new(false));
313 let timer_handle: Option<tokio::task::JoinHandle<()>> = opts.timeout.map(|d| {
314 let cancel = worker_token.clone();
315 let flag = timed_out_flag.clone();
316 tokio::spawn(async move {
317 tokio::time::sleep(d).await;
318 flag.store(true, Ordering::SeqCst);
319 cancel.cancel();
320 })
321 });
322 let timed_out_check = timed_out_flag.clone();
323
324 let worker_span = tracing::debug_span!("scatter_worker", item = %item.label);
325 let handle = tokio::spawn(crate::telemetry::bind_current_context(async move {
329 let _permit = permit; let mut worker_ctx = worker_ctx; let runner = PipelineRunner::new(tools);
335 let mut result =
336 runner.run_sequential(&commands, &mut worker_ctx, &*worker_dispatcher).await;
337
338 let genuinely_completed = result.ok();
345
346 crate::output_limit::apply_spill_contract(&mut result, &worker_ctx.output_limit).await;
359
360 if let Some(h) = timer_handle {
363 h.abort();
364 }
365
366 let timed_out = timed_out_check.load(Ordering::SeqCst) && !genuinely_completed;
381
382 ScatterResult { item, result, timed_out }
383 }.instrument(worker_span)));
384
385 handles.push(handle);
386 }
387
388 let mut results = Vec::with_capacity(handles.len());
390 for handle in handles {
391 match handle.await {
392 Ok(result) => results.push(result),
393 Err(e) => {
394 results.push(ScatterResult {
395 item: ScatterItem::new(serde_json::Value::String(
396 "<worker panicked>".to_string(),
397 )),
398 result: ExecResult::failure(1, format!("Task panicked: {}", e)),
399 timed_out: false,
400 });
401 }
402 }
403 }
404
405 results
406 }
407}
408
409pub fn extract_items(data: Option<&Value>, text: &str) -> Result<Vec<ScatterItem>, String> {
427 match data {
429 Some(Value::Json(serde_json::Value::Array(arr))) => {
431 let mut items = Vec::with_capacity(arr.len());
432 for (i, elem) in arr.iter().enumerate() {
433 if elem.is_null() {
434 return Err(format!(
435 "scatter: item {i} is null — filter nulls out first, \
436 e.g. jq 'map(select(. != null))'"
437 ));
438 }
439 items.push(ScatterItem::new(elem.clone()));
440 }
441 return Ok(items);
442 }
443 Some(Value::String(s)) => {
445 return Ok(vec![ScatterItem::new(serde_json::Value::String(s.clone()))])
446 }
447 Some(Value::Int(i)) => return Ok(vec![ScatterItem::new(serde_json::json!(i))]),
448 Some(Value::Float(f)) => return Ok(vec![ScatterItem::new(serde_json::json!(f))]),
449 Some(Value::Bool(b)) => return Ok(vec![ScatterItem::new(serde_json::json!(b))]),
450 Some(Value::Null) => {
451 return Err("scatter: input is null — nothing to fan out".to_string())
452 }
453 Some(Value::Json(serde_json::Value::Object(map))) => {
456 let hint = map
457 .iter()
458 .find(|(_, v)| v.is_array())
459 .map(|(k, _)| format!(" (did you mean jq '.{k}'?)"))
460 .unwrap_or_default();
461 return Err(format!(
462 "scatter: input is a single object, not an array — select the array to \
463 fan out over{hint}"
464 ));
465 }
466 Some(Value::Json(serde_json::Value::Null)) => {
467 return Err("scatter: input is null — nothing to fan out".to_string())
468 }
469 Some(Value::Json(json)) => return Ok(vec![ScatterItem::new(json.clone())]),
471 Some(Value::Bytes(b)) => {
474 return Err(format!(
475 "scatter: input is binary ({} bytes) — decode it to text or JSON first",
476 b.len()
477 ))
478 }
479 None => {}
481 }
482
483 let trimmed = text.trim_end_matches(['\n', '\r']);
486 if trimmed.is_empty() {
487 return Ok(vec![]);
488 }
489 Ok(trimmed
490 .split('\n')
491 .map(|line| line.trim_end_matches('\r'))
492 .filter(|line| !line.is_empty())
493 .map(ScatterItem::from_text_line)
494 .collect())
495}
496
497fn strip_one_trailing_newline(s: &str) -> &str {
500 let s = s.strip_suffix('\n').unwrap_or(s);
501 s.strip_suffix('\r').unwrap_or(s)
502}
503
504fn result_row(i: usize, r: &ScatterResult) -> serde_json::Value {
527 let mut ok = r.result.ok() && !r.timed_out;
528 let mut code = if r.timed_out { 124 } else { r.result.code };
529
530 let (out_text, err_text) = match r.result.try_text_out() {
531 Ok(text) => (
532 strip_one_trailing_newline(&text).to_string(),
533 strip_one_trailing_newline(&r.result.err).to_string(),
534 ),
535 Err(e) => {
536 ok = false;
537 if code == 0 {
538 code = 1;
539 }
540 (
541 String::new(),
542 format!(
543 "binary worker output not representable as text ({} bytes) — \
544 encode it in the worker (base64/xxd)",
545 e.len
546 ),
547 )
548 }
549 };
550
551 let mut row = serde_json::Map::new();
552 row.insert("i".into(), serde_json::json!(i));
553 row.insert("item".into(), r.item.json.clone());
554 row.insert("ok".into(), serde_json::json!(ok));
555 row.insert("code".into(), serde_json::json!(code));
556 row.insert("out".into(), serde_json::json!(out_text));
557 row.insert("err".into(), serde_json::json!(err_text));
558 if let Some(data) = &r.result.data {
559 row.insert("data".into(), kaish_types::value_to_json(data));
560 }
561 if r.timed_out {
562 row.insert("timed_out".into(), serde_json::json!(true));
563 }
564 serde_json::Value::Object(row)
565}
566
567fn gather_results(results: &[ScatterResult], opts: &GatherOptions) -> ExecResult {
582 let is_unrepresentable = |r: &ScatterResult| r.result.try_text_out().is_err();
588
589 let failed: Vec<&ScatterResult> = results
590 .iter()
591 .filter(|r| !r.result.ok() || r.timed_out || is_unrepresentable(r))
592 .collect();
593 let code = if failed.is_empty() { 0 } else { 123 };
594 let err = if failed.is_empty() {
595 String::new()
596 } else {
597 let names = failed
598 .iter()
599 .map(|r| {
600 if is_unrepresentable(r) {
601 format!("{} (binary output not representable as text)", r.item.label)
602 } else {
603 r.item.label.clone()
604 }
605 })
606 .collect::<Vec<_>>()
607 .join(", ");
608 format!("gather: {} of {} worker(s) failed: {names}", failed.len(), results.len())
609 };
610
611 if opts.lines {
612 if !failed.is_empty() {
618 return ExecResult::failure(code, format!("{err} (drop --lines to get per-worker rows)"));
619 }
620 let text = results
621 .iter()
622 .map(|r| strip_one_trailing_newline(&r.result.text_out()).to_string())
623 .collect::<Vec<_>>()
624 .join("\n");
625 return ExecResult::success(text);
626 }
627
628 let rows: Vec<serde_json::Value> =
629 results.iter().enumerate().map(|(i, r)| result_row(i, r)).collect();
630 let text = if opts.json {
631 serde_json::to_string_pretty(&rows).unwrap_or_default()
633 } else {
634 rows.iter().map(|row| row.to_string()).collect::<Vec<_>>().join("\n")
636 };
637 let array = serde_json::Value::Array(rows);
638 let mut result = ExecResult::from_parts(code, text, err, Some(Value::Json(array)));
643 result.data_is_value = true;
644 result
645}
646
647fn describe_value(v: &Value) -> String {
651 match v {
652 Value::Null => "null".to_string(),
653 Value::Bool(b) => b.to_string(),
654 Value::Int(n) => n.to_string(),
655 Value::Float(f) => f.to_string(),
656 Value::String(s) => format!("{s:?}"),
657 Value::Json(j) => j.to_string(),
658 Value::Bytes(b) => format!("<{} bytes>", b.len()),
659 }
660}
661
662pub fn parse_scatter_options(args: &crate::tools::ToolArgs) -> Result<ScatterOptions, String> {
671 let mut opts = ScatterOptions::default();
672
673 match args.named.get("as") {
674 None => {}
675 Some(Value::String(name)) => {
676 crate::name::validate(name)
679 .map_err(|bad| format!("scatter --as: `{name}': {bad}"))?;
680 opts.var_name = name.clone();
681 }
682 Some(other) => {
683 return Err(format!(
684 "scatter --as: expected a variable name, got {}",
685 describe_value(other)
686 ))
687 }
688 }
689
690 match args.named.get("limit") {
691 None => {}
692 Some(Value::Int(n)) => opts.limit = clamp_scatter_limit(*n),
693 Some(Value::String(s)) => match s.trim().parse::<i64>() {
696 Ok(n) => opts.limit = clamp_scatter_limit(n),
697 Err(_) => {
698 return Err(format!(
699 "scatter --limit: expected a positive integer, got {}",
700 describe_value(&Value::String(s.clone()))
701 ))
702 }
703 },
704 Some(other) => {
705 return Err(format!(
706 "scatter --limit: expected a positive integer, got {}",
707 describe_value(other)
708 ))
709 }
710 }
711
712 match args.named.get("timeout") {
716 None => {}
717 Some(Value::String(s)) => match parse_duration(s) {
718 Some(d) => opts.timeout = Some(d),
719 None => {
720 return Err(format!(
721 "scatter --timeout: invalid duration {} (try: 30, 5s, 500ms, 2m, 1h)",
722 describe_value(&Value::String(s.clone()))
723 ))
724 }
725 },
726 Some(Value::Int(n)) if *n >= 0 => opts.timeout = Some(Duration::from_secs(*n as u64)),
727 Some(other) => {
728 return Err(format!(
729 "scatter --timeout: expected a non-negative duration, got {}",
730 describe_value(other)
731 ))
732 }
733 }
734
735 Ok(opts)
736}
737
738fn clamp_scatter_limit(requested: i64) -> usize {
742 let clamped = requested.clamp(1, SCATTER_LIMIT_MAX as i64);
743 if requested > SCATTER_LIMIT_MAX as i64 {
744 tracing::warn!(
745 target: "kaish::scatter",
746 requested = requested,
747 ceiling = SCATTER_LIMIT_MAX,
748 "scatter limit clamped to ceiling"
749 );
750 }
751 clamped as usize
752}
753
754pub const SCATTER_LIMIT_MAX: usize = 10_000;
758
759pub fn parse_gather_options(args: &crate::tools::ToolArgs) -> Result<GatherOptions, String> {
767 let mut opts = GatherOptions::default();
768
769 if args.has_flag("lines") {
770 opts.lines = true;
771 }
772
773 if args.has_flag("json") {
774 opts.json = true;
775 }
776
777 Ok(opts)
778}
779
780#[cfg(test)]
781mod tests {
782 use super::*;
783
784 fn labels(items: &[ScatterItem]) -> Vec<String> {
785 items.iter().map(|i| i.label.clone()).collect()
786 }
787
788 fn item(s: &str) -> ScatterItem {
789 ScatterItem::new(serde_json::Value::String(s.to_string()))
790 }
791
792 #[test]
793 fn test_extract_items_structured_json_array() {
794 let data = Value::Json(serde_json::json!(["a", "b", "c"]));
795 let items = extract_items(Some(&data), "").unwrap();
796 assert_eq!(labels(&items), vec!["a", "b", "c"]);
797 }
798
799 #[test]
800 fn test_extract_items_structured_mixed_types_stay_typed() {
801 let data = Value::Json(serde_json::json!([1, "1", true, {"id": 7}]));
804 let items = extract_items(Some(&data), "").unwrap();
805 assert_eq!(items[0].json, serde_json::json!(1));
806 assert_eq!(items[1].json, serde_json::json!("1"));
807 assert_ne!(items[0].json, items[1].json, "1 and \"1\" must not conflate");
808 assert_eq!(items[2].json, serde_json::json!(true));
809 assert_eq!(items[3].json, serde_json::json!({"id": 7}));
810 }
811
812 #[test]
813 fn test_extract_items_null_element_is_loud() {
814 let data = Value::Json(serde_json::json!(["a", null, "c"]));
815 let err = extract_items(Some(&data), "").unwrap_err();
816 assert!(err.contains("null"), "should name the problem: {err}");
817 assert!(err.contains("item 1"), "should name the position: {err}");
818 }
819
820 #[test]
821 fn test_extract_items_single_object_is_loud_with_hint() {
822 let data = Value::Json(serde_json::json!({"jobs": [1, 2]}));
823 let err = extract_items(Some(&data), "").unwrap_err();
824 assert!(err.contains("single object"), "{err}");
825 assert!(err.contains("jq '.jobs'"), "should hint the array key: {err}");
826 }
827
828 #[test]
829 fn test_extract_items_binary_is_loud() {
830 let data = Value::Bytes(vec![0, 1, 2]);
831 let err = extract_items(Some(&data), "").unwrap_err();
832 assert!(err.contains("binary"), "{err}");
833 }
834
835 #[test]
836 fn test_extract_items_structured_string() {
837 let data = Value::String("single".into());
838 let items = extract_items(Some(&data), "").unwrap();
839 assert_eq!(labels(&items), vec!["single"]);
840 }
841
842 #[test]
843 fn test_extract_items_single_line_text() {
844 let items = extract_items(None, "hello").unwrap();
845 assert_eq!(labels(&items), vec!["hello"]);
846 }
847
848 #[test]
849 fn test_extract_items_empty() {
850 let items = extract_items(None, "").unwrap();
851 assert!(items.is_empty());
852 }
853
854 #[test]
855 fn test_extract_items_multiline_fans_out_per_line() {
856 let items = extract_items(None, "one\ntwo\nthree").unwrap();
857 assert_eq!(labels(&items), vec!["one", "two", "three"]);
858 }
859
860 #[test]
861 fn test_extract_items_trailing_newline_no_phantom_item() {
862 let items = extract_items(None, "one\ntwo\n").unwrap();
863 assert_eq!(labels(&items), vec!["one", "two"]);
864 }
865
866 #[test]
867 fn test_extract_items_crlf_per_line() {
868 let items = extract_items(None, "one\r\ntwo\r\n").unwrap();
869 assert_eq!(labels(&items), vec!["one", "two"]);
870 }
871
872 #[test]
873 fn test_extract_items_blank_lines_skipped() {
874 let items = extract_items(None, "a\n\nb").unwrap();
877 assert_eq!(labels(&items), vec!["a", "b"]);
878 }
879
880 #[test]
881 fn test_extract_items_whitespace_within_line_not_split() {
882 let items = extract_items(None, "a b\nc d").unwrap();
883 assert_eq!(labels(&items), vec!["a b", "c d"]);
884 }
885
886 #[test]
887 fn test_extract_items_only_newlines_is_empty() {
888 let items = extract_items(None, "\n\n").unwrap();
889 assert!(items.is_empty());
890 }
891
892 #[test]
893 fn test_extract_items_structured_overrides_text() {
894 let data = Value::Json(serde_json::json!(["x", "y"]));
895 let items = extract_items(Some(&data), "ignored\ntext").unwrap();
896 assert_eq!(labels(&items), vec!["x", "y"]);
897 }
898
899 #[test]
900 fn test_item_label_truncates_on_char_boundary() {
901 let long: String = "é".repeat(100);
903 let it = ScatterItem::new(serde_json::Value::String(long));
904 assert!(it.label.ends_with("..."));
905 assert_eq!(it.label.chars().count(), 67);
906 }
907
908 #[test]
909 fn test_gather_results_jsonl_rows_carry_everything() {
910 let results = vec![
911 ScatterResult {
912 item: item("a"),
913 result: ExecResult::success("result_a\n"),
914 timed_out: false,
915 },
916 ScatterResult {
917 item: item("b"),
918 result: ExecResult::failure(7, "boom\n"),
919 timed_out: false,
920 },
921 ];
922 let out = gather_results(&results, &GatherOptions::default());
923 assert_eq!(out.code, 123, "any failure → 123 (A′)");
924 let rows: Vec<serde_json::Value> = out
925 .text_out()
926 .lines()
927 .map(|l| serde_json::from_str(l).unwrap())
928 .collect();
929 assert_eq!(rows.len(), 2, "every worker gets a row, failures included");
930 assert_eq!(rows[0]["i"], 0);
931 assert_eq!(rows[0]["item"], "a");
932 assert_eq!(rows[0]["ok"], true);
933 assert_eq!(rows[0]["out"], "result_a", "trailing newline stripped");
934 assert_eq!(rows[0]["err"], "", "err always present");
935 assert!(rows[0].get("timed_out").is_none(), "omit-false");
936 assert!(rows[0].get("data").is_none(), "omit-empty");
937 assert_eq!(rows[1]["i"], 1);
938 assert_eq!(rows[1]["ok"], false);
939 assert_eq!(rows[1]["code"], 7);
940 assert_eq!(rows[1]["err"], "boom");
941 assert!(matches!(out.data, Some(Value::Json(serde_json::Value::Array(_)))));
943 }
944
945 #[test]
946 fn test_gather_results_all_ok_is_zero() {
947 let results = vec![ScatterResult {
948 item: item("a"),
949 result: ExecResult::success("x"),
950 timed_out: false,
951 }];
952 let out = gather_results(&results, &GatherOptions::default());
953 assert_eq!(out.code, 0);
954 assert!(out.err.is_empty());
955 }
956
957 #[test]
958 fn test_gather_results_timeout_row_is_124() {
959 let results = vec![ScatterResult {
960 item: item("slow"),
961 result: ExecResult::failure(1, "cancelled"),
962 timed_out: true,
963 }];
964 let out = gather_results(&results, &GatherOptions::default());
965 assert_eq!(out.code, 123);
966 let row: serde_json::Value = serde_json::from_str(out.text_out().lines().next().unwrap()).unwrap();
967 assert_eq!(row["code"], 124, "timeout reports the timeout(1) code");
968 assert_eq!(row["ok"], false);
969 assert_eq!(row["timed_out"], true);
970 }
971
972 #[test]
973 fn test_gather_results_typed_record_item_in_row() {
974 let results = vec![ScatterResult {
975 item: ScatterItem::new(serde_json::json!({"id": 3, "host": "web1"})),
976 result: ExecResult::success("ok"),
977 timed_out: false,
978 }];
979 let out = gather_results(&results, &GatherOptions::default());
980 let row: serde_json::Value = serde_json::from_str(out.text_out().lines().next().unwrap()).unwrap();
981 assert_eq!(row["item"]["id"], 3, "row item is the TYPED value, not a string");
982 }
983
984 #[test]
985 fn test_gather_results_worker_data_rides_the_row() {
986 let mut r = ExecResult::success("text");
987 r.data = Some(Value::Json(serde_json::json!({"k": 1})));
988 let results = vec![ScatterResult { item: item("a"), result: r, timed_out: false }];
989 let out = gather_results(&results, &GatherOptions::default());
990 let row: serde_json::Value = serde_json::from_str(out.text_out().lines().next().unwrap()).unwrap();
991 assert_eq!(row["data"]["k"], 1, "worker .data lands typed in the row");
992 assert_eq!(row["out"], "text", "out stays alongside data");
993 }
994
995 #[test]
1005 fn test_gather_results_spilled_worker_counts_as_failed() {
1006 let mut spilled = ExecResult::success("truncated preview");
1007 spilled.did_spill = true;
1008 spilled.original_code = Some(0);
1009 spilled.code = 3; let results = vec![
1012 ScatterResult { item: item("a"), result: spilled, timed_out: false },
1013 ScatterResult { item: item("b"), result: ExecResult::success("clean"), timed_out: false },
1014 ];
1015 let out = gather_results(&results, &GatherOptions::default());
1016 assert_eq!(
1017 out.code, 123,
1018 "a spilled worker (code remapped to 3) must count as failed, not silently succeed"
1019 );
1020 let rows: Vec<serde_json::Value> = out
1021 .text_out()
1022 .lines()
1023 .map(|l| serde_json::from_str(l).unwrap())
1024 .collect();
1025 assert_eq!(rows[0]["ok"], false, "spilled row must read ok:false: {:?}", rows[0]);
1026 assert_eq!(rows[0]["code"], 3, "spilled row must carry the remapped exit 3: {:?}", rows[0]);
1027 assert_eq!(rows[1]["ok"], true, "the clean worker's own row is unaffected: {:?}", rows[1]);
1028 }
1029
1030 #[test]
1031 fn test_gather_results_lines_happy_path() {
1032 let results = vec![
1033 ScatterResult { item: item("a"), result: ExecResult::success("result_a\n"), timed_out: false },
1034 ScatterResult { item: item("b"), result: ExecResult::success("result_b"), timed_out: false },
1035 ];
1036 let out = gather_results(&results, &GatherOptions { lines: true, ..Default::default() });
1037 assert_eq!(out.code, 0);
1038 assert_eq!(&*out.text_out(), "result_a\nresult_b");
1039 }
1040
1041 #[test]
1042 fn test_gather_results_lines_hard_errors_on_any_failure() {
1043 let results = vec![
1045 ScatterResult { item: item("a"), result: ExecResult::success("good"), timed_out: false },
1046 ScatterResult { item: item("b"), result: ExecResult::failure(1, "boom"), timed_out: false },
1047 ];
1048 let out = gather_results(&results, &GatherOptions { lines: true, ..Default::default() });
1049 assert_eq!(out.code, 123);
1050 assert!(out.text_out().is_empty(), "no partial text on --lines failure");
1051 assert!(out.err.contains("b"), "names the failed item: {}", out.err);
1052 }
1053
1054 #[test]
1055 fn test_parse_scatter_options() {
1056 use crate::tools::ToolArgs;
1057
1058 let mut args = ToolArgs::new();
1059 args.named.insert("as".to_string(), Value::String("URL".to_string()));
1060 args.named.insert("limit".to_string(), Value::Int(4));
1061
1062 let opts = parse_scatter_options(&args).unwrap();
1063 assert_eq!(opts.var_name, "URL");
1064 assert_eq!(opts.limit, 4);
1065 }
1066
1067 #[test]
1068 fn test_parse_gather_options() {
1069 use crate::tools::ToolArgs;
1070
1071 let mut args = ToolArgs::new();
1072 args.flags.insert("lines".to_string());
1073
1074 let opts = parse_gather_options(&args).unwrap();
1075 assert!(opts.lines);
1076 assert!(!parse_gather_options(&ToolArgs::new()).unwrap().lines, "default is JSONL");
1077 }
1078
1079 #[test]
1080 fn scatter_limit_clamps_to_ceiling() {
1081 use crate::tools::ToolArgs;
1082
1083 let mut args = ToolArgs::new();
1084 args.named.insert("limit".to_string(), Value::Int(999_999));
1085 let opts = parse_scatter_options(&args).unwrap();
1086 assert_eq!(opts.limit, SCATTER_LIMIT_MAX);
1087 }
1088
1089 #[test]
1090 fn scatter_limit_raises_zero_to_one() {
1091 use crate::tools::ToolArgs;
1092
1093 let mut args = ToolArgs::new();
1094 args.named.insert("limit".to_string(), Value::Int(0));
1095 let opts = parse_scatter_options(&args).unwrap();
1096 assert_eq!(opts.limit, 1);
1097 }
1098
1099 #[test]
1100 fn scatter_limit_raises_negative_to_one() {
1101 use crate::tools::ToolArgs;
1102
1103 let mut args = ToolArgs::new();
1104 args.named.insert("limit".to_string(), Value::Int(-42));
1105 let opts = parse_scatter_options(&args).unwrap();
1106 assert_eq!(opts.limit, 1);
1107 }
1108
1109 #[test]
1110 fn scatter_limit_preserves_valid_values() {
1111 use crate::tools::ToolArgs;
1112
1113 let mut args = ToolArgs::new();
1114 args.named.insert("limit".to_string(), Value::Int(500));
1115 let opts = parse_scatter_options(&args).unwrap();
1116 assert_eq!(opts.limit, 500);
1117 }
1118
1119 #[test]
1122 fn scatter_limit_wrong_type_is_loud_error() {
1123 use crate::tools::ToolArgs;
1124
1125 let mut args = ToolArgs::new();
1126 args.named.insert("limit".to_string(), Value::String("five".to_string()));
1127 let err = parse_scatter_options(&args).unwrap_err();
1128 assert!(err.contains("--limit"), "{err}");
1129 assert!(err.contains("five"), "{err}");
1130 }
1131
1132 #[test]
1133 fn scatter_limit_bool_is_loud_error() {
1134 use crate::tools::ToolArgs;
1135
1136 let mut args = ToolArgs::new();
1137 args.named.insert("limit".to_string(), Value::Bool(true));
1138 let err = parse_scatter_options(&args).unwrap_err();
1139 assert!(err.contains("--limit"), "{err}");
1140 }
1141
1142 #[test]
1143 fn scatter_limit_numeric_string_coerces() {
1144 use crate::tools::ToolArgs;
1146
1147 let mut args = ToolArgs::new();
1148 args.named.insert("limit".to_string(), Value::String("5".to_string()));
1149 let opts = parse_scatter_options(&args).unwrap();
1150 assert_eq!(opts.limit, 5);
1151 }
1152
1153 #[test]
1154 fn scatter_as_wrong_type_is_loud_error() {
1155 use crate::tools::ToolArgs;
1156
1157 let mut args = ToolArgs::new();
1158 args.named.insert("as".to_string(), Value::Int(42));
1159 let err = parse_scatter_options(&args).unwrap_err();
1160 assert!(err.contains("--as"), "{err}");
1161 assert!(err.contains("42"), "{err}");
1162 }
1163
1164 #[test]
1165 fn scatter_timeout_negative_int_is_loud_error() {
1166 use crate::tools::ToolArgs;
1167
1168 let mut args = ToolArgs::new();
1169 args.named.insert("timeout".to_string(), Value::Int(-5));
1170 let err = parse_scatter_options(&args).unwrap_err();
1171 assert!(err.contains("--timeout"), "{err}");
1172 }
1173
1174 #[test]
1175 fn scatter_timeout_unparseable_string_is_loud_error() {
1176 use crate::tools::ToolArgs;
1177
1178 let mut args = ToolArgs::new();
1179 args.named.insert("timeout".to_string(), Value::String("banana".to_string()));
1180 let err = parse_scatter_options(&args).unwrap_err();
1181 assert!(err.contains("--timeout"), "{err}");
1182 assert!(err.contains("banana"), "{err}");
1183 }
1184
1185 #[test]
1186 fn scatter_timeout_valid_duration_string_parses() {
1187 use crate::tools::ToolArgs;
1188
1189 let mut args = ToolArgs::new();
1190 args.named.insert("timeout".to_string(), Value::String("5s".to_string()));
1191 let opts = parse_scatter_options(&args).unwrap();
1192 assert_eq!(opts.timeout, Some(Duration::from_secs(5)));
1193 }
1194
1195 #[test]
1196 fn scatter_timeout_nonnegative_int_is_seconds() {
1197 use crate::tools::ToolArgs;
1198
1199 let mut args = ToolArgs::new();
1200 args.named.insert("timeout".to_string(), Value::Int(30));
1201 let opts = parse_scatter_options(&args).unwrap();
1202 assert_eq!(opts.timeout, Some(Duration::from_secs(30)));
1203 }
1204
1205 fn binary_result(invalid_utf8: Vec<u8>) -> ExecResult {
1208 ExecResult::success_bytes(invalid_utf8)
1209 }
1210
1211 #[test]
1212 fn gather_row_goes_loud_not_lossy_on_binary_out() {
1213 let results = vec![ScatterResult {
1216 item: item("bin"),
1217 result: binary_result(vec![0xFF, 0xFE, 0x00, 0x01]),
1218 timed_out: false,
1219 }];
1220 let out = gather_results(&results, &GatherOptions::default());
1221 assert_eq!(out.code, 123, "a binary row flips the overall exit code too");
1222 let row: serde_json::Value =
1223 serde_json::from_str(out.text_out().lines().next().unwrap()).unwrap();
1224 assert_eq!(row["ok"], false, "binary output must not be silently ok:true");
1225 assert_ne!(row["code"], 0, "must carry a nonzero code");
1226 assert!(row["out"].as_str().unwrap().is_empty(), "no lossy text in out");
1227 let err_text = row["err"].as_str().unwrap();
1228 assert!(err_text.contains("binary"), "{err_text}");
1229 assert!(!err_text.contains('\u{FFFD}'), "must not carry U+FFFD: {err_text}");
1230 }
1231
1232 #[test]
1233 fn gather_lines_hard_errors_on_binary_out() {
1234 let results = vec![
1237 ScatterResult { item: item("a"), result: ExecResult::success("good"), timed_out: false },
1238 ScatterResult {
1239 item: item("bin"),
1240 result: binary_result(vec![0xFF, 0xFE]),
1241 timed_out: false,
1242 },
1243 ];
1244 let out = gather_results(&results, &GatherOptions { lines: true, ..Default::default() });
1245 assert_eq!(out.code, 123);
1246 assert!(out.text_out().is_empty(), "no partial/lossy text on binary --lines failure");
1247 assert!(!out.err.contains('\u{FFFD}'), "must not carry U+FFFD: {}", out.err);
1248 assert!(out.err.contains("binary") || out.err.contains("bin"), "{}", out.err);
1249 }
1250
1251 fn ctx_with_memory_fs() -> ExecContext {
1254 use crate::vfs::{MemoryFs, VfsRouter};
1255 use std::sync::Arc;
1256 let mut vfs = VfsRouter::new();
1257 vfs.mount("/", MemoryFs::new());
1258 ExecContext::new(Arc::new(vfs))
1259 }
1260
1261 #[test]
1262 fn worker_ctx_inherits_parent_watchdog() {
1263 use crate::watchdog::Watchdog;
1264 use std::sync::Arc;
1265
1266 let mut parent = ctx_with_memory_fs();
1267 parent.watchdog = Some(Arc::new(Watchdog::new(Duration::from_secs(30))));
1268
1269 let worker_ctx = parent.child_for_pipeline();
1273 assert!(
1274 worker_ctx.watchdog.is_some(),
1275 "worker must carry the parent's script watchdog, not None"
1276 );
1277
1278 let from_scratch =
1281 ExecContext::with_backend_and_scope(parent.backend.clone(), parent.scope.clone());
1282 assert!(
1283 from_scratch.watchdog.is_none(),
1284 "the abandoned from-scratch path is exactly why the worker lost its watchdog"
1285 );
1286 }
1287
1288 #[tokio::test]
1291 async fn worker_spills_over_the_shared_output_limit() {
1292 use crate::output_limit::{apply_spill_contract, OutputLimitConfig};
1293
1294 let mut cfg = OutputLimitConfig::agent().in_memory();
1296 cfg.set_limit(Some(64));
1297
1298 let mut parent = ctx_with_memory_fs();
1299 parent.output_limit = cfg;
1300
1301 let worker_ctx = parent.child_for_pipeline();
1306 assert!(worker_ctx.output_limit.is_enabled(), "budget must reach the worker");
1307
1308 let mut result = ExecResult::success("x".repeat(4096));
1313 assert!(worker_ctx.output_limit.is_enabled());
1314 apply_spill_contract(&mut result, &worker_ctx.output_limit).await;
1315
1316 assert!(result.did_spill, "worker output over the limit must spill, not stay resident");
1317 assert_eq!(
1318 result.code, 3,
1319 "a spilled worker must exit 3 so gather's ok()-based aggregation counts it as failed"
1320 );
1321 assert_eq!(result.original_code, Some(0), "the worker's own clean exit is preserved");
1322 assert!(
1323 result.text_out().len() < 4096,
1324 "spilled output must be truncated, not the full payload: {} bytes",
1325 result.text_out().len()
1326 );
1327 }
1328
1329 #[tokio::test(flavor = "current_thread", start_paused = true)]
1357 async fn worker_completing_at_timeout_boundary_is_not_misclassified() {
1358 use crate::ast::{Arg, Expr};
1359 use crate::dispatch::BackendDispatcher;
1360 use crate::tools::register_builtins;
1361 use crate::vfs::{MemoryFs, VfsRouter};
1362
1363 let mut registry = ToolRegistry::new();
1364 register_builtins(&mut registry);
1365 let tools = Arc::new(registry);
1366 let dispatcher: Arc<dyn CommandDispatcher> =
1367 Arc::new(BackendDispatcher::new(tools.clone()));
1368 let runner = ScatterGatherRunner::new(tools.clone(), dispatcher);
1369
1370 let commands = vec![Command {
1372 name: "sleep".to_string(),
1373 args: vec![Arg::Positional(Expr::Literal(Value::String("0.02".to_string())))],
1374 redirects: vec![],
1375 }];
1376 let opts = ScatterOptions {
1377 timeout: Some(Duration::from_millis(20)),
1378 ..ScatterOptions::default()
1379 };
1380
1381 let mut false_positives = 0;
1382 let mut genuine_timeouts = 0;
1383 let mut clean_success = 0;
1384 let iterations = 300;
1385 for _ in 0..iterations {
1386 let mut vfs = VfsRouter::new();
1391 vfs.mount("/", MemoryFs::new());
1392 let ctx = ExecContext::with_vfs_and_tools(Arc::new(vfs), tools.clone());
1393 let items = vec![item("x")];
1394 let results = runner.run_parallel(&items, &opts, &commands, &ctx).await;
1395 assert_eq!(results.len(), 1);
1396 let r = &results[0];
1397 match (r.timed_out, r.result.ok()) {
1398 (true, true) => false_positives += 1,
1399 (true, false) => genuine_timeouts += 1,
1400 (false, _) => clean_success += 1,
1401 }
1402 }
1403
1404 eprintln!(
1405 "worker_completing_at_timeout_boundary: {false_positives} false-positive(s), \
1406 {genuine_timeouts} genuine timeout(s), {clean_success} clean success(es) out of \
1407 {iterations} iterations"
1408 );
1409 assert!(
1415 genuine_timeouts > 0 && clean_success > 0,
1416 "the tie never formed (genuine_timeouts={genuine_timeouts}, \
1417 clean_success={clean_success}) — this test needs the race to actually occur to \
1418 mean anything; check the tied durations still create a real contest"
1419 );
1420 assert_eq!(
1421 false_positives, 0,
1422 "GH #132: a worker whose operation genuinely completed (result.ok()) must never \
1423 be reported timed_out — completion should win the tie"
1424 );
1425 }
1426}