Skip to main content

capsule_runtime/
plan.rs

1use std::{
2    collections::{HashMap, HashSet},
3    fs,
4    io::{Read, Write},
5    net::TcpStream,
6    path::{Path, PathBuf},
7    thread,
8    time::Duration,
9};
10
11use murmur_artifact::{CodeTaskRequest, MurmurMessage};
12use serde::Deserialize;
13use serde_json::{json, Value};
14use url::Url;
15
16use crate::{
17    bindings::host::murmur::tool::run::{Status as ToolStatus, ToolInput, ToolResult},
18    sandbox,
19    shell::{execute_shell, split_shell_words},
20    types::CapabilityPolicy,
21};
22
23pub mod condition;
24
25#[derive(Debug, Deserialize)]
26struct PlanFile {
27    id: String,
28    steps: Vec<StepDef>,
29}
30
31#[derive(Debug, Clone, Deserialize)]
32struct StepDef {
33    id: String,
34    tool: Option<String>,
35    shell: Option<String>,
36    capsule: Option<String>,
37    #[serde(default)]
38    input: Value,
39    #[serde(default)]
40    depends_on: Vec<String>,
41    #[serde(rename = "if")]
42    condition: Option<String>,
43    #[serde(default = "default_on_error")]
44    on_error: String,
45    #[serde(default)]
46    retries: u32,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50enum StepKind {
51    Tool,
52    Shell,
53    Spawn,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub enum StepStatus {
58    Success,
59    Failed,
60    Skipped,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct StepResult {
65    pub step_id: String,
66    pub status: StepStatus,
67    pub output: Option<String>,
68    pub error: Option<String>,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct ExecutionReport {
73    pub plan_id: String,
74    pub results: Vec<StepResult>,
75    pub completed: bool,
76    pub failed_step: Option<String>,
77}
78
79pub struct SchedulerContext<'a> {
80    pub workdir: PathBuf,
81    pub capability_policy: CapabilityPolicy,
82    pub installed_tools: HashSet<String>,
83    pub capsule_versions: HashMap<String, String>,
84    pub current_job_id: Option<String>,
85    pub invoke_tool: &'a (dyn Fn(&str, ToolInput) -> Result<ToolResult, String> + Sync),
86}
87
88pub fn execute(plan_path: &Path, ctx: &SchedulerContext<'_>) -> ExecutionReport {
89    match execute_inner(plan_path, ctx) {
90        Ok(report) => report,
91        Err((plan_id, failed_step, error)) => ExecutionReport {
92            plan_id,
93            results: vec![StepResult {
94                step_id: failed_step.clone(),
95                status: StepStatus::Failed,
96                output: None,
97                error: Some(error),
98            }],
99            completed: false,
100            failed_step: Some(failed_step),
101        },
102    }
103}
104
105fn execute_inner(
106    plan_path: &Path,
107    ctx: &SchedulerContext<'_>,
108) -> Result<ExecutionReport, (String, String, String)> {
109    let raw = fs::read_to_string(plan_path).map_err(|error| {
110        (
111            "unknown".to_string(),
112            "plan".to_string(),
113            format!("failed to read plan file {}: {error}", plan_path.display()),
114        )
115    })?;
116    let plan: PlanFile = serde_json::from_str(&raw).map_err(|error| {
117        (
118            "unknown".to_string(),
119            "plan".to_string(),
120            format!("failed to parse plan JSON: {error}"),
121        )
122    })?;
123
124    if let Err((step_id, error)) = validate_plan(&plan, ctx) {
125        return Ok(ExecutionReport {
126            plan_id: plan.id,
127            results: vec![StepResult {
128                step_id: step_id.clone(),
129                status: StepStatus::Failed,
130                output: None,
131                error: Some(error),
132            }],
133            completed: false,
134            failed_step: Some(step_id.clone()),
135        });
136    }
137
138    // Host-probed kernel enforcement tier + resolved network allowlist for this plan's shell
139    // steps — resolved once, up front, same cadence as `runtime::launch_session`.
140    let shell_enforcement = match sandbox::ShellEnforcement::resolve(&ctx.capability_policy) {
141        Ok(enforcement) => enforcement,
142        Err(error) => {
143            return Ok(ExecutionReport {
144                plan_id: plan.id,
145                results: vec![StepResult {
146                    step_id: "plan".to_string(),
147                    status: StepStatus::Failed,
148                    output: None,
149                    error: Some(format!(
150                        "failed to resolve shell subprocess sandbox enforcement: {error}"
151                    )),
152                }],
153                completed: false,
154                failed_step: Some("plan".to_string()),
155            });
156        }
157    };
158
159    let mut results = HashMap::<String, StepResult>::new();
160    let mut result_order = Vec::<String>::new();
161    let mut failed_step = None;
162
163    loop {
164        if results.len() == plan.steps.len() || failed_step.is_some() {
165            break;
166        }
167
168        let mut ready = Vec::new();
169        let mut progressed = false;
170
171        for step in &plan.steps {
172            if results.contains_key(&step.id) {
173                continue;
174            }
175            if !step.depends_on.iter().all(|dep| results.contains_key(dep)) {
176                continue;
177            }
178
179            match condition::evaluate(step.condition.as_deref(), &results) {
180                Ok(true) => ready.push(step.clone()),
181                Ok(false) => {
182                    insert_result(
183                        &mut results,
184                        &mut result_order,
185                        StepResult {
186                            step_id: step.id.clone(),
187                            status: StepStatus::Skipped,
188                            output: None,
189                            error: None,
190                        },
191                    );
192                    progressed = true;
193                }
194                Err(error) => {
195                    insert_result(
196                        &mut results,
197                        &mut result_order,
198                        StepResult {
199                            step_id: step.id.clone(),
200                            status: StepStatus::Failed,
201                            output: None,
202                            error: Some(error),
203                        },
204                    );
205                    failed_step = Some(step.id.clone());
206                    progressed = true;
207                    break;
208                }
209            }
210        }
211
212        if failed_step.is_some() {
213            break;
214        }
215
216        if ready.is_empty() {
217            if progressed {
218                continue;
219            }
220            let blocked = plan
221                .steps
222                .iter()
223                .find(|step| !results.contains_key(&step.id))
224                .map(|step| step.id.clone())
225                .unwrap_or_else(|| "plan".to_string());
226            insert_result(
227                &mut results,
228                &mut result_order,
229                StepResult {
230                    step_id: blocked.clone(),
231                    status: StepStatus::Failed,
232                    output: None,
233                    error: Some(
234                        "plan is blocked by a dependency cycle or missing dependency".to_string(),
235                    ),
236                },
237            );
238            failed_step = Some(blocked);
239            break;
240        }
241
242        let mut completed = Vec::new();
243        thread::scope(|scope| {
244            let mut handles = Vec::new();
245            for step in ready {
246                let snapshot = results.clone();
247                let shell_enforcement = &shell_enforcement;
248                handles.push(scope.spawn(move || {
249                    execute_step_with_retries(&step, ctx, &snapshot, shell_enforcement)
250                }));
251            }
252            for handle in handles {
253                completed.push(handle.join().unwrap_or_else(|_| StepResult {
254                    step_id: "unknown".to_string(),
255                    status: StepStatus::Failed,
256                    output: None,
257                    error: Some("step dispatch thread panicked".to_string()),
258                }));
259            }
260        });
261
262        for result in completed {
263            let Some(step) = plan.steps.iter().find(|step| step.id == result.step_id) else {
264                insert_result(&mut results, &mut result_order, result);
265                failed_step = Some("unknown".to_string());
266                break;
267            };
268
269            if result.status == StepStatus::Failed {
270                match step.on_error.as_str() {
271                    "fail" => {
272                        let id = result.step_id.clone();
273                        insert_result(&mut results, &mut result_order, result);
274                        failed_step = Some(id);
275                        break;
276                    }
277                    "skip" => insert_result(
278                        &mut results,
279                        &mut result_order,
280                        StepResult {
281                            step_id: result.step_id,
282                            status: StepStatus::Skipped,
283                            output: None,
284                            error: result.error,
285                        },
286                    ),
287                    "continue" => insert_result(&mut results, &mut result_order, result),
288                    _ => {
289                        let id = result.step_id.clone();
290                        insert_result(
291                            &mut results,
292                            &mut result_order,
293                            StepResult {
294                                error: Some(format!("invalid on_error policy '{}'", step.on_error)),
295                                ..result
296                            },
297                        );
298                        failed_step = Some(id);
299                        break;
300                    }
301                }
302            } else {
303                insert_result(&mut results, &mut result_order, result);
304            }
305        }
306    }
307
308    let ordered_results = result_order
309        .into_iter()
310        .filter_map(|id| results.remove(&id))
311        .collect::<Vec<_>>();
312
313    Ok(ExecutionReport {
314        plan_id: plan.id,
315        results: ordered_results,
316        completed: failed_step.is_none(),
317        failed_step,
318    })
319}
320
321fn validate_plan(plan: &PlanFile, ctx: &SchedulerContext<'_>) -> Result<(), (String, String)> {
322    let mut ids = HashSet::new();
323    for step in &plan.steps {
324        if !ids.insert(step.id.clone()) {
325            return Err((step.id.clone(), format!("duplicate step id '{}'", step.id)));
326        }
327
328        match infer_kind(step) {
329            Ok(StepKind::Tool) => {
330                let tool = step.tool.as_ref().unwrap();
331                if !ctx.installed_tools.contains(tool) {
332                    return Err((step.id.clone(), format!("tool '{tool}' is not installed")));
333                }
334            }
335            Ok(StepKind::Shell) => {
336                let Some(binary) = shell_binary(step.shell.as_deref().unwrap_or_default()) else {
337                    return Err((step.id.clone(), "shell command is empty".to_string()));
338                };
339                if !ctx.capability_policy.shell_allow.contains(&binary) {
340                    return Err((
341                        step.id.clone(),
342                        format!("binary '{binary}' is not in capabilities.shell.allow"),
343                    ));
344                }
345            }
346            Ok(StepKind::Spawn) => {
347                // spawn_allow is enforced by mur-roost from the parent's CapabilityPolicy.
348                // No local check here — the capsule cannot self-authorize its own spawn rights.
349            }
350            Err(error) => return Err((step.id.clone(), error)),
351        }
352
353        if !matches!(step.on_error.as_str(), "fail" | "skip" | "continue") {
354            return Err((
355                step.id.clone(),
356                format!("invalid on_error policy '{}'", step.on_error),
357            ));
358        }
359    }
360
361    for step in &plan.steps {
362        for dep in &step.depends_on {
363            if !ids.contains(dep) {
364                return Err((step.id.clone(), format!("unknown dependency '{dep}'")));
365            }
366        }
367        validate_references(step, &ids)?;
368    }
369
370    Ok(())
371}
372
373fn validate_references(step: &StepDef, ids: &HashSet<String>) -> Result<(), (String, String)> {
374    let mut references = Vec::new();
375    collect_references(&step.input, &mut references);
376    if let Some(condition) = &step.condition {
377        for token in condition.split_whitespace() {
378            if token.starts_with('$') {
379                references.push(token.trim_matches(|c| c == '(' || c == ')').to_string());
380            }
381        }
382    }
383
384    for reference in references {
385        if let Some((step_id, field)) = parse_reference(&reference) {
386            if !ids.contains(step_id) {
387                return Err((step.id.clone(), format!("unknown reference '{reference}'")));
388            }
389            if field != "output" && field != "status" {
390                return Err((
391                    step.id.clone(),
392                    format!("unknown reference field '{field}'"),
393                ));
394            }
395        }
396    }
397
398    Ok(())
399}
400
401fn collect_references(value: &Value, references: &mut Vec<String>) {
402    match value {
403        Value::String(value) if value.starts_with('$') => references.push(value.clone()),
404        Value::Array(values) => values
405            .iter()
406            .for_each(|value| collect_references(value, references)),
407        Value::Object(map) => map
408            .values()
409            .for_each(|value| collect_references(value, references)),
410        _ => {}
411    }
412}
413
414fn execute_step_with_retries(
415    step: &StepDef,
416    ctx: &SchedulerContext<'_>,
417    results: &HashMap<String, StepResult>,
418    enforcement: &sandbox::ShellEnforcement,
419) -> StepResult {
420    let attempts = step.retries.saturating_add(1);
421    let mut last = None;
422    for _ in 0..attempts {
423        let result = execute_step_once(step, ctx, results, enforcement);
424        if result.status != StepStatus::Failed {
425            return result;
426        }
427        last = Some(result);
428    }
429
430    last.unwrap_or_else(|| StepResult {
431        step_id: step.id.clone(),
432        status: StepStatus::Failed,
433        output: None,
434        error: Some("step was not attempted".to_string()),
435    })
436}
437
438fn execute_step_once(
439    step: &StepDef,
440    ctx: &SchedulerContext<'_>,
441    results: &HashMap<String, StepResult>,
442    enforcement: &sandbox::ShellEnforcement,
443) -> StepResult {
444    let mut input = step.input.clone();
445    if let Err(error) = interpolate_value(&mut input, results) {
446        return failed(&step.id, error);
447    }
448    let input_json = serde_json::to_string(&input).unwrap_or_else(|_| "{}".to_string());
449
450    match infer_kind(step) {
451        Ok(StepKind::Tool) => dispatch_tool_step(step, ctx, input_json),
452        Ok(StepKind::Shell) => dispatch_shell_step(step, ctx, enforcement),
453        Ok(StepKind::Spawn) => dispatch_capsule_step(step, ctx, input),
454        Err(error) => failed(&step.id, error),
455    }
456}
457
458fn dispatch_tool_step(
459    step: &StepDef,
460    ctx: &SchedulerContext<'_>,
461    input_json: String,
462) -> StepResult {
463    let name = step.tool.as_deref().unwrap_or_default();
464    match (ctx.invoke_tool)(
465        name,
466        ToolInput {
467            data: Some(input_json),
468            log_path: None,
469        },
470    ) {
471        Ok(result) if matches!(result.status, ToolStatus::Passed) => StepResult {
472            step_id: step.id.clone(),
473            status: StepStatus::Success,
474            output: result
475                .data
476                .or_else(|| read_optional_path(result.data_path.as_deref())),
477            error: None,
478        },
479        Ok(result) => failed(
480            &step.id,
481            result
482                .summary
483                .or(result.data)
484                .unwrap_or_else(|| "tool step failed".to_string()),
485        ),
486        Err(error) => failed(&step.id, error),
487    }
488}
489
490fn dispatch_shell_step(
491    step: &StepDef,
492    ctx: &SchedulerContext<'_>,
493    enforcement: &sandbox::ShellEnforcement,
494) -> StepResult {
495    let command = step.shell.as_deref().unwrap_or_default();
496    let Some((binary, args)) = parse_shell_command(command) else {
497        return failed(&step.id, "shell command is empty");
498    };
499    let arg_refs = args.iter().map(String::as_str).collect::<Vec<_>>();
500
501    match execute_shell(
502        &binary,
503        &arg_refs,
504        &[],
505        &ctx.workdir,
506        &ctx.capability_policy,
507        enforcement,
508    ) {
509        Ok(result) if result.exit_code == 0 => StepResult {
510            step_id: step.id.clone(),
511            status: StepStatus::Success,
512            output: Some(result.stdout),
513            error: None,
514        },
515        Ok(result) => failed(&step.id, result.stderr),
516        Err(error) => failed(&step.id, error),
517    }
518}
519
520fn dispatch_capsule_step(step: &StepDef, ctx: &SchedulerContext<'_>, input: Value) -> StepResult {
521    let capsule = step.capsule.as_deref().unwrap_or_default();
522    let roost_url = match std::env::var("MURMUR_ROOST_URL") {
523        Ok(value) if !value.trim().is_empty() => value,
524        _ => {
525            return failed(
526                &step.id,
527                "MURMUR_ROOST_URL is not set; capsule steps require mur-roost",
528            )
529        }
530    };
531    let version = ctx
532        .capsule_versions
533        .get(capsule)
534        .cloned()
535        .unwrap_or_else(|| "0.1.0".to_string());
536
537    // Step 1: Spawn child capsule via mur-roost. Input is NOT sent here.
538    let spawn_body = json!({
539        "name": capsule,
540        "version": version,
541        "workdir": ctx.workdir,
542        "spawned_by": ctx.current_job_id,
543    })
544    .to_string();
545
546    let spawn = match http_json(
547        "POST",
548        &format!("{}/spawn", roost_url.trim_end_matches('/')),
549        Some(&spawn_body),
550    ) {
551        Ok(value) => value,
552        Err(error) => return failed(&step.id, error),
553    };
554
555    let Some(capsule_url) = spawn.get("capsule_url").and_then(Value::as_str) else {
556        return failed(
557            &step.id,
558            format!("mur-roost spawn response missing capsule_url: {spawn}"),
559        );
560    };
561    let capsule_url = capsule_url.trim_end_matches('/').to_string();
562
563    // Step 2: Build the A2A message/send JSON-RPC body with the task input.
564    let input_text = match capsule_step_input_message(input) {
565        Ok(text) => text,
566        Err(error) => return failed(&step.id, error),
567    };
568    let req_id = format!("plan-{}", step.id);
569    let msg_id = format!("msg-{}", step.id);
570    let a2a_send_body = json!({
571        "jsonrpc": "2.0",
572        "id": req_id,
573        "method": "message/send",
574        "params": {
575            "message": {
576                "messageId": msg_id,
577                "role": "user",
578                "parts": [{"text": input_text}]
579            }
580        }
581    })
582    .to_string();
583
584    // Step 3: POST message/send to the capsule with exponential backoff (100ms→2s, 30s max).
585    let deadline = std::time::Instant::now() + Duration::from_secs(30);
586    let mut delay_ms = 100u64;
587    let send_response = loop {
588        match http_json("POST", &capsule_url, Some(&a2a_send_body)) {
589            Ok(resp) => break resp,
590            Err(_) if std::time::Instant::now() < deadline => {
591                thread::sleep(Duration::from_millis(delay_ms));
592                delay_ms = (delay_ms * 2).min(2000);
593            }
594            Err(e) => {
595                return failed(
596                    &step.id,
597                    format!("capsule at {capsule_url} did not become ready within 30s: {e}"),
598                )
599            }
600        }
601    };
602
603    let Some(task_id) = send_response
604        .get("result")
605        .and_then(|r| r.get("id"))
606        .and_then(Value::as_str)
607    else {
608        return failed(
609            &step.id,
610            format!("A2A message/send response missing result.id: {send_response}"),
611        );
612    };
613    let task_id = task_id.to_string();
614
615    // Step 4: Poll tasks/get until completed or failed.
616    let tasks_get_body = json!({
617        "jsonrpc": "2.0",
618        "id": req_id,
619        "method": "tasks/get",
620        "params": { "id": task_id }
621    })
622    .to_string();
623
624    loop {
625        thread::sleep(Duration::from_millis(500));
626        let task = match http_json("POST", &capsule_url, Some(&tasks_get_body)) {
627            Ok(v) => v,
628            Err(e) => return failed(&step.id, e),
629        };
630        let state = task
631            .get("result")
632            .and_then(|r| r.get("status"))
633            .and_then(|s| s.get("state"))
634            .and_then(Value::as_str);
635        match state {
636            Some("submitted" | "working" | "input-required") => continue,
637            Some("completed") => {
638                let text = task
639                    .get("result")
640                    .and_then(|r| r.get("artifacts"))
641                    .and_then(Value::as_array)
642                    .and_then(|a| a.first())
643                    .and_then(|a| a.get("parts"))
644                    .and_then(Value::as_array)
645                    .and_then(|p| p.first())
646                    .and_then(|p| p.get("text"))
647                    .and_then(Value::as_str)
648                    .unwrap_or("")
649                    .to_string();
650                return StepResult {
651                    step_id: step.id.clone(),
652                    status: StepStatus::Success,
653                    output: Some(text),
654                    error: None,
655                };
656            }
657            Some("failed" | "rejected") => {
658                let msg = task
659                    .get("result")
660                    .and_then(|r| r.get("status"))
661                    .and_then(|s| s.get("message"))
662                    .and_then(|m| m.get("parts"))
663                    .and_then(Value::as_array)
664                    .and_then(|p| p.first())
665                    .and_then(|p| p.get("text"))
666                    .and_then(Value::as_str)
667                    .unwrap_or("capsule task failed")
668                    .to_string();
669                return failed(&step.id, msg);
670            }
671            other => {
672                return failed(
673                    &step.id,
674                    format!("unknown A2A task state: {other:?}"),
675                )
676            }
677        }
678    }
679}
680
681fn capsule_step_input_message(input: Value) -> Result<String, String> {
682    let request = serde_json::from_value::<CodeTaskRequest>(input.clone()).unwrap_or_else(|_| {
683        CodeTaskRequest {
684            objective: serde_json::to_string(&input).unwrap_or_default(),
685            instructions: None,
686            context: None,
687            output_format: None,
688        }
689    });
690    let message = MurmurMessage {
691        schema: "murmur.message.v1".to_string(),
692        message_type: "murmur.code_task.request.v1".to_string(),
693        job_id: None,
694        payload: request,
695    };
696
697    serde_json::to_string(&message).map_err(|error| {
698        format!("failed to serialize capsule step input as MurmurMessage: {error}")
699    })
700}
701
702fn interpolate_value(
703    value: &mut Value,
704    results: &HashMap<String, StepResult>,
705) -> Result<(), String> {
706    match value {
707        Value::String(text) if text.starts_with('$') => {
708            *text = condition::resolve_reference(text, results)?;
709        }
710        Value::Array(values) => {
711            for value in values {
712                interpolate_value(value, results)?;
713            }
714        }
715        Value::Object(map) => {
716            for value in map.values_mut() {
717                interpolate_value(value, results)?;
718            }
719        }
720        _ => {}
721    }
722
723    Ok(())
724}
725
726fn infer_kind(step: &StepDef) -> Result<StepKind, String> {
727    let count =
728        step.tool.is_some() as u8 + step.shell.is_some() as u8 + step.capsule.is_some() as u8;
729    match count {
730        1 if step.tool.is_some() => Ok(StepKind::Tool),
731        1 if step.shell.is_some() => Ok(StepKind::Shell),
732        1 if step.capsule.is_some() => Ok(StepKind::Spawn),
733        0 => Err("step must declare exactly one of tool, shell, or capsule".to_string()),
734        _ => Err("step declares more than one of tool, shell, or capsule".to_string()),
735    }
736}
737
738fn default_on_error() -> String {
739    "fail".to_string()
740}
741
742fn failed(step_id: &str, error: impl Into<String>) -> StepResult {
743    StepResult {
744        step_id: step_id.to_string(),
745        status: StepStatus::Failed,
746        output: None,
747        error: Some(error.into()),
748    }
749}
750
751fn insert_result(
752    results: &mut HashMap<String, StepResult>,
753    order: &mut Vec<String>,
754    result: StepResult,
755) {
756    order.push(result.step_id.clone());
757    results.insert(result.step_id.clone(), result);
758}
759
760fn shell_binary(command: &str) -> Option<String> {
761    parse_shell_command(command).map(|(binary, _)| binary)
762}
763
764fn parse_shell_command(command: &str) -> Option<(String, Vec<String>)> {
765    let parts = split_shell_words(command);
766    let (binary, args) = parts.split_first()?;
767    Some((binary.clone(), args.to_vec()))
768}
769
770fn parse_reference(reference: &str) -> Option<(&str, &str)> {
771    reference.strip_prefix('$')?.rsplit_once('.')
772}
773
774fn read_optional_path(path: Option<&str>) -> Option<String> {
775    path.and_then(|path| fs::read_to_string(path).ok())
776}
777
778fn http_json(method: &str, url: &str, body: Option<&str>) -> Result<Value, String> {
779    let url = Url::parse(url).map_err(|error| format!("invalid URL '{url}': {error}"))?;
780    if url.scheme() != "http" {
781        return Err(format!("unsupported URL scheme '{}'", url.scheme()));
782    }
783    let host = url
784        .host_str()
785        .ok_or_else(|| format!("URL '{url}' has no host"))?;
786    let port = url.port_or_known_default().unwrap_or(80);
787    let addr = format!("{host}:{port}");
788    let mut stream = TcpStream::connect(&addr)
789        .map_err(|error| format!("failed to connect to {addr}: {error}"))?;
790    stream
791        .set_read_timeout(Some(Duration::from_secs(30)))
792        .map_err(|error| error.to_string())?;
793
794    let path = match url.query() {
795        Some(query) => format!("{}?{query}", url.path()),
796        None => url.path().to_string(),
797    };
798    let body = body.unwrap_or("");
799    let request = if method == "POST" {
800        format!(
801            "POST {path} HTTP/1.1\r\nHost: {host}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
802            body.len()
803        )
804    } else {
805        format!("GET {path} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\n\r\n")
806    };
807    stream
808        .write_all(request.as_bytes())
809        .map_err(|error| format!("failed to write HTTP request: {error}"))?;
810
811    let mut response = String::new();
812    stream
813        .read_to_string(&mut response)
814        .map_err(|error| format!("failed to read HTTP response: {error}"))?;
815    let Some((headers, body)) = response.split_once("\r\n\r\n") else {
816        return Err("invalid HTTP response".to_string());
817    };
818    if !headers.starts_with("HTTP/1.1 2") && !headers.starts_with("HTTP/1.0 2") {
819        return Err(format!("HTTP request failed: {headers}; body: {body}"));
820    }
821    serde_json::from_str(body).map_err(|error| format!("failed to parse HTTP JSON: {error}"))
822}
823
824#[cfg(test)]
825mod tests {
826    use std::{
827        sync::{
828            atomic::{AtomicUsize, Ordering},
829            Arc,
830        },
831        time::{Duration, Instant},
832    };
833
834    use tempfile::tempdir;
835
836    use super::*;
837
838    fn test_ctx<'a>(
839        workdir: PathBuf,
840        invoke_tool: &'a (dyn Fn(&str, ToolInput) -> Result<ToolResult, String> + Sync),
841    ) -> SchedulerContext<'a> {
842        SchedulerContext {
843            workdir,
844            capability_policy: CapabilityPolicy {
845                shell_allow: vec!["bash".to_string()],
846                spawn_allow: vec!["worker".to_string()],
847                ..CapabilityPolicy::default()
848            },
849            installed_tools: HashSet::from([
850                "ok".to_string(),
851                "fail".to_string(),
852                "slow".to_string(),
853                "a".to_string(),
854                "b".to_string(),
855            ]),
856            capsule_versions: HashMap::new(),
857            current_job_id: None,
858            invoke_tool,
859        }
860    }
861
862    fn tool_result(status: ToolStatus, data: Option<String>) -> ToolResult {
863        ToolResult {
864            status,
865            summary: None,
866            data,
867            data_path: None,
868            truncated: false,
869            metadata: Vec::new(),
870        }
871    }
872
873    fn write_plan(workdir: &Path, plan: Value) -> PathBuf {
874        let path = workdir.join("plan.json");
875        fs::write(&path, serde_json::to_string(&plan).unwrap()).unwrap();
876        path
877    }
878
879    #[test]
880    fn dependent_step_receives_upstream_output() {
881        let dir = tempdir().unwrap();
882        let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
883        let seen_tool = Arc::clone(&seen);
884        let invoke = move |_name: &str, input: ToolInput| {
885            seen_tool
886                .lock()
887                .unwrap()
888                .push(input.data.unwrap_or_default());
889            Ok(tool_result(
890                ToolStatus::Passed,
891                Some("upstream".to_string()),
892            ))
893        };
894        let plan = write_plan(
895            dir.path(),
896            json!({
897                "id": "p",
898                "steps": [
899                    {"id": "a", "tool": "ok"},
900                    {"id": "b", "tool": "ok", "depends_on": ["a"], "input": {"value": "$a.output"}}
901                ]
902            }),
903        );
904
905        let report = execute(&plan, &test_ctx(dir.path().to_path_buf(), &invoke));
906        assert!(report.completed);
907        assert_eq!(report.results.len(), 2);
908        assert!(seen.lock().unwrap()[1].contains("upstream"));
909    }
910
911    #[test]
912    fn parallel_tool_steps_run_concurrently() {
913        let dir = tempdir().unwrap();
914        let active = Arc::new(AtomicUsize::new(0));
915        let max_active = Arc::new(AtomicUsize::new(0));
916        let invoke_active = Arc::clone(&active);
917        let invoke_max = Arc::clone(&max_active);
918        let invoke = move |_name: &str, _input: ToolInput| {
919            let now = invoke_active.fetch_add(1, Ordering::SeqCst) + 1;
920            invoke_max.fetch_max(now, Ordering::SeqCst);
921            thread::sleep(Duration::from_millis(150));
922            invoke_active.fetch_sub(1, Ordering::SeqCst);
923            Ok(tool_result(ToolStatus::Passed, Some("ok".to_string())))
924        };
925        let plan = write_plan(
926            dir.path(),
927            json!({
928                "id": "p",
929                "steps": [
930                    {"id": "a", "tool": "slow"},
931                    {"id": "b", "tool": "slow"}
932                ]
933            }),
934        );
935
936        let started = Instant::now();
937        let report = execute(&plan, &test_ctx(dir.path().to_path_buf(), &invoke));
938        assert!(report.completed);
939        assert!(started.elapsed() < Duration::from_millis(280));
940        assert_eq!(max_active.load(Ordering::SeqCst), 2);
941    }
942
943    #[test]
944    fn on_error_and_retries_are_applied() {
945        let dir = tempdir().unwrap();
946        let calls = Arc::new(AtomicUsize::new(0));
947        let invoke_calls = Arc::clone(&calls);
948        let invoke = move |name: &str, _input: ToolInput| {
949            invoke_calls.fetch_add(1, Ordering::SeqCst);
950            if name == "a" {
951                Ok(tool_result(ToolStatus::Failed, None))
952            } else {
953                Ok(tool_result(ToolStatus::Passed, Some("ok".to_string())))
954            }
955        };
956        let plan = write_plan(
957            dir.path(),
958            json!({
959                "id": "p",
960                "steps": [
961                    {"id": "a", "tool": "a", "retries": 2, "on_error": "continue"},
962                    {"id": "b", "tool": "b", "depends_on": ["a"]}
963                ]
964            }),
965        );
966
967        let report = execute(&plan, &test_ctx(dir.path().to_path_buf(), &invoke));
968        assert!(report.completed);
969        assert_eq!(calls.load(Ordering::SeqCst), 4);
970    }
971
972    #[test]
973    fn condition_false_skips_step() {
974        let dir = tempdir().unwrap();
975        let invoke = |_name: &str, _input: ToolInput| {
976            Ok(tool_result(ToolStatus::Passed, Some("ok".to_string())))
977        };
978        let plan = write_plan(
979            dir.path(),
980            json!({
981                "id": "p",
982                "steps": [
983                    {"id": "a", "tool": "ok"},
984                    {"id": "b", "tool": "ok", "depends_on": ["a"], "if": "$a.status == 'failed'"}
985                ]
986            }),
987        );
988
989        let report = execute(&plan, &test_ctx(dir.path().to_path_buf(), &invoke));
990        assert!(report.completed);
991        assert_eq!(report.results[1].status, StepStatus::Skipped);
992    }
993}