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