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