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 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 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 }
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 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 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 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 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 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 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
732fn 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 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}