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 result = runner.run_sequential(pre_scatter, ctx, &*self.sequential_dispatcher).await;
176 if !result.ok() {
177 return result;
178 }
179 (result.text_out().into_owned(), result.data)
180 };
181
182 let items = match extract_items(data.as_ref(), &text) {
184 Ok(items) => items,
185 Err(msg) => return ExecResult::failure(1, msg),
186 };
187 if items.is_empty() {
188 return ExecResult::success("");
189 }
190
191 tracing::Span::current().record("item_count", items.len());
192
193 let results = self
195 .run_parallel(&items, &scatter_opts, parallel, ctx)
196 .await;
197
198 let gathered = gather_results(&results, &gather_opts);
202
203 let gathered = apply_redirects(gathered, gather_redirects, ctx, &*self.sequential_dispatcher).await;
215
216 if post_gather.is_empty() || gathered.code != 0 {
219 gathered
220 } else {
221 ctx.set_stdin_with_data(
222 gathered.text_out().into_owned(),
223 gathered.data.clone(),
224 );
225 runner.run_sequential(post_gather, ctx, &*self.sequential_dispatcher).await
226 }
227 }
228
229 #[tracing::instrument(level = "debug", skip(self, items, opts, commands, base_ctx), fields(worker_count = items.len()))]
238 async fn run_parallel(
239 &self,
240 items: &[ScatterItem],
241 opts: &ScatterOptions,
242 commands: &[Command],
243 base_ctx: &ExecContext,
244 ) -> Vec<ScatterResult> {
245 let semaphore = Arc::new(Semaphore::new(opts.limit));
246 let tools = self.tools.clone();
247 let var_name = opts.var_name.clone();
248
249 let mut handles = Vec::with_capacity(items.len());
251
252 for item in items.iter().cloned() {
253 let permit = semaphore.clone().acquire_owned().await;
254 let tools = tools.clone();
255 let worker_dispatcher = self.sequential_dispatcher.fork_attached().await;
260 let commands = commands.to_vec();
261 let parent_token = base_ctx.cancel.clone();
262 let worker_token = parent_token.child_token();
263
264 let mut worker_ctx = base_ctx.child_for_pipeline();
279 worker_ctx.scope.set(
283 &var_name,
284 crate::interpreter::json_to_value_no_envelope(item.json.clone()),
285 );
286 worker_ctx.cancel = worker_token.clone();
289
290 let timed_out_flag = Arc::new(AtomicBool::new(false));
296 let timer_handle: Option<tokio::task::JoinHandle<()>> = opts.timeout.map(|d| {
297 let cancel = worker_token.clone();
298 let flag = timed_out_flag.clone();
299 tokio::spawn(async move {
300 tokio::time::sleep(d).await;
301 flag.store(true, Ordering::SeqCst);
302 cancel.cancel();
303 })
304 });
305 let timed_out_check = timed_out_flag.clone();
306
307 let worker_span = tracing::debug_span!("scatter_worker", item = %item.label);
308 let handle = tokio::spawn(crate::telemetry::bind_current_context(async move {
312 let _permit = permit; let mut worker_ctx = worker_ctx; let runner = PipelineRunner::new(tools);
318 let mut result =
319 runner.run_sequential(&commands, &mut worker_ctx, &*worker_dispatcher).await;
320
321 if worker_ctx.output_limit.is_enabled() {
330 let _ = crate::output_limit::spill_if_needed(
331 &mut result,
332 &worker_ctx.output_limit,
333 )
334 .await;
335 }
336
337 if let Some(h) = timer_handle {
340 h.abort();
341 }
342
343 let timed_out = timed_out_check.load(Ordering::SeqCst) && !result.ok();
358
359 ScatterResult { item, result, timed_out }
360 }.instrument(worker_span)));
361
362 handles.push(handle);
363 }
364
365 let mut results = Vec::with_capacity(handles.len());
367 for handle in handles {
368 match handle.await {
369 Ok(result) => results.push(result),
370 Err(e) => {
371 results.push(ScatterResult {
372 item: ScatterItem::new(serde_json::Value::String(
373 "<worker panicked>".to_string(),
374 )),
375 result: ExecResult::failure(1, format!("Task panicked: {}", e)),
376 timed_out: false,
377 });
378 }
379 }
380 }
381
382 results
383 }
384}
385
386pub fn extract_items(data: Option<&Value>, text: &str) -> Result<Vec<ScatterItem>, String> {
404 match data {
406 Some(Value::Json(serde_json::Value::Array(arr))) => {
408 let mut items = Vec::with_capacity(arr.len());
409 for (i, elem) in arr.iter().enumerate() {
410 if elem.is_null() {
411 return Err(format!(
412 "scatter: item {i} is null — refusing to bind a worker to null \
413 (filter it out first, e.g. jq 'map(select(. != null))')"
414 ));
415 }
416 items.push(ScatterItem::new(elem.clone()));
417 }
418 return Ok(items);
419 }
420 Some(Value::String(s)) => {
422 return Ok(vec![ScatterItem::new(serde_json::Value::String(s.clone()))])
423 }
424 Some(Value::Int(i)) => return Ok(vec![ScatterItem::new(serde_json::json!(i))]),
425 Some(Value::Float(f)) => return Ok(vec![ScatterItem::new(serde_json::json!(f))]),
426 Some(Value::Bool(b)) => return Ok(vec![ScatterItem::new(serde_json::json!(b))]),
427 Some(Value::Null) => {
428 return Err("scatter: input is null — nothing to fan out".to_string())
429 }
430 Some(Value::Json(serde_json::Value::Object(map))) => {
433 let hint = map
434 .iter()
435 .find(|(_, v)| v.is_array())
436 .map(|(k, _)| format!(" (did you mean jq '.{k}'?)"))
437 .unwrap_or_default();
438 return Err(format!(
439 "scatter: input is a single object, not an array — select the array to \
440 fan out over{hint}"
441 ));
442 }
443 Some(Value::Json(serde_json::Value::Null)) => {
444 return Err("scatter: input is null — nothing to fan out".to_string())
445 }
446 Some(Value::Json(json)) => return Ok(vec![ScatterItem::new(json.clone())]),
448 Some(Value::Bytes(b)) => {
451 return Err(format!(
452 "scatter: input is binary ({} bytes) — decode it to text or JSON first",
453 b.len()
454 ))
455 }
456 None => {}
458 }
459
460 let trimmed = text.trim_end_matches(['\n', '\r']);
463 if trimmed.is_empty() {
464 return Ok(vec![]);
465 }
466 Ok(trimmed
467 .split('\n')
468 .map(|line| line.trim_end_matches('\r'))
469 .filter(|line| !line.is_empty())
470 .map(ScatterItem::from_text_line)
471 .collect())
472}
473
474fn strip_one_trailing_newline(s: &str) -> &str {
477 let s = s.strip_suffix('\n').unwrap_or(s);
478 s.strip_suffix('\r').unwrap_or(s)
479}
480
481fn result_row(i: usize, r: &ScatterResult) -> serde_json::Value {
504 let mut ok = r.result.ok() && !r.timed_out;
505 let mut code = if r.timed_out { 124 } else { r.result.code };
506
507 let (out_text, err_text) = match r.result.try_text_out() {
508 Ok(text) => (
509 strip_one_trailing_newline(&text).to_string(),
510 strip_one_trailing_newline(&r.result.err).to_string(),
511 ),
512 Err(e) => {
513 ok = false;
514 if code == 0 {
515 code = 1;
516 }
517 (
518 String::new(),
519 format!(
520 "binary worker output not representable as text ({} bytes) — \
521 encode it in the worker (base64/xxd)",
522 e.len
523 ),
524 )
525 }
526 };
527
528 let mut row = serde_json::Map::new();
529 row.insert("i".into(), serde_json::json!(i));
530 row.insert("item".into(), r.item.json.clone());
531 row.insert("ok".into(), serde_json::json!(ok));
532 row.insert("code".into(), serde_json::json!(code));
533 row.insert("out".into(), serde_json::json!(out_text));
534 row.insert("err".into(), serde_json::json!(err_text));
535 if let Some(data) = &r.result.data {
536 row.insert("data".into(), kaish_types::value_to_json(data));
537 }
538 if let Some(latch) = &r.result.latch
543 && let Ok(v) = serde_json::to_value(latch)
544 {
545 row.insert("latch".into(), v);
546 }
547 if r.timed_out {
548 row.insert("timed_out".into(), serde_json::json!(true));
549 }
550 serde_json::Value::Object(row)
551}
552
553fn gather_results(results: &[ScatterResult], opts: &GatherOptions) -> ExecResult {
568 let is_unrepresentable = |r: &ScatterResult| r.result.try_text_out().is_err();
574
575 let failed: Vec<&ScatterResult> = results
576 .iter()
577 .filter(|r| !r.result.ok() || r.timed_out || is_unrepresentable(r))
578 .collect();
579 let code = if failed.is_empty() { 0 } else { 123 };
580 let err = if failed.is_empty() {
581 String::new()
582 } else {
583 let names = failed
584 .iter()
585 .map(|r| {
586 if is_unrepresentable(r) {
587 format!("{} (binary output not representable as text)", r.item.label)
588 } else {
589 r.item.label.clone()
590 }
591 })
592 .collect::<Vec<_>>()
593 .join(", ");
594 format!("gather: {} of {} worker(s) failed: {names}", failed.len(), results.len())
595 };
596
597 if opts.lines {
598 if !failed.is_empty() {
604 return ExecResult::failure(code, format!("{err} (drop --lines to get per-worker rows)"));
605 }
606 let text = results
607 .iter()
608 .map(|r| strip_one_trailing_newline(&r.result.text_out()).to_string())
609 .collect::<Vec<_>>()
610 .join("\n");
611 return ExecResult::success(text);
612 }
613
614 let rows: Vec<serde_json::Value> =
615 results.iter().enumerate().map(|(i, r)| result_row(i, r)).collect();
616 let text = if opts.json {
617 serde_json::to_string_pretty(&rows).unwrap_or_default()
619 } else {
620 rows.iter().map(|row| row.to_string()).collect::<Vec<_>>().join("\n")
622 };
623 let array = serde_json::Value::Array(rows);
624 ExecResult::from_parts(code, text, err, Some(Value::Json(array)))
625}
626
627fn describe_value(v: &Value) -> String {
631 match v {
632 Value::Null => "null".to_string(),
633 Value::Bool(b) => b.to_string(),
634 Value::Int(n) => n.to_string(),
635 Value::Float(f) => f.to_string(),
636 Value::String(s) => format!("{s:?}"),
637 Value::Json(j) => j.to_string(),
638 Value::Bytes(b) => format!("<{} bytes>", b.len()),
639 }
640}
641
642pub fn parse_scatter_options(args: &crate::tools::ToolArgs) -> Result<ScatterOptions, String> {
651 let mut opts = ScatterOptions::default();
652
653 match args.named.get("as") {
654 None => {}
655 Some(Value::String(name)) => opts.var_name = name.clone(),
656 Some(other) => {
657 return Err(format!(
658 "scatter --as: expected a variable name, got {}",
659 describe_value(other)
660 ))
661 }
662 }
663
664 match args.named.get("limit") {
665 None => {}
666 Some(Value::Int(n)) => opts.limit = clamp_scatter_limit(*n),
667 Some(Value::String(s)) => match s.trim().parse::<i64>() {
670 Ok(n) => opts.limit = clamp_scatter_limit(n),
671 Err(_) => {
672 return Err(format!(
673 "scatter --limit: expected a positive integer, got {}",
674 describe_value(&Value::String(s.clone()))
675 ))
676 }
677 },
678 Some(other) => {
679 return Err(format!(
680 "scatter --limit: expected a positive integer, got {}",
681 describe_value(other)
682 ))
683 }
684 }
685
686 match args.named.get("timeout") {
690 None => {}
691 Some(Value::String(s)) => match parse_duration(s) {
692 Some(d) => opts.timeout = Some(d),
693 None => {
694 return Err(format!(
695 "scatter --timeout: invalid duration {} (try: 30, 5s, 500ms, 2m, 1h)",
696 describe_value(&Value::String(s.clone()))
697 ))
698 }
699 },
700 Some(Value::Int(n)) if *n >= 0 => opts.timeout = Some(Duration::from_secs(*n as u64)),
701 Some(other) => {
702 return Err(format!(
703 "scatter --timeout: expected a non-negative duration, got {}",
704 describe_value(other)
705 ))
706 }
707 }
708
709 Ok(opts)
710}
711
712fn clamp_scatter_limit(requested: i64) -> usize {
716 let clamped = requested.clamp(1, SCATTER_LIMIT_MAX as i64);
717 if requested > SCATTER_LIMIT_MAX as i64 {
718 tracing::warn!(
719 target: "kaish::scatter",
720 requested = requested,
721 ceiling = SCATTER_LIMIT_MAX,
722 "scatter limit clamped to ceiling"
723 );
724 }
725 clamped as usize
726}
727
728pub const SCATTER_LIMIT_MAX: usize = 10_000;
732
733pub fn parse_gather_options(args: &crate::tools::ToolArgs) -> Result<GatherOptions, String> {
741 let mut opts = GatherOptions::default();
742
743 if args.has_flag("lines") {
744 opts.lines = true;
745 }
746
747 if args.has_flag("json") {
748 opts.json = true;
749 }
750
751 Ok(opts)
752}
753
754#[cfg(test)]
755mod tests {
756 use super::*;
757
758 fn labels(items: &[ScatterItem]) -> Vec<String> {
759 items.iter().map(|i| i.label.clone()).collect()
760 }
761
762 fn item(s: &str) -> ScatterItem {
763 ScatterItem::new(serde_json::Value::String(s.to_string()))
764 }
765
766 #[test]
767 fn test_extract_items_structured_json_array() {
768 let data = Value::Json(serde_json::json!(["a", "b", "c"]));
769 let items = extract_items(Some(&data), "").unwrap();
770 assert_eq!(labels(&items), vec!["a", "b", "c"]);
771 }
772
773 #[test]
774 fn test_extract_items_structured_mixed_types_stay_typed() {
775 let data = Value::Json(serde_json::json!([1, "1", true, {"id": 7}]));
778 let items = extract_items(Some(&data), "").unwrap();
779 assert_eq!(items[0].json, serde_json::json!(1));
780 assert_eq!(items[1].json, serde_json::json!("1"));
781 assert_ne!(items[0].json, items[1].json, "1 and \"1\" must not conflate");
782 assert_eq!(items[2].json, serde_json::json!(true));
783 assert_eq!(items[3].json, serde_json::json!({"id": 7}));
784 }
785
786 #[test]
787 fn test_extract_items_null_element_is_loud() {
788 let data = Value::Json(serde_json::json!(["a", null, "c"]));
789 let err = extract_items(Some(&data), "").unwrap_err();
790 assert!(err.contains("null"), "should name the problem: {err}");
791 assert!(err.contains("item 1"), "should name the position: {err}");
792 }
793
794 #[test]
795 fn test_extract_items_single_object_is_loud_with_hint() {
796 let data = Value::Json(serde_json::json!({"jobs": [1, 2]}));
797 let err = extract_items(Some(&data), "").unwrap_err();
798 assert!(err.contains("single object"), "{err}");
799 assert!(err.contains("jq '.jobs'"), "should hint the array key: {err}");
800 }
801
802 #[test]
803 fn test_extract_items_binary_is_loud() {
804 let data = Value::Bytes(vec![0, 1, 2]);
805 let err = extract_items(Some(&data), "").unwrap_err();
806 assert!(err.contains("binary"), "{err}");
807 }
808
809 #[test]
810 fn test_extract_items_structured_string() {
811 let data = Value::String("single".into());
812 let items = extract_items(Some(&data), "").unwrap();
813 assert_eq!(labels(&items), vec!["single"]);
814 }
815
816 #[test]
817 fn test_extract_items_single_line_text() {
818 let items = extract_items(None, "hello").unwrap();
819 assert_eq!(labels(&items), vec!["hello"]);
820 }
821
822 #[test]
823 fn test_extract_items_empty() {
824 let items = extract_items(None, "").unwrap();
825 assert!(items.is_empty());
826 }
827
828 #[test]
829 fn test_extract_items_multiline_fans_out_per_line() {
830 let items = extract_items(None, "one\ntwo\nthree").unwrap();
831 assert_eq!(labels(&items), vec!["one", "two", "three"]);
832 }
833
834 #[test]
835 fn test_extract_items_trailing_newline_no_phantom_item() {
836 let items = extract_items(None, "one\ntwo\n").unwrap();
837 assert_eq!(labels(&items), vec!["one", "two"]);
838 }
839
840 #[test]
841 fn test_extract_items_crlf_per_line() {
842 let items = extract_items(None, "one\r\ntwo\r\n").unwrap();
843 assert_eq!(labels(&items), vec!["one", "two"]);
844 }
845
846 #[test]
847 fn test_extract_items_blank_lines_skipped() {
848 let items = extract_items(None, "a\n\nb").unwrap();
851 assert_eq!(labels(&items), vec!["a", "b"]);
852 }
853
854 #[test]
855 fn test_extract_items_whitespace_within_line_not_split() {
856 let items = extract_items(None, "a b\nc d").unwrap();
857 assert_eq!(labels(&items), vec!["a b", "c d"]);
858 }
859
860 #[test]
861 fn test_extract_items_only_newlines_is_empty() {
862 let items = extract_items(None, "\n\n").unwrap();
863 assert!(items.is_empty());
864 }
865
866 #[test]
867 fn test_extract_items_structured_overrides_text() {
868 let data = Value::Json(serde_json::json!(["x", "y"]));
869 let items = extract_items(Some(&data), "ignored\ntext").unwrap();
870 assert_eq!(labels(&items), vec!["x", "y"]);
871 }
872
873 #[test]
874 fn test_item_label_truncates_on_char_boundary() {
875 let long: String = "é".repeat(100);
877 let it = ScatterItem::new(serde_json::Value::String(long));
878 assert!(it.label.ends_with("..."));
879 assert_eq!(it.label.chars().count(), 67);
880 }
881
882 #[test]
883 fn test_gather_results_jsonl_rows_carry_everything() {
884 let results = vec![
885 ScatterResult {
886 item: item("a"),
887 result: ExecResult::success("result_a\n"),
888 timed_out: false,
889 },
890 ScatterResult {
891 item: item("b"),
892 result: ExecResult::failure(7, "boom\n"),
893 timed_out: false,
894 },
895 ];
896 let out = gather_results(&results, &GatherOptions::default());
897 assert_eq!(out.code, 123, "any failure → 123 (A′)");
898 let rows: Vec<serde_json::Value> = out
899 .text_out()
900 .lines()
901 .map(|l| serde_json::from_str(l).unwrap())
902 .collect();
903 assert_eq!(rows.len(), 2, "every worker gets a row, failures included");
904 assert_eq!(rows[0]["i"], 0);
905 assert_eq!(rows[0]["item"], "a");
906 assert_eq!(rows[0]["ok"], true);
907 assert_eq!(rows[0]["out"], "result_a", "trailing newline stripped");
908 assert_eq!(rows[0]["err"], "", "err always present");
909 assert!(rows[0].get("timed_out").is_none(), "omit-false");
910 assert!(rows[0].get("data").is_none(), "omit-empty");
911 assert_eq!(rows[1]["i"], 1);
912 assert_eq!(rows[1]["ok"], false);
913 assert_eq!(rows[1]["code"], 7);
914 assert_eq!(rows[1]["err"], "boom");
915 assert!(matches!(out.data, Some(Value::Json(serde_json::Value::Array(_)))));
917 }
918
919 #[test]
920 fn test_gather_results_all_ok_is_zero() {
921 let results = vec![ScatterResult {
922 item: item("a"),
923 result: ExecResult::success("x"),
924 timed_out: false,
925 }];
926 let out = gather_results(&results, &GatherOptions::default());
927 assert_eq!(out.code, 0);
928 assert!(out.err.is_empty());
929 }
930
931 #[test]
932 fn test_gather_results_timeout_row_is_124() {
933 let results = vec![ScatterResult {
934 item: item("slow"),
935 result: ExecResult::failure(1, "cancelled"),
936 timed_out: true,
937 }];
938 let out = gather_results(&results, &GatherOptions::default());
939 assert_eq!(out.code, 123);
940 let row: serde_json::Value = serde_json::from_str(out.text_out().lines().next().unwrap()).unwrap();
941 assert_eq!(row["code"], 124, "timeout reports the timeout(1) code");
942 assert_eq!(row["ok"], false);
943 assert_eq!(row["timed_out"], true);
944 }
945
946 #[test]
947 fn test_gather_results_typed_record_item_in_row() {
948 let results = vec![ScatterResult {
949 item: ScatterItem::new(serde_json::json!({"id": 3, "host": "web1"})),
950 result: ExecResult::success("ok"),
951 timed_out: false,
952 }];
953 let out = gather_results(&results, &GatherOptions::default());
954 let row: serde_json::Value = serde_json::from_str(out.text_out().lines().next().unwrap()).unwrap();
955 assert_eq!(row["item"]["id"], 3, "row item is the TYPED value, not a string");
956 }
957
958 #[test]
959 fn test_gather_results_worker_data_rides_the_row() {
960 let mut r = ExecResult::success("text");
961 r.data = Some(Value::Json(serde_json::json!({"k": 1})));
962 let results = vec![ScatterResult { item: item("a"), result: r, timed_out: false }];
963 let out = gather_results(&results, &GatherOptions::default());
964 let row: serde_json::Value = serde_json::from_str(out.text_out().lines().next().unwrap()).unwrap();
965 assert_eq!(row["data"]["k"], 1, "worker .data lands typed in the row");
966 assert_eq!(row["out"], "text", "out stays alongside data");
967 }
968
969 #[test]
970 fn test_gather_results_worker_latch_rides_the_row() {
971 use kaish_types::result::LatchRequest;
975
976 let mut r = ExecResult::failure(2, "rm: confirmation required (latch enabled)");
977 r.latch = Some(Box::new(LatchRequest {
978 nonce: "a3f7b2c1".to_string(),
979 command: "rm".to_string(),
980 paths: vec!["precious.txt".to_string()],
981 hint: "rm --confirm=\"a3f7b2c1\" precious.txt".to_string(),
982 tool: "rm".to_string(),
983 argv: vec!["precious.txt".to_string()],
984 ttl: 60,
985 job_id: None,
986 }));
987 let results = vec![ScatterResult { item: item("a"), result: r, timed_out: false }];
988 let out = gather_results(&results, &GatherOptions::default());
989 assert_eq!(out.code, 123, "a latched worker still counts as failed for gather's exit code");
990 let row: serde_json::Value = serde_json::from_str(out.text_out().lines().next().unwrap()).unwrap();
991 assert_eq!(row["ok"], false);
992 assert_eq!(row["code"], 2);
993 assert_eq!(
994 row["latch"]["nonce"], "a3f7b2c1",
995 "the latch nonce must ride the row: {row}"
996 );
997 assert_eq!(row["latch"]["command"], "rm");
998 }
999
1000 #[test]
1001 fn test_gather_results_lines_happy_path() {
1002 let results = vec![
1003 ScatterResult { item: item("a"), result: ExecResult::success("result_a\n"), timed_out: false },
1004 ScatterResult { item: item("b"), result: ExecResult::success("result_b"), timed_out: false },
1005 ];
1006 let out = gather_results(&results, &GatherOptions { lines: true, ..Default::default() });
1007 assert_eq!(out.code, 0);
1008 assert_eq!(&*out.text_out(), "result_a\nresult_b");
1009 }
1010
1011 #[test]
1012 fn test_gather_results_lines_hard_errors_on_any_failure() {
1013 let results = vec![
1015 ScatterResult { item: item("a"), result: ExecResult::success("good"), timed_out: false },
1016 ScatterResult { item: item("b"), result: ExecResult::failure(1, "boom"), timed_out: false },
1017 ];
1018 let out = gather_results(&results, &GatherOptions { lines: true, ..Default::default() });
1019 assert_eq!(out.code, 123);
1020 assert!(out.text_out().is_empty(), "no partial text on --lines failure");
1021 assert!(out.err.contains("b"), "names the failed item: {}", out.err);
1022 }
1023
1024 #[test]
1025 fn test_parse_scatter_options() {
1026 use crate::tools::ToolArgs;
1027
1028 let mut args = ToolArgs::new();
1029 args.named.insert("as".to_string(), Value::String("URL".to_string()));
1030 args.named.insert("limit".to_string(), Value::Int(4));
1031
1032 let opts = parse_scatter_options(&args).unwrap();
1033 assert_eq!(opts.var_name, "URL");
1034 assert_eq!(opts.limit, 4);
1035 }
1036
1037 #[test]
1038 fn test_parse_gather_options() {
1039 use crate::tools::ToolArgs;
1040
1041 let mut args = ToolArgs::new();
1042 args.flags.insert("lines".to_string());
1043
1044 let opts = parse_gather_options(&args).unwrap();
1045 assert!(opts.lines);
1046 assert!(!parse_gather_options(&ToolArgs::new()).unwrap().lines, "default is JSONL");
1047 }
1048
1049 #[test]
1050 fn scatter_limit_clamps_to_ceiling() {
1051 use crate::tools::ToolArgs;
1052
1053 let mut args = ToolArgs::new();
1054 args.named.insert("limit".to_string(), Value::Int(999_999));
1055 let opts = parse_scatter_options(&args).unwrap();
1056 assert_eq!(opts.limit, SCATTER_LIMIT_MAX);
1057 }
1058
1059 #[test]
1060 fn scatter_limit_raises_zero_to_one() {
1061 use crate::tools::ToolArgs;
1062
1063 let mut args = ToolArgs::new();
1064 args.named.insert("limit".to_string(), Value::Int(0));
1065 let opts = parse_scatter_options(&args).unwrap();
1066 assert_eq!(opts.limit, 1);
1067 }
1068
1069 #[test]
1070 fn scatter_limit_raises_negative_to_one() {
1071 use crate::tools::ToolArgs;
1072
1073 let mut args = ToolArgs::new();
1074 args.named.insert("limit".to_string(), Value::Int(-42));
1075 let opts = parse_scatter_options(&args).unwrap();
1076 assert_eq!(opts.limit, 1);
1077 }
1078
1079 #[test]
1080 fn scatter_limit_preserves_valid_values() {
1081 use crate::tools::ToolArgs;
1082
1083 let mut args = ToolArgs::new();
1084 args.named.insert("limit".to_string(), Value::Int(500));
1085 let opts = parse_scatter_options(&args).unwrap();
1086 assert_eq!(opts.limit, 500);
1087 }
1088
1089 #[test]
1092 fn scatter_limit_wrong_type_is_loud_error() {
1093 use crate::tools::ToolArgs;
1094
1095 let mut args = ToolArgs::new();
1096 args.named.insert("limit".to_string(), Value::String("five".to_string()));
1097 let err = parse_scatter_options(&args).unwrap_err();
1098 assert!(err.contains("--limit"), "{err}");
1099 assert!(err.contains("five"), "{err}");
1100 }
1101
1102 #[test]
1103 fn scatter_limit_bool_is_loud_error() {
1104 use crate::tools::ToolArgs;
1105
1106 let mut args = ToolArgs::new();
1107 args.named.insert("limit".to_string(), Value::Bool(true));
1108 let err = parse_scatter_options(&args).unwrap_err();
1109 assert!(err.contains("--limit"), "{err}");
1110 }
1111
1112 #[test]
1113 fn scatter_limit_numeric_string_coerces() {
1114 use crate::tools::ToolArgs;
1116
1117 let mut args = ToolArgs::new();
1118 args.named.insert("limit".to_string(), Value::String("5".to_string()));
1119 let opts = parse_scatter_options(&args).unwrap();
1120 assert_eq!(opts.limit, 5);
1121 }
1122
1123 #[test]
1124 fn scatter_as_wrong_type_is_loud_error() {
1125 use crate::tools::ToolArgs;
1126
1127 let mut args = ToolArgs::new();
1128 args.named.insert("as".to_string(), Value::Int(42));
1129 let err = parse_scatter_options(&args).unwrap_err();
1130 assert!(err.contains("--as"), "{err}");
1131 assert!(err.contains("42"), "{err}");
1132 }
1133
1134 #[test]
1135 fn scatter_timeout_negative_int_is_loud_error() {
1136 use crate::tools::ToolArgs;
1137
1138 let mut args = ToolArgs::new();
1139 args.named.insert("timeout".to_string(), Value::Int(-5));
1140 let err = parse_scatter_options(&args).unwrap_err();
1141 assert!(err.contains("--timeout"), "{err}");
1142 }
1143
1144 #[test]
1145 fn scatter_timeout_unparseable_string_is_loud_error() {
1146 use crate::tools::ToolArgs;
1147
1148 let mut args = ToolArgs::new();
1149 args.named.insert("timeout".to_string(), Value::String("banana".to_string()));
1150 let err = parse_scatter_options(&args).unwrap_err();
1151 assert!(err.contains("--timeout"), "{err}");
1152 assert!(err.contains("banana"), "{err}");
1153 }
1154
1155 #[test]
1156 fn scatter_timeout_valid_duration_string_parses() {
1157 use crate::tools::ToolArgs;
1158
1159 let mut args = ToolArgs::new();
1160 args.named.insert("timeout".to_string(), Value::String("5s".to_string()));
1161 let opts = parse_scatter_options(&args).unwrap();
1162 assert_eq!(opts.timeout, Some(Duration::from_secs(5)));
1163 }
1164
1165 #[test]
1166 fn scatter_timeout_nonnegative_int_is_seconds() {
1167 use crate::tools::ToolArgs;
1168
1169 let mut args = ToolArgs::new();
1170 args.named.insert("timeout".to_string(), Value::Int(30));
1171 let opts = parse_scatter_options(&args).unwrap();
1172 assert_eq!(opts.timeout, Some(Duration::from_secs(30)));
1173 }
1174
1175 fn binary_result(invalid_utf8: Vec<u8>) -> ExecResult {
1178 ExecResult::success_bytes(invalid_utf8)
1179 }
1180
1181 #[test]
1182 fn gather_row_goes_loud_not_lossy_on_binary_out() {
1183 let results = vec![ScatterResult {
1186 item: item("bin"),
1187 result: binary_result(vec![0xFF, 0xFE, 0x00, 0x01]),
1188 timed_out: false,
1189 }];
1190 let out = gather_results(&results, &GatherOptions::default());
1191 assert_eq!(out.code, 123, "a binary row flips the overall exit code too");
1192 let row: serde_json::Value =
1193 serde_json::from_str(out.text_out().lines().next().unwrap()).unwrap();
1194 assert_eq!(row["ok"], false, "binary output must not be silently ok:true");
1195 assert_ne!(row["code"], 0, "must carry a nonzero code");
1196 assert!(row["out"].as_str().unwrap().is_empty(), "no lossy text in out");
1197 let err_text = row["err"].as_str().unwrap();
1198 assert!(err_text.contains("binary"), "{err_text}");
1199 assert!(!err_text.contains('\u{FFFD}'), "must not carry U+FFFD: {err_text}");
1200 }
1201
1202 #[test]
1203 fn gather_lines_hard_errors_on_binary_out() {
1204 let results = vec![
1207 ScatterResult { item: item("a"), result: ExecResult::success("good"), timed_out: false },
1208 ScatterResult {
1209 item: item("bin"),
1210 result: binary_result(vec![0xFF, 0xFE]),
1211 timed_out: false,
1212 },
1213 ];
1214 let out = gather_results(&results, &GatherOptions { lines: true, ..Default::default() });
1215 assert_eq!(out.code, 123);
1216 assert!(out.text_out().is_empty(), "no partial/lossy text on binary --lines failure");
1217 assert!(!out.err.contains('\u{FFFD}'), "must not carry U+FFFD: {}", out.err);
1218 assert!(out.err.contains("binary") || out.err.contains("bin"), "{}", out.err);
1219 }
1220
1221 fn ctx_with_memory_fs() -> ExecContext {
1224 use crate::vfs::{MemoryFs, VfsRouter};
1225 use std::sync::Arc;
1226 let mut vfs = VfsRouter::new();
1227 vfs.mount("/", MemoryFs::new());
1228 ExecContext::new(Arc::new(vfs))
1229 }
1230
1231 #[test]
1232 fn worker_ctx_inherits_parent_watchdog() {
1233 use crate::watchdog::Watchdog;
1234 use std::sync::Arc;
1235
1236 let mut parent = ctx_with_memory_fs();
1237 parent.watchdog = Some(Arc::new(Watchdog::new(Duration::from_secs(30))));
1238
1239 let worker_ctx = parent.child_for_pipeline();
1243 assert!(
1244 worker_ctx.watchdog.is_some(),
1245 "worker must carry the parent's script watchdog, not None"
1246 );
1247
1248 let from_scratch =
1251 ExecContext::with_backend_and_scope(parent.backend.clone(), parent.scope.clone());
1252 assert!(
1253 from_scratch.watchdog.is_none(),
1254 "the abandoned from-scratch path is exactly why the worker lost its watchdog"
1255 );
1256 }
1257
1258 #[tokio::test]
1261 async fn worker_spills_over_the_shared_output_limit() {
1262 use crate::output_limit::{spill_if_needed, OutputLimitConfig};
1263
1264 let mut cfg = OutputLimitConfig::agent().in_memory();
1266 cfg.set_limit(Some(64));
1267
1268 let mut parent = ctx_with_memory_fs();
1269 parent.output_limit = cfg;
1270
1271 let worker_ctx = parent.child_for_pipeline();
1276 assert!(worker_ctx.output_limit.is_enabled(), "budget must reach the worker");
1277
1278 let mut result = ExecResult::success("x".repeat(4096));
1280 assert!(worker_ctx.output_limit.is_enabled());
1281 let _ = spill_if_needed(&mut result, &worker_ctx.output_limit).await;
1282
1283 assert!(result.did_spill, "worker output over the limit must spill, not stay resident");
1284 assert!(
1285 result.text_out().len() < 4096,
1286 "spilled output must be truncated, not the full payload: {} bytes",
1287 result.text_out().len()
1288 );
1289 }
1290
1291 #[tokio::test(flavor = "current_thread", start_paused = true)]
1319 async fn worker_completing_at_timeout_boundary_is_not_misclassified() {
1320 use crate::ast::{Arg, Expr};
1321 use crate::dispatch::BackendDispatcher;
1322 use crate::tools::register_builtins;
1323 use crate::vfs::{MemoryFs, VfsRouter};
1324
1325 let mut registry = ToolRegistry::new();
1326 register_builtins(&mut registry);
1327 let tools = Arc::new(registry);
1328 let dispatcher: Arc<dyn CommandDispatcher> =
1329 Arc::new(BackendDispatcher::new(tools.clone()));
1330 let runner = ScatterGatherRunner::new(tools.clone(), dispatcher);
1331
1332 let commands = vec![Command {
1334 name: "sleep".to_string(),
1335 args: vec![Arg::Positional(Expr::Literal(Value::String("0.02".to_string())))],
1336 redirects: vec![],
1337 }];
1338 let opts = ScatterOptions {
1339 timeout: Some(Duration::from_millis(20)),
1340 ..ScatterOptions::default()
1341 };
1342
1343 let mut false_positives = 0;
1344 let mut genuine_timeouts = 0;
1345 let mut clean_success = 0;
1346 let iterations = 300;
1347 for _ in 0..iterations {
1348 let mut vfs = VfsRouter::new();
1353 vfs.mount("/", MemoryFs::new());
1354 let ctx = ExecContext::with_vfs_and_tools(Arc::new(vfs), tools.clone());
1355 let items = vec![item("x")];
1356 let results = runner.run_parallel(&items, &opts, &commands, &ctx).await;
1357 assert_eq!(results.len(), 1);
1358 let r = &results[0];
1359 match (r.timed_out, r.result.ok()) {
1360 (true, true) => false_positives += 1,
1361 (true, false) => genuine_timeouts += 1,
1362 (false, _) => clean_success += 1,
1363 }
1364 }
1365
1366 eprintln!(
1367 "worker_completing_at_timeout_boundary: {false_positives} false-positive(s), \
1368 {genuine_timeouts} genuine timeout(s), {clean_success} clean success(es) out of \
1369 {iterations} iterations"
1370 );
1371 assert!(
1377 genuine_timeouts > 0 && clean_success > 0,
1378 "the tie never formed (genuine_timeouts={genuine_timeouts}, \
1379 clean_success={clean_success}) — this test needs the race to actually occur to \
1380 mean anything; check the tied durations still create a real contest"
1381 );
1382 assert_eq!(
1383 false_positives, 0,
1384 "GH #132: a worker whose operation genuinely completed (result.ok()) must never \
1385 be reported timed_out — completion should win the tie"
1386 );
1387 }
1388}