Skip to main content

connector_client/
batch.rs

1//! Generic batch-action executor.
2//!
3//! Parses a JSON spec describing a list of MCP tool actions, runs them
4//! sequentially, in parallel, or DAG-ordered via `dependsOn`, and produces a
5//! structured run report with one log entry per action. The executor is
6//! generic over an async dispatcher, so the CLI, the standalone MCP server,
7//! and the plugin's embedded MCP server all share the same scheduling logic
8//! while dispatching through their own tool tables.
9
10use 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/// Execution mode for a batch when no explicit dependencies decide the order.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
22#[serde(rename_all = "lowercase")]
23pub enum BatchMode {
24    /// Run actions one after another in spec order (each action implicitly
25    /// depends on the previous one).
26    #[default]
27    Sequential,
28    /// Start all actions concurrently; only explicit `dependsOn` edges order
29    /// them.
30    Parallel,
31}
32
33/// One action inside a batch: an MCP tool name plus its arguments.
34#[derive(Debug, Clone, Deserialize)]
35#[serde(rename_all = "camelCase", deny_unknown_fields)]
36pub struct ActionSpec {
37    /// Optional stable id, referenced by `dependsOn` of other actions.
38    #[serde(default)]
39    pub id: Option<String>,
40    /// MCP tool name (e.g. `webview_interact`, `webview_execute_js`).
41    pub tool: String,
42    /// Tool arguments object (same shape as a direct MCP tool call).
43    #[serde(default)]
44    pub args: Value,
45    /// Ids of actions that must succeed before this one starts.
46    #[serde(default, alias = "depends_on")]
47    pub depends_on: Vec<String>,
48    /// Per-action timeout override in milliseconds.
49    #[serde(default, alias = "timeout_ms")]
50    pub timeout_ms: Option<u64>,
51    /// Omit the tool result from the log entry (status/timing only).
52    #[serde(default, alias = "omit_result")]
53    pub omit_result: bool,
54}
55
56/// Parsed batch specification.
57#[derive(Debug, Clone, Deserialize)]
58#[serde(rename_all = "camelCase", deny_unknown_fields)]
59pub struct BatchSpec {
60    #[serde(default)]
61    pub mode: BatchMode,
62    /// Stop starting new actions after the first failure (default true).
63    /// In-flight actions still finish; unstarted ones are logged as skipped.
64    #[serde(default = "default_true", alias = "stop_on_error")]
65    pub stop_on_error: bool,
66    /// Maximum number of concurrently running actions (default unlimited).
67    #[serde(default, alias = "max_parallel")]
68    pub max_parallel: Option<usize>,
69    /// Default per-action timeout in milliseconds.
70    #[serde(default, alias = "timeout_ms")]
71    pub timeout_ms: Option<u64>,
72    /// Write the run report as pretty JSON to this file path.
73    #[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    /// Parse a spec from JSON. Accepts either the full object form or a bare
84    /// array of actions (shorthand for `{ "actions": [...] }`).
85    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        // Reject dependency cycles up front (Kahn over the same edge set the
135        // scheduler uses: explicit dependsOn plus the sequential chain), so a
136        // bad spec fails with a clear error instead of a report full of
137        // never-became-ready skips.
138        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    /// Build the scheduling edges: `(all_deps, explicit_deps)` per action.
169    /// `all_deps` adds the implicit previous-action chain in sequential mode;
170    /// `explicit_deps` holds only user-written `dependsOn` edges (the ones a
171    /// failure propagates along).
172    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                // Unknown ids are rejected by validate(); skip defensively for
185                // callers constructing a BatchSpec by hand.
186                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
204/// Legacy compatibility belongs only to tools whose published contract uses
205/// a top-level error string. Query/eval data is never interpreted as an error.
206fn 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
242/// Batch screenshots use durable references by default. The legacy explicit
243/// `save:false` opt-out remains available to callers needing inline images.
244pub 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
256/// Compact only a known screenshot result that provides a saved artifact.
257/// Never strip image-looking fields from arbitrary business query values.
258pub 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/// Per-action run log entry.
290#[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    /// "ok" | "error" | "skipped"
297    pub status: String,
298    /// Offset from batch start when the action began, in milliseconds.
299    #[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/// Full batch run report returned to the caller (and optionally saved to disk).
312#[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    /// Unix epoch milliseconds when the batch started.
322    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
340/// Parse a spec value, run it through `dispatch`, and save the report when the
341/// spec requests it. This is the single entry point shared by the CLI and both
342/// MCP servers.
343pub 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
353/// Typed entry point for internal dispatchers, without MCP envelope round trips.
354pub 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        // Include the destination before serialization; clear it on failure.
370        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
379/// Run a parsed batch spec through `dispatch`, returning the report.
380pub 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
392/// Shared scheduler over typed outcomes. Failed verification and unknown
393/// execution block explicit dependencies exactly like execution errors.
394pub 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    // Scheduling edges (with the sequential chain) vs explicit dependsOn
404    // edges. Readiness follows `deps`; failures propagate only along
405    // `explicit_deps`, so sequential + stopOnError:false still runs the rest
406    // in order after a failure.
407    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    // Display ids: explicit id, or a synthesized `a{index}` nudged with `_`
426    // prefixes until it collides with no explicit id.
427    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    // Mark an action skipped and release its dependents (which may cascade).
449    #[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        // Start ready actions (or skip them when aborting / deps failed).
483        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                // Stamp the start at first poll, not at schedule time, so the
542                // offset reflects when the action actually began running.
543                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        // Wait for the next completion, then release its dependents.
570        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    // Anything still pending (unreachable due to skip cascades) is skipped.
619    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
656/// Write a report as pretty JSON to `path`, creating parent directories.
657pub 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    /// Dispatcher that records call order and sleeps briefly.
688    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        // Sequential actions must not overlap: starts are ordered.
722        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        // Two actions that each wait for the other to start: they only finish
733        // if they truly run concurrently.
734        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); // child of the failure
815        assert_eq!(report.succeeded, 1); // independent action still ran
816        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))); // (current, peak)
846        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        // Empty actions
913        assert!(BatchSpec::parse(&json!({ "actions": [] })).is_err());
914        // Unknown dependency
915        assert!(BatchSpec::parse(&json!({
916            "actions": [ { "tool": "a", "dependsOn": ["ghost"] } ]
917        }))
918        .is_err());
919        // Duplicate ids
920        assert!(BatchSpec::parse(&json!({
921            "actions": [
922                { "id": "x", "tool": "a" },
923                { "id": "x", "tool": "b" }
924            ]
925        }))
926        .is_err());
927        // Self dependency
928        assert!(BatchSpec::parse(&json!({
929            "actions": [ { "id": "x", "tool": "a", "dependsOn": ["x"] } ]
930        }))
931        .is_err());
932        // Non-object args
933        assert!(BatchSpec::parse(&json!({
934            "actions": [ { "tool": "a", "args": "nope" } ]
935        }))
936        .is_err());
937        // Snake_case aliases accepted
938        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        // Explicit a <-> b cycle.
950        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        // Sequential chain + a forward explicit dep also forms a cycle.
961        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        // Defense in depth: a caller constructing a cyclic BatchSpec directly
974        // (bypassing parse/validate) must get skips, not a hang.
975        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        // stopOnError:false in sequential mode must keep executing the
1002        // remaining actions in order — only explicit dependsOn edges skip.
1003        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        // A tool "succeeding" with {"error": ...} (e.g. element not found)
1023        // must fail the action and trigger stopOnError for the rest.
1024        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        // Typos like "timeout" (instead of timeoutMs) must not be silently
1045        // ignored.
1046        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}