1use std::collections::{HashMap, HashSet, VecDeque};
11use std::future::Future;
12use std::path::Path;
13use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
14
15use crate::outcome::{legacy_outcome, EffectStatus, ExecutionOutcome, WorkflowError};
16use futures_util::stream::{FuturesUnordered, StreamExt};
17use serde::{Deserialize, Serialize};
18use serde_json::Value;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
22#[serde(rename_all = "lowercase")]
23pub enum BatchMode {
24 #[default]
27 Sequential,
28 Parallel,
31}
32
33#[derive(Debug, Clone, Deserialize)]
35#[serde(rename_all = "camelCase", deny_unknown_fields)]
36pub struct ActionSpec {
37 #[serde(default)]
39 pub id: Option<String>,
40 pub tool: String,
42 #[serde(default)]
44 pub args: Value,
45 #[serde(default, alias = "depends_on")]
47 pub depends_on: Vec<String>,
48 #[serde(default, alias = "timeout_ms")]
50 pub timeout_ms: Option<u64>,
51 #[serde(default, alias = "omit_result")]
53 pub omit_result: bool,
54}
55
56#[derive(Debug, Clone, Deserialize)]
58#[serde(rename_all = "camelCase", deny_unknown_fields)]
59pub struct BatchSpec {
60 #[serde(default)]
61 pub mode: BatchMode,
62 #[serde(default = "default_true", alias = "stop_on_error")]
65 pub stop_on_error: bool,
66 #[serde(default, alias = "max_parallel")]
68 pub max_parallel: Option<usize>,
69 #[serde(default, alias = "timeout_ms")]
71 pub timeout_ms: Option<u64>,
72 #[serde(default)]
74 pub save: Option<String>,
75 pub actions: Vec<ActionSpec>,
76}
77
78fn default_true() -> bool {
79 true
80}
81
82impl BatchSpec {
83 pub fn parse(value: &Value) -> Result<Self, String> {
86 let object = match value {
87 Value::Array(actions) => {
88 serde_json::json!({ "actions": actions })
89 }
90 Value::Object(_) => value.clone(),
91 _ => return Err("batch spec must be a JSON object or array".to_string()),
92 };
93 let spec: BatchSpec =
94 serde_json::from_value(object).map_err(|e| format!("Invalid batch spec: {e}"))?;
95 spec.validate()?;
96 Ok(spec)
97 }
98
99 fn validate(&self) -> Result<(), String> {
100 if self.actions.is_empty() {
101 return Err("batch spec has no actions".to_string());
102 }
103 let mut ids: HashSet<&str> = HashSet::new();
104 for action in &self.actions {
105 if action.tool.trim().is_empty() {
106 return Err("every action needs a non-empty 'tool'".to_string());
107 }
108 if !matches!(action.args, Value::Object(_) | Value::Null) {
109 return Err(format!(
110 "action '{}': 'args' must be a JSON object",
111 action.id.as_deref().unwrap_or(&action.tool)
112 ));
113 }
114 if let Some(id) = &action.id {
115 if !ids.insert(id.as_str()) {
116 return Err(format!("duplicate action id '{id}'"));
117 }
118 }
119 }
120 for action in &self.actions {
121 for dep in &action.depends_on {
122 if !ids.contains(dep.as_str()) {
123 return Err(format!(
124 "action '{}' depends on unknown id '{dep}'",
125 action.id.as_deref().unwrap_or(&action.tool)
126 ));
127 }
128 if action.id.as_deref() == Some(dep.as_str()) {
129 return Err(format!("action '{dep}' depends on itself"));
130 }
131 }
132 }
133
134 let (deps, _) = self.dependency_edges();
139 let n = self.actions.len();
140 let mut remaining: Vec<usize> = deps.iter().map(Vec::len).collect();
141 let mut queue: VecDeque<usize> = (0..n).filter(|&i| remaining[i] == 0).collect();
142 let mut resolved = 0usize;
143 let mut dependents: Vec<Vec<usize>> = vec![Vec::new(); n];
144 for (i, dep_list) in deps.iter().enumerate() {
145 for &d in dep_list {
146 dependents[d].push(i);
147 }
148 }
149 while let Some(i) = queue.pop_front() {
150 resolved += 1;
151 for &d in &dependents[i] {
152 remaining[d] -= 1;
153 if remaining[d] == 0 {
154 queue.push_back(d);
155 }
156 }
157 }
158 if resolved < n {
159 let stuck: Vec<&str> = (0..n)
160 .filter(|&i| remaining[i] > 0)
161 .filter_map(|i| self.actions[i].id.as_deref())
162 .collect();
163 return Err(format!("dependency cycle among actions: {stuck:?}"));
164 }
165 Ok(())
166 }
167
168 fn dependency_edges(&self) -> (Vec<Vec<usize>>, Vec<Vec<usize>>) {
173 let n = self.actions.len();
174 let id_to_index: HashMap<&str, usize> = self
175 .actions
176 .iter()
177 .enumerate()
178 .filter_map(|(i, a)| a.id.as_deref().map(|id| (id, i)))
179 .collect();
180 let mut deps: Vec<Vec<usize>> = vec![Vec::new(); n];
181 let mut explicit: Vec<Vec<usize>> = vec![Vec::new(); n];
182 for (i, action) in self.actions.iter().enumerate() {
183 for dep in &action.depends_on {
184 let Some(&di) = id_to_index.get(dep.as_str()) else {
187 continue;
188 };
189 if !deps[i].contains(&di) {
190 deps[i].push(di);
191 }
192 if !explicit[i].contains(&di) {
193 explicit[i].push(di);
194 }
195 }
196 if self.mode == BatchMode::Sequential && i > 0 && !deps[i].contains(&(i - 1)) {
197 deps[i].push(i - 1);
198 }
199 }
200 (deps, explicit)
201 }
202}
203
204fn adapt_legacy(tool: &str, result: Result<Value, String>) -> ExecutionOutcome {
207 match result {
208 Err(message) => ExecutionOutcome::unknown(WorkflowError::new(
209 "outcome_unknown",
210 "dispatching",
211 message,
212 )),
213 Ok(value) => {
214 if matches!(
215 tool,
216 "webview_interact"
217 | "webview_keyboard"
218 | "webview_wait_for"
219 | "webview_act_and_verify"
220 | "webview_locator"
221 | "webview_select_option"
222 | "webview_scroll"
223 ) {
224 if let Some(message) = value.get("error").and_then(Value::as_str) {
225 let mut outcome = ExecutionOutcome::failed(
226 WorkflowError::new(
227 "execution_failed",
228 "executing",
229 format!("{message} — {value}"),
230 ),
231 EffectStatus::Possible,
232 );
233 outcome.data = value;
234 return outcome;
235 }
236 }
237 legacy_outcome(tool, value)
238 }
239 }
240}
241
242pub fn prepare_tool_args(tool: &str, mut args: Value) -> Value {
245 if tool == "webview_screenshot" {
246 if args.is_null() {
247 args = serde_json::json!({});
248 }
249 if let Some(object) = args.as_object_mut() {
250 object.entry("save").or_insert(Value::Bool(true));
251 }
252 }
253 args
254}
255
256pub fn compact_tool_outcome(tool: &str, outcome: &mut ExecutionOutcome) {
259 if tool != "webview_screenshot" {
260 return;
261 }
262 let Some(artifact) = outcome.data.get("artifact") else {
263 return;
264 };
265 let Some(id) = artifact
266 .get("artifactId")
267 .or_else(|| artifact.get("id"))
268 .and_then(Value::as_str)
269 .filter(|id| !id.trim().is_empty())
270 else {
271 return;
272 };
273 if !artifact
274 .get("path")
275 .and_then(Value::as_str)
276 .is_some_and(|path| !path.trim().is_empty())
277 {
278 return;
279 }
280 let id = id.to_string();
281 if let Some(data) = outcome.data.as_object_mut() {
282 data.remove("base64");
283 }
284 if !outcome.evidence_refs.contains(&id) {
285 outcome.evidence_refs.push(id);
286 }
287}
288
289#[derive(Debug, Clone, Serialize)]
291#[serde(rename_all = "camelCase")]
292pub struct ActionLog {
293 pub index: usize,
294 pub id: String,
295 pub tool: String,
296 pub status: String,
298 #[serde(skip_serializing_if = "Option::is_none")]
300 pub started_at_ms: Option<u64>,
301 #[serde(skip_serializing_if = "Option::is_none")]
302 pub duration_ms: Option<u64>,
303 #[serde(skip_serializing_if = "Option::is_none")]
304 pub result: Option<Value>,
305 #[serde(skip_serializing_if = "Option::is_none")]
306 pub error: Option<String>,
307 #[serde(skip_serializing_if = "Option::is_none")]
308 pub outcome: Option<ExecutionOutcome>,
309}
310
311#[derive(Debug, Clone, Serialize)]
313#[serde(rename_all = "camelCase")]
314pub struct BatchReport {
315 pub ok: bool,
316 pub mode: BatchMode,
317 pub total: usize,
318 pub succeeded: usize,
319 pub failed: usize,
320 pub skipped: usize,
321 pub started_at: u64,
323 pub duration_ms: u64,
324 #[serde(skip_serializing_if = "Option::is_none")]
325 pub saved_to: Option<String>,
326 #[serde(skip_serializing_if = "Option::is_none")]
327 pub persistence_warning: Option<String>,
328 pub logs: Vec<ActionLog>,
329}
330
331#[derive(Debug, Clone, Copy, PartialEq)]
332enum ActionState {
333 Pending,
334 Running,
335 Ok,
336 Failed,
337 Skipped,
338}
339
340pub async fn run_from_value<F, Fut>(spec_value: &Value, dispatch: F) -> Result<BatchReport, String>
344where
345 F: Fn(String, Value) -> Fut,
346 Fut: Future<Output = Result<Value, String>>,
347{
348 let spec = BatchSpec::parse(spec_value)?;
349 let report = run_batch(&spec, dispatch).await;
350 Ok(persist_report(&spec, report))
351}
352
353pub async fn run_from_value_outcomes<F, Fut>(
355 spec_value: &Value,
356 dispatch: F,
357) -> Result<BatchReport, String>
358where
359 F: Fn(String, Value) -> Fut,
360 Fut: Future<Output = ExecutionOutcome>,
361{
362 let spec = BatchSpec::parse(spec_value)?;
363 let report = run_batch_outcomes(&spec, dispatch).await;
364 Ok(persist_report(&spec, report))
365}
366
367fn persist_report(spec: &BatchSpec, mut report: BatchReport) -> BatchReport {
368 if let Some(path) = &spec.save {
369 report.saved_to = Some(path.clone());
371 if let Err(error) = save_report(path, &report) {
372 report.saved_to = None;
373 report.persistence_warning = Some(error);
374 }
375 }
376 report
377}
378
379pub async fn run_batch<F, Fut>(spec: &BatchSpec, dispatch: F) -> BatchReport
381where
382 F: Fn(String, Value) -> Fut,
383 Fut: Future<Output = Result<Value, String>>,
384{
385 run_batch_outcomes(spec, |tool, args| {
386 let future = dispatch(tool.clone(), args);
387 async move { adapt_legacy(&tool, future.await) }
388 })
389 .await
390}
391
392pub async fn run_batch_outcomes<F, Fut>(spec: &BatchSpec, dispatch: F) -> BatchReport
395where
396 F: Fn(String, Value) -> Fut,
397 Fut: Future<Output = ExecutionOutcome>,
398{
399 let n = spec.actions.len();
400 let started_at = unix_ms();
401 let start = Instant::now();
402
403 let (deps, explicit_deps) = spec.dependency_edges();
408 let mut dependents: Vec<Vec<usize>> = vec![Vec::new(); n];
409 let mut remaining: Vec<usize> = vec![0; n];
410 for (i, dep_list) in deps.iter().enumerate() {
411 remaining[i] = dep_list.len();
412 for &d in dep_list {
413 dependents[d].push(i);
414 }
415 }
416
417 let mut state: Vec<ActionState> = vec![ActionState::Pending; n];
418 let mut logs: Vec<Option<ActionLog>> = (0..n).map(|_| None).collect();
419 let mut ready: VecDeque<usize> = (0..n).filter(|&i| remaining[i] == 0).collect();
420 let mut abort = false;
421
422 let max_parallel = spec.max_parallel.unwrap_or(usize::MAX).max(1);
423 let mut running = FuturesUnordered::new();
424
425 let explicit_ids: HashSet<&str> = spec
428 .actions
429 .iter()
430 .filter_map(|a| a.id.as_deref())
431 .collect();
432 let log_ids: Vec<String> = spec
433 .actions
434 .iter()
435 .enumerate()
436 .map(|(i, a)| match &a.id {
437 Some(id) => id.clone(),
438 None => {
439 let mut candidate = format!("a{i}");
440 while explicit_ids.contains(candidate.as_str()) {
441 candidate.insert(0, '_');
442 }
443 candidate
444 }
445 })
446 .collect();
447
448 #[allow(clippy::too_many_arguments)]
450 fn skip_action(
451 i: usize,
452 reason: String,
453 spec: &BatchSpec,
454 log_ids: &[String],
455 state: &mut [ActionState],
456 logs: &mut [Option<ActionLog>],
457 remaining: &mut [usize],
458 dependents: &[Vec<usize>],
459 newly_unblocked: &mut VecDeque<usize>,
460 ) {
461 state[i] = ActionState::Skipped;
462 logs[i] = Some(ActionLog {
463 index: i,
464 id: log_ids[i].clone(),
465 tool: spec.actions[i].tool.clone(),
466 status: "skipped".to_string(),
467 started_at_ms: None,
468 duration_ms: None,
469 result: None,
470 error: Some(reason),
471 outcome: None,
472 });
473 for &d in &dependents[i] {
474 remaining[d] -= 1;
475 if remaining[d] == 0 {
476 newly_unblocked.push_back(d);
477 }
478 }
479 }
480
481 loop {
482 while let Some(i) = ready.pop_front() {
484 if state[i] != ActionState::Pending {
485 continue;
486 }
487 let failed_dep = explicit_deps[i]
488 .iter()
489 .find(|&&d| matches!(state[d], ActionState::Failed | ActionState::Skipped));
490 if let Some(&d) = failed_dep {
491 let mut unblocked = VecDeque::new();
492 let reason = match state[d] {
493 ActionState::Failed => format!("dependency '{}' failed", log_ids[d]),
494 _ => format!("dependency '{}' was skipped", log_ids[d]),
495 };
496 skip_action(
497 i,
498 reason,
499 spec,
500 &log_ids,
501 &mut state,
502 &mut logs,
503 &mut remaining,
504 &dependents,
505 &mut unblocked,
506 );
507 ready.append(&mut unblocked);
508 continue;
509 }
510 if abort {
511 let mut unblocked = VecDeque::new();
512 skip_action(
513 i,
514 "batch aborted after earlier failure (stopOnError)".to_string(),
515 spec,
516 &log_ids,
517 &mut state,
518 &mut logs,
519 &mut remaining,
520 &dependents,
521 &mut unblocked,
522 );
523 ready.append(&mut unblocked);
524 continue;
525 }
526 if running.len() >= max_parallel {
527 ready.push_front(i);
528 break;
529 }
530
531 state[i] = ActionState::Running;
532 let tool = spec.actions[i].tool.clone();
533 let args = match &spec.actions[i].args {
534 Value::Null => Value::Object(serde_json::Map::new()),
535 other => other.clone(),
536 };
537 let timeout_ms = spec.actions[i].timeout_ms.or(spec.timeout_ms);
538 let args = prepare_tool_args(&tool, args);
539 let fut = dispatch(tool, args);
540 running.push(async move {
541 let started_at_ms = start.elapsed().as_millis() as u64;
544 let action_start = Instant::now();
545 let result = match timeout_ms {
546 Some(ms) => match tokio::time::timeout(Duration::from_millis(ms), fut).await {
547 Ok(r) => r,
548 Err(_) => ExecutionOutcome::unknown(WorkflowError::new(
549 "outcome_unknown",
550 "dispatching",
551 format!("action timed out after {ms}ms; remote execution may continue"),
552 )),
553 },
554 None => fut.await,
555 };
556 (
557 i,
558 started_at_ms,
559 action_start.elapsed().as_millis() as u64,
560 result,
561 )
562 });
563 }
564
565 if running.is_empty() {
566 break;
567 }
568
569 let Some((i, started_at_ms, duration_ms, result)) = running.next().await else {
571 break;
572 };
573 let mut outcome = result;
574 compact_tool_outcome(&spec.actions[i].tool, &mut outcome);
575 let (status, ok_result, error) = if outcome.is_success(false) {
576 state[i] = ActionState::Ok;
577 let kept = if spec.actions[i].omit_result {
578 None
579 } else {
580 Some(outcome.data.clone())
581 };
582 ("ok", kept, None)
583 } else {
584 state[i] = ActionState::Failed;
585 if spec.stop_on_error {
586 abort = true;
587 }
588 let message = outcome
589 .error
590 .as_ref()
591 .map(ToString::to_string)
592 .unwrap_or_else(|| "Action execution or verification did not succeed".to_string());
593 ("error", None, Some(message))
594 };
595 let mut visible_outcome = outcome;
596 if spec.actions[i].omit_result {
597 visible_outcome.data = Value::Null;
598 }
599 logs[i] = Some(ActionLog {
600 index: i,
601 id: log_ids[i].clone(),
602 tool: spec.actions[i].tool.clone(),
603 status: status.to_string(),
604 started_at_ms: Some(started_at_ms),
605 duration_ms: Some(duration_ms),
606 result: ok_result,
607 error,
608 outcome: Some(visible_outcome),
609 });
610 for &d in &dependents[i] {
611 remaining[d] -= 1;
612 if remaining[d] == 0 {
613 ready.push_back(d);
614 }
615 }
616 }
617
618 for i in 0..n {
620 if state[i] == ActionState::Pending {
621 logs[i] = Some(ActionLog {
622 index: i,
623 id: log_ids[i].clone(),
624 tool: spec.actions[i].tool.clone(),
625 status: "skipped".to_string(),
626 started_at_ms: None,
627 duration_ms: None,
628 result: None,
629 error: Some("never became ready (dependency chain did not complete)".to_string()),
630 outcome: None,
631 });
632 state[i] = ActionState::Skipped;
633 }
634 }
635
636 let logs: Vec<ActionLog> = logs.into_iter().flatten().collect();
637 let succeeded = logs.iter().filter(|l| l.status == "ok").count();
638 let failed = logs.iter().filter(|l| l.status == "error").count();
639 let skipped = logs.iter().filter(|l| l.status == "skipped").count();
640
641 BatchReport {
642 ok: failed == 0 && skipped == 0,
643 mode: spec.mode,
644 total: n,
645 succeeded,
646 failed,
647 skipped,
648 started_at,
649 duration_ms: start.elapsed().as_millis() as u64,
650 saved_to: None,
651 persistence_warning: None,
652 logs,
653 }
654}
655
656pub fn save_report(path: &str, report: &BatchReport) -> Result<(), String> {
658 let json = serde_json::to_string_pretty(report)
659 .map_err(|e| format!("Failed to serialize batch report: {e}"))?;
660 if let Some(parent) = Path::new(path).parent() {
661 if !parent.as_os_str().is_empty() {
662 std::fs::create_dir_all(parent)
663 .map_err(|e| format!("Failed to create {}: {e}", parent.display()))?;
664 }
665 }
666 std::fs::write(path, json).map_err(|e| format!("Failed to write {path}: {e}"))
667}
668
669fn unix_ms() -> u64 {
670 SystemTime::now()
671 .duration_since(UNIX_EPOCH)
672 .map(|d| d.as_millis() as u64)
673 .unwrap_or(0)
674}
675
676#[cfg(test)]
677mod tests {
678 use super::*;
679 use serde_json::json;
680 use std::sync::Arc;
681 use tokio::sync::Mutex;
682
683 fn spec(value: Value) -> BatchSpec {
684 BatchSpec::parse(&value).expect("valid spec")
685 }
686
687 fn recording_dispatch(
689 calls: Arc<Mutex<Vec<String>>>,
690 ) -> impl Fn(String, Value) -> std::pin::Pin<Box<dyn Future<Output = Result<Value, String>> + Send>>
691 {
692 move |tool, args| {
693 let calls = calls.clone();
694 Box::pin(async move {
695 calls.lock().await.push(tool.clone());
696 tokio::time::sleep(Duration::from_millis(5)).await;
697 if tool == "fail" {
698 Err("boom".to_string())
699 } else {
700 Ok(json!({ "tool": tool, "args": args }))
701 }
702 })
703 }
704 }
705
706 #[tokio::test]
707 async fn sequential_runs_in_order() {
708 let calls = Arc::new(Mutex::new(Vec::new()));
709 let s = spec(json!({
710 "mode": "sequential",
711 "actions": [
712 { "tool": "one" },
713 { "tool": "two" },
714 { "tool": "three" }
715 ]
716 }));
717 let report = run_batch(&s, recording_dispatch(calls.clone())).await;
718 assert!(report.ok);
719 assert_eq!(report.succeeded, 3);
720 assert_eq!(*calls.lock().await, vec!["one", "two", "three"]);
721 let starts: Vec<u64> = report
723 .logs
724 .iter()
725 .map(|l| l.started_at_ms.unwrap())
726 .collect();
727 assert!(starts.windows(2).all(|w| w[0] <= w[1]));
728 }
729
730 #[tokio::test]
731 async fn parallel_actions_overlap() {
732 let first = Arc::new(tokio::sync::Notify::new());
735 let second = Arc::new(tokio::sync::Notify::new());
736 let s = spec(json!({
737 "mode": "parallel",
738 "actions": [ { "tool": "a" }, { "tool": "b" } ]
739 }));
740 let dispatch = move |tool: String, _args: Value| {
741 let first = first.clone();
742 let second = second.clone();
743 async move {
744 if tool == "a" {
745 first.notify_one();
746 second.notified().await;
747 } else {
748 second.notify_one();
749 first.notified().await;
750 }
751 Ok(json!(tool))
752 }
753 };
754 let report = tokio::time::timeout(Duration::from_secs(5), run_batch(&s, dispatch))
755 .await
756 .expect("parallel actions deadlocked — they did not overlap");
757 assert!(report.ok);
758 assert_eq!(report.succeeded, 2);
759 }
760
761 #[tokio::test]
762 async fn depends_on_orders_dag() {
763 let calls = Arc::new(Mutex::new(Vec::new()));
764 let s = spec(json!({
765 "mode": "parallel",
766 "actions": [
767 { "id": "setup", "tool": "one" },
768 { "id": "left", "tool": "two", "dependsOn": ["setup"] },
769 { "id": "right", "tool": "three", "dependsOn": ["setup"] },
770 { "id": "final", "tool": "four", "dependsOn": ["left", "right"] }
771 ]
772 }));
773 let report = run_batch(&s, recording_dispatch(calls.clone())).await;
774 assert!(report.ok, "report: {report:?}");
775 let calls = calls.lock().await;
776 assert_eq!(calls[0], "one");
777 assert_eq!(calls[3], "four");
778 }
779
780 #[tokio::test]
781 async fn stop_on_error_skips_rest_sequentially() {
782 let calls = Arc::new(Mutex::new(Vec::new()));
783 let s = spec(json!({
784 "actions": [
785 { "tool": "one" },
786 { "tool": "fail" },
787 { "tool": "three" }
788 ]
789 }));
790 let report = run_batch(&s, recording_dispatch(calls.clone())).await;
791 assert!(!report.ok);
792 assert_eq!(report.succeeded, 1);
793 assert_eq!(report.failed, 1);
794 assert_eq!(report.skipped, 1);
795 assert_eq!(report.logs[2].status, "skipped");
796 assert_eq!(*calls.lock().await, vec!["one", "fail"]);
797 }
798
799 #[tokio::test]
800 async fn no_stop_on_error_continues_independent_actions() {
801 let calls = Arc::new(Mutex::new(Vec::new()));
802 let s = spec(json!({
803 "mode": "parallel",
804 "stopOnError": false,
805 "actions": [
806 { "id": "bad", "tool": "fail" },
807 { "id": "child", "tool": "two", "dependsOn": ["bad"] },
808 { "id": "free", "tool": "three" }
809 ]
810 }));
811 let report = run_batch(&s, recording_dispatch(calls.clone())).await;
812 assert!(!report.ok);
813 assert_eq!(report.failed, 1);
814 assert_eq!(report.skipped, 1); assert_eq!(report.succeeded, 1); let child = report.logs.iter().find(|l| l.id == "child").unwrap();
817 assert_eq!(child.status, "skipped");
818 assert!(child
819 .error
820 .as_deref()
821 .unwrap()
822 .contains("dependency 'bad' failed"));
823 }
824
825 #[tokio::test]
826 async fn per_action_timeout_fails_action() {
827 let s = spec(json!({
828 "actions": [ { "tool": "slow", "timeoutMs": 20 } ]
829 }));
830 let dispatch = |_tool: String, _args: Value| async move {
831 tokio::time::sleep(Duration::from_secs(10)).await;
832 Ok(json!(null))
833 };
834 let report = run_batch(&s, dispatch).await;
835 assert!(!report.ok);
836 assert!(report.logs[0]
837 .error
838 .as_deref()
839 .unwrap()
840 .contains("timed out after 20ms"));
841 }
842
843 #[tokio::test]
844 async fn max_parallel_limits_concurrency() {
845 let live = Arc::new(Mutex::new((0usize, 0usize))); let s = spec(json!({
847 "mode": "parallel",
848 "maxParallel": 2,
849 "actions": [
850 { "tool": "a" }, { "tool": "b" }, { "tool": "c" }, { "tool": "d" }
851 ]
852 }));
853 let dispatch = move |_tool: String, _args: Value| {
854 let live = live.clone();
855 async move {
856 {
857 let mut g = live.lock().await;
858 g.0 += 1;
859 g.1 = g.1.max(g.0);
860 assert!(g.0 <= 2, "more than maxParallel actions ran at once");
861 }
862 tokio::time::sleep(Duration::from_millis(10)).await;
863 live.lock().await.0 -= 1;
864 Ok(json!(null))
865 }
866 };
867 let report = run_batch(&s, dispatch).await;
868 assert!(report.ok);
869 }
870
871 #[tokio::test]
872 async fn omit_result_drops_payload() {
873 let s = spec(json!({
874 "actions": [ { "tool": "one", "omitResult": true } ]
875 }));
876 let dispatch = |_t: String, _a: Value| async move { Ok(json!({ "huge": "payload" })) };
877 let report = run_batch(&s, dispatch).await;
878 assert!(report.ok);
879 assert_eq!(report.logs[0].status, "ok");
880 assert!(report.logs[0].result.is_none());
881 }
882
883 #[tokio::test]
884 async fn run_from_value_saves_report() {
885 let path =
886 std::env::temp_dir().join(format!("connector-batch-test-{}.json", std::process::id()));
887 let path_str = path.to_string_lossy().to_string();
888 let value = json!({
889 "save": path_str,
890 "actions": [ { "tool": "one" } ]
891 });
892 let dispatch = |_t: String, _a: Value| async move { Ok(json!(1)) };
893 let report = run_from_value(&value, dispatch).await.unwrap();
894 assert_eq!(report.saved_to.as_deref(), Some(path_str.as_str()));
895 let saved: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
896 assert_eq!(saved["total"], json!(1));
897 assert_eq!(saved["logs"][0]["status"], json!("ok"));
898 let _ = std::fs::remove_file(&path);
899 }
900
901 #[tokio::test]
902 async fn bare_array_is_actions_shorthand() {
903 let value = json!([ { "tool": "one" }, { "tool": "two" } ]);
904 let dispatch = |_t: String, _a: Value| async move { Ok(json!(null)) };
905 let report = run_from_value(&value, dispatch).await.unwrap();
906 assert_eq!(report.total, 2);
907 assert_eq!(report.mode, BatchMode::Sequential);
908 }
909
910 #[test]
911 fn validation_rejects_bad_specs() {
912 assert!(BatchSpec::parse(&json!({ "actions": [] })).is_err());
914 assert!(BatchSpec::parse(&json!({
916 "actions": [ { "tool": "a", "dependsOn": ["ghost"] } ]
917 }))
918 .is_err());
919 assert!(BatchSpec::parse(&json!({
921 "actions": [
922 { "id": "x", "tool": "a" },
923 { "id": "x", "tool": "b" }
924 ]
925 }))
926 .is_err());
927 assert!(BatchSpec::parse(&json!({
929 "actions": [ { "id": "x", "tool": "a", "dependsOn": ["x"] } ]
930 }))
931 .is_err());
932 assert!(BatchSpec::parse(&json!({
934 "actions": [ { "tool": "a", "args": "nope" } ]
935 }))
936 .is_err());
937 let s = BatchSpec::parse(&json!({
939 "stop_on_error": false,
940 "actions": [ { "tool": "a", "timeout_ms": 5 } ]
941 }))
942 .unwrap();
943 assert!(!s.stop_on_error);
944 assert_eq!(s.actions[0].timeout_ms, Some(5));
945 }
946
947 #[test]
948 fn validation_rejects_dependency_cycles() {
949 let err = BatchSpec::parse(&json!({
951 "mode": "parallel",
952 "actions": [
953 { "id": "a", "tool": "one", "dependsOn": ["b"] },
954 { "id": "b", "tool": "two", "dependsOn": ["a"] }
955 ]
956 }))
957 .unwrap_err();
958 assert!(err.contains("cycle"), "unexpected error: {err}");
959
960 let err = BatchSpec::parse(&json!({
962 "actions": [
963 { "id": "first", "tool": "one", "dependsOn": ["second"] },
964 { "id": "second", "tool": "two" }
965 ]
966 }))
967 .unwrap_err();
968 assert!(err.contains("cycle"), "unexpected error: {err}");
969 }
970
971 #[tokio::test]
972 async fn hand_built_cycle_never_deadlocks() {
973 let action = |id: &str, dep: &str| ActionSpec {
976 id: Some(id.to_string()),
977 tool: "one".to_string(),
978 args: Value::Null,
979 depends_on: vec![dep.to_string()],
980 timeout_ms: None,
981 omit_result: false,
982 };
983 let s = BatchSpec {
984 mode: BatchMode::Parallel,
985 stop_on_error: true,
986 max_parallel: None,
987 timeout_ms: None,
988 save: None,
989 actions: vec![action("a", "b"), action("b", "a")],
990 };
991 let dispatch = |_t: String, _a: Value| async move { Ok(json!(null)) };
992 let report = tokio::time::timeout(Duration::from_secs(5), run_batch(&s, dispatch))
993 .await
994 .expect("cycle deadlocked the executor");
995 assert!(!report.ok);
996 assert_eq!(report.skipped, 2);
997 }
998
999 #[tokio::test]
1000 async fn continue_on_error_sequential_still_runs_rest_in_order() {
1001 let calls = Arc::new(Mutex::new(Vec::new()));
1004 let s = spec(json!({
1005 "stopOnError": false,
1006 "actions": [
1007 { "tool": "one" },
1008 { "tool": "fail" },
1009 { "tool": "three" }
1010 ]
1011 }));
1012 let report = run_batch(&s, recording_dispatch(calls.clone())).await;
1013 assert!(!report.ok);
1014 assert_eq!(report.succeeded, 2);
1015 assert_eq!(report.failed, 1);
1016 assert_eq!(report.skipped, 0);
1017 assert_eq!(*calls.lock().await, vec!["one", "fail", "three"]);
1018 }
1019
1020 #[tokio::test]
1021 async fn soft_error_result_counts_as_failure() {
1022 let s = spec(json!({
1025 "actions": [
1026 { "id": "click", "tool": "webview_interact" },
1027 { "tool": "read_logs" }
1028 ]
1029 }));
1030 let dispatch = |_t: String, _a: Value| async move {
1031 Ok(json!({ "error": "Element not found", "selector": "#missing" }))
1032 };
1033 let report = run_batch(&s, dispatch).await;
1034 assert!(!report.ok);
1035 assert_eq!(report.logs[0].status, "error");
1036 let msg = report.logs[0].error.as_deref().unwrap();
1037 assert!(msg.contains("Element not found"), "message: {msg}");
1038 assert!(msg.contains("#missing"), "detail lost: {msg}");
1039 assert_eq!(report.logs[1].status, "skipped");
1040 }
1041
1042 #[test]
1043 fn unknown_spec_fields_are_rejected() {
1044 let err = BatchSpec::parse(&json!({
1047 "actions": [ { "tool": "a", "timeout": 5000 } ]
1048 }))
1049 .unwrap_err();
1050 assert!(err.contains("timeout"), "unexpected error: {err}");
1051 assert!(BatchSpec::parse(&json!({
1052 "mod": "parallel",
1053 "actions": [ { "tool": "a" } ]
1054 }))
1055 .is_err());
1056 }
1057
1058 #[tokio::test]
1059 async fn synthesized_log_id_avoids_explicit_collision() {
1060 let s = spec(json!({
1061 "mode": "parallel",
1062 "actions": [
1063 { "tool": "one" },
1064 { "id": "a0", "tool": "two" }
1065 ]
1066 }));
1067 let dispatch = |_t: String, _a: Value| async move { Ok(json!(null)) };
1068 let report = run_batch(&s, dispatch).await;
1069 let ids: Vec<&str> = report.logs.iter().map(|l| l.id.as_str()).collect();
1070 assert_eq!(ids[1], "a0");
1071 assert_ne!(ids[0], "a0", "synthesized id collided with explicit id");
1072 }
1073}