1use std::collections::HashMap;
16use std::sync::Arc;
17
18use serde::{Deserialize, Serialize};
19
20use oneai_core::error::{OneAIError, Result};
21use oneai_core::traits::{InteractionGate, LlmProvider, PermissionResolver, Tool};
22use oneai_core::{Conversation, InferenceRequest, Message};
23
24use crate::config::{RetryPolicy, WorkflowConfig};
25use crate::dag::WorkflowDag;
26
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
29#[non_exhaustive]
30pub enum StepStatus {
31 Pending,
33 Running,
35 Completed,
37 Failed,
39 Skipped,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct StepResult {
46 pub step_id: String,
48
49 pub status: StepStatus,
51
52 pub output: Option<String>,
54
55 pub error: Option<String>,
57
58 pub retries_used: usize,
60
61 pub execution_time_ms: Option<u64>,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct WorkflowResult {
68 pub name: String,
70
71 pub step_results: HashMap<String, StepResult>,
73
74 pub variables: HashMap<String, String>,
76
77 pub success: bool,
79
80 pub total_time_ms: u64,
82
83 pub completed_levels: usize,
85}
86
87impl WorkflowResult {
88 pub fn get_step_result(&self, step_id: &str) -> Option<&StepResult> {
90 self.step_results.get(step_id)
91 }
92
93 pub fn completed_steps(&self) -> Vec<String> {
95 self.step_results
96 .iter()
97 .filter(|(_, r)| r.status == StepStatus::Completed)
98 .map(|(id, _)| id.clone())
99 .collect()
100 }
101
102 pub fn failed_steps(&self) -> Vec<String> {
104 self.step_results
105 .iter()
106 .filter(|(_, r)| r.status == StepStatus::Failed)
107 .map(|(id, _)| id.clone())
108 .collect()
109 }
110}
111
112#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct WorkflowContext {
115 pub variables: HashMap<String, String>,
117 pub step_outputs: HashMap<String, String>,
119}
120
121impl WorkflowContext {
122 pub fn from_config(config: &WorkflowConfig) -> Self {
124 Self {
125 variables: config.variables.clone(),
126 step_outputs: HashMap::new(),
127 }
128 }
129
130 pub fn set_variable(&mut self, key: impl Into<String>, value: impl Into<String>) {
132 self.variables.insert(key.into(), value.into());
133 }
134
135 pub fn get_variable(&self, key: &str) -> Option<&String> {
137 self.variables.get(key)
138 }
139
140 pub fn set_step_output(&mut self, step_id: impl Into<String>, output: impl Into<String>) {
142 self.step_outputs.insert(step_id.into(), output.into());
143 }
144}
145
146pub struct WorkflowExecutor {
162 tools: Arc<tokio::sync::RwLock<HashMap<String, Arc<dyn Tool>>>>,
165 interaction_gate: Arc<dyn InteractionGate>,
167 provider: Option<Arc<dyn LlmProvider>>,
172 permission_resolver: Option<Arc<dyn PermissionResolver>>,
178}
179
180impl WorkflowExecutor {
181 pub fn new(
183 tools: Arc<HashMap<String, Arc<dyn Tool>>>,
184 interaction_gate: Arc<dyn InteractionGate>,
185 ) -> Self {
186 Self {
188 tools: Arc::new(tokio::sync::RwLock::new((*tools).clone())),
189 interaction_gate,
190 provider: None,
191 permission_resolver: None,
192 }
193 }
194
195 pub fn new_empty(interaction_gate: Arc<dyn InteractionGate>) -> Self {
198 Self {
199 tools: Arc::new(tokio::sync::RwLock::new(HashMap::new())),
200 interaction_gate,
201 provider: None,
202 permission_resolver: None,
203 }
204 }
205
206 pub fn with_provider(
209 tools: Arc<HashMap<String, Arc<dyn Tool>>>,
210 interaction_gate: Arc<dyn InteractionGate>,
211 provider: Arc<dyn LlmProvider>,
212 ) -> Self {
213 Self {
214 tools: Arc::new(tokio::sync::RwLock::new((*tools).clone())),
215 interaction_gate,
216 provider: Some(provider),
217 permission_resolver: None,
218 }
219 }
220
221 pub fn with_permission_resolver(mut self, resolver: Arc<dyn PermissionResolver>) -> Self {
224 self.permission_resolver = Some(resolver);
225 self
226 }
227
228 pub fn set_provider(&mut self, provider: Arc<dyn LlmProvider>) {
232 self.provider = Some(provider);
233 }
234
235 pub async fn register_tool(&self, tool: Arc<dyn Tool>) {
237 self.tools
238 .write()
239 .await
240 .insert(tool.name().to_string(), tool);
241 }
242
243 pub fn tools_handle(&self) -> Arc<tokio::sync::RwLock<HashMap<String, Arc<dyn Tool>>>> {
246 self.tools.clone()
247 }
248
249 pub async fn execute(
255 &self,
256 dag: &WorkflowDag,
257 config: &WorkflowConfig,
258 ) -> Result<WorkflowResult> {
259 let start_time = std::time::Instant::now();
260 let mut step_results: HashMap<String, StepResult> = HashMap::new();
261 let mut context = WorkflowContext::from_config(config);
262 let mut completed_levels = 0;
263
264 for id in dag.nodes.keys() {
266 step_results.insert(
267 id.clone(),
268 StepResult {
269 step_id: id.clone(),
270 status: StepStatus::Pending,
271 output: None,
272 error: None,
273 retries_used: 0,
274 execution_time_ms: None,
275 },
276 );
277 }
278
279 for level_ids in &dag.levels {
281 let _level_start = std::time::Instant::now();
282
283 let mut skip_level = false;
285 if !config.continue_on_failure {
286 for step_id in level_ids {
287 if let Some(node) = dag.get_node(step_id) {
288 for dep_id in &node.depends_on {
289 if let Some(dep_result) = step_results.get(dep_id) {
290 if dep_result.status == StepStatus::Failed
291 || dep_result.status == StepStatus::Skipped
292 {
293 skip_level = true;
294 break;
295 }
296 }
297 }
298 }
299 if skip_level {
300 break;
301 }
302 }
303 }
304
305 if skip_level {
306 for step_id in level_ids {
308 if let Some(result) = step_results.get_mut(step_id) {
309 result.status = StepStatus::Skipped;
310 result.error = Some("Dependency failed — step skipped".to_string());
311 }
312 }
313 continue;
314 }
315
316 let mut level_futures = Vec::new();
318
319 for step_id in level_ids {
320 let node = dag.get_node(step_id).unwrap();
321 let step_config = node.step.clone();
322 let retry_policy = config.effective_retry_policy(step_id);
323 let timeout_secs = config.effective_timeout(step_id);
324 let tools = self.tools.clone();
325 let interaction_gate = self.interaction_gate.clone();
326 let context_snapshot = context.clone();
327 let provider = self.provider.clone();
328 let permission_resolver = self.permission_resolver.clone();
329
330 level_futures.push(tokio::spawn(async move {
331 execute_step(
332 step_config,
333 retry_policy,
334 timeout_secs,
335 tools,
336 interaction_gate,
337 context_snapshot,
338 provider,
339 permission_resolver,
340 )
341 .await
342 }));
343 }
344
345 for future in level_futures {
347 let result = future
348 .await
349 .map_err(|e| OneAIError::Workflow(format!("Task join error: {}", e)))?;
350
351 match result {
352 Ok(step_result) => {
353 step_results.insert(step_result.step_id.clone(), step_result.clone());
354 if step_result.status == StepStatus::Completed {
355 if let Some(output) = &step_result.output {
356 context.set_step_output(&step_result.step_id, output);
357 }
358 }
359 }
360 Err(e) => {
361 tracing::error!("Step execution error: {}", e);
363 }
364 }
365 }
366
367 completed_levels += 1;
368 }
369
370 let total_time_ms = start_time.elapsed().as_millis() as u64;
371
372 let success = step_results
373 .values()
374 .all(|r| r.status == StepStatus::Completed || r.status == StepStatus::Skipped);
375
376 Ok(WorkflowResult {
377 name: dag.name.clone(),
378 step_results,
379 variables: context.variables,
380 success,
381 total_time_ms,
382 completed_levels,
383 })
384 }
385}
386
387#[allow(clippy::too_many_arguments)]
398async fn execute_step(
399 mut step: crate::config::StepConfig,
400 retry_policy: RetryPolicy,
401 timeout_secs: Option<u64>,
402 tools: Arc<tokio::sync::RwLock<HashMap<String, Arc<dyn Tool>>>>,
403 interaction_gate: Arc<dyn InteractionGate>,
404 context: WorkflowContext,
405 provider: Option<Arc<dyn LlmProvider>>,
406 permission_resolver: Option<Arc<dyn PermissionResolver>>,
407) -> Result<StepResult> {
408 let start_time = std::time::Instant::now();
409 let step_id = step.id.clone();
410 let mut retries_used = 0;
411 let mut last_error: Option<String> = None;
412
413 let interpolated_prompt = step
417 .prompt
418 .as_ref()
419 .map(|p| interpolate_template(p, &context));
420 let interpolated_tool_args = step.tool_args.as_ref().map(|a| {
421 let json_str = serde_json::to_string(a).unwrap_or_default();
423 let interpolated = interpolate_template(&json_str, &context);
424 serde_json::from_str(&interpolated).unwrap_or_else(|_| a.clone())
425 });
426
427 if let (Some(resolver), Some(tool_name)) = (permission_resolver.as_ref(), step.tool.as_ref()) {
434 let check_args = interpolated_tool_args
435 .clone()
436 .or_else(|| step.tool_args.clone())
437 .unwrap_or_else(|| serde_json::json!({}));
438 match resolver.resolve(tool_name, &check_args) {
439 oneai_core::PermissionAction::Deny { reason } => {
440 tracing::warn!(
441 "Workflow step '{}' (tool '{}') denied by domain policy: {}",
442 step_id,
443 tool_name,
444 reason
445 );
446 return Ok(StepResult {
447 step_id,
448 status: StepStatus::Failed,
449 output: None,
450 error: Some(format!("Denied by domain policy: {}", reason)),
451 retries_used: 0,
452 execution_time_ms: Some(start_time.elapsed().as_millis() as u64),
453 });
454 }
455 oneai_core::PermissionAction::AutoApprove => {
456 tracing::info!(
460 "Workflow step '{}' (tool '{}') auto-approved by domain policy",
461 step_id,
462 tool_name
463 );
464 }
465 oneai_core::PermissionAction::RequireConfirmation => {
466 tracing::info!(
469 "Workflow step '{}' (tool '{}') requires confirmation by domain policy",
470 step_id,
471 tool_name
472 );
473 step.requires_approval = true;
474 }
475 oneai_core::PermissionAction::UseDefaultPermission { .. } => {
476 }
478 }
479 }
480
481 if step.requires_approval {
483 let approval_request = oneai_core::ApprovalRequest {
484 tool_name: step.id.clone(),
485 args: step.tool_args.clone().unwrap_or(serde_json::json!({})),
486 risk_level: oneai_core::RiskLevel::High,
487 permission_level: Some(oneai_core::PermissionLevel::Full),
488 justification: format!("Workflow step '{}' requires human approval", step.id),
489 };
490
491 let response = interaction_gate
492 .request(oneai_core::InteractionRequest::ToolApproval {
493 approval: approval_request,
494 })
495 .await?;
496 match response {
497 oneai_core::InteractionResponse::Abort { reason } => {
498 return Ok(StepResult {
499 step_id,
500 status: StepStatus::Failed,
501 output: None,
502 error: Some(format!("Approval denied: {}", reason)),
503 retries_used: 0,
504 execution_time_ms: Some(start_time.elapsed().as_millis() as u64),
505 });
506 }
507 oneai_core::InteractionResponse::Revise { feedback } => {
508 return Ok(StepResult {
510 step_id,
511 status: StepStatus::Failed,
512 output: None,
513 error: Some(format!("Approval denied: {}", feedback)),
514 retries_used: 0,
515 execution_time_ms: Some(start_time.elapsed().as_millis() as u64),
516 });
517 }
518 oneai_core::InteractionResponse::ProceedWith {
519 modification: oneai_core::InteractionModification::ReplaceToolArgs(_args),
520 } => {
521 }
524 oneai_core::InteractionResponse::ProceedWith { .. }
525 | oneai_core::InteractionResponse::Proceed
526 | oneai_core::InteractionResponse::Choose { .. } => {
527 }
529 _ => {}
531 }
532 }
533
534 for attempt in 0..=retry_policy.max_retries {
536 let exec_result: std::result::Result<oneai_core::ToolOutput, OneAIError> =
537 if let Some(tool_name) = &step.tool {
538 let tools_map = tools.read().await;
540 let tool = tools_map.get(tool_name);
541 if let Some(tool) = tool {
542 let args = match &interpolated_tool_args {
544 Some(a) => a.clone(),
545 None => step.tool_args.clone().unwrap_or(serde_json::json!({})),
546 };
547 let timeout = timeout_secs.unwrap_or(60);
548
549 let timeout_result = tokio::time::timeout(
550 std::time::Duration::from_secs(timeout),
551 tool.execute(args.clone()),
552 )
553 .await;
554
555 match timeout_result {
556 Ok(inner_result) => inner_result, Err(_) => Err(OneAIError::Timeout(format!(
558 "Step '{}' timed out after {} seconds",
559 step_id, timeout
560 ))),
561 }
562 } else {
563 Err(OneAIError::Workflow(format!(
564 "Tool '{}' not found for step '{}'",
565 tool_name, step_id
566 )))
567 }
568 } else if let Some(provider) = &provider {
569 let prompt_text = match &interpolated_prompt {
571 Some(p) => p.clone(),
572 None => step.prompt.clone().unwrap_or_default(),
573 };
574 if prompt_text.is_empty() {
575 Ok(oneai_core::ToolOutput {
576 success: true,
577 content: String::new(),
578 error: None,
579 ..Default::default()
580 })
581 } else {
582 let mut conversation = Conversation::new();
584 conversation.add_message(Message::system(
585 "You are executing a step in a deterministic workflow. Respond concisely.",
586 ));
587 conversation.add_message(Message::user(prompt_text));
588
589 let request = InferenceRequest {
590 conversation,
591 tools: vec![], max_tokens: Some(2048),
593 temperature: Some(0.3),
594 top_p: None,
595 stop_sequences: vec![],
596 constrained_output: None,
597 thinking_budget: None,
598 metadata: HashMap::new(),
599 };
600
601 let response = provider.infer(request).await;
602 match response {
603 Ok(inference_response) => Ok(oneai_core::ToolOutput {
604 success: true,
605 content: inference_response.message.text_content(),
606 error: None,
607 ..Default::default()
608 }),
609 Err(e) => Err(OneAIError::Provider(format!(
610 "LLM inference failed for step '{}': {}",
611 step_id, e
612 ))),
613 }
614 }
615 } else {
616 let prompt_text = match &interpolated_prompt {
618 Some(p) => p.clone(),
619 None => step.prompt.clone().unwrap_or_default(),
620 };
621 Ok(oneai_core::ToolOutput {
622 success: true,
623 content: prompt_text,
624 error: None,
625 ..Default::default()
626 })
627 };
628
629 match exec_result {
630 Ok(output) => {
631 if output.success {
632 return Ok(StepResult {
633 step_id,
634 status: StepStatus::Completed,
635 output: Some(output.content),
636 error: None,
637 retries_used,
638 execution_time_ms: Some(start_time.elapsed().as_millis() as u64),
639 });
640 } else {
641 last_error = Some(
642 output
643 .error
644 .unwrap_or_else(|| "Tool execution failed".to_string()),
645 );
646 }
647 }
648 Err(e) => {
649 last_error = Some(format!("Step error: {}", e));
650 }
651 }
652
653 retries_used = attempt;
654
655 if attempt < retry_policy.max_retries {
656 tokio::time::sleep(std::time::Duration::from_secs(
658 retry_policy.retry_delay_secs,
659 ))
660 .await;
661 }
662 }
663
664 Ok(StepResult {
666 step_id,
667 status: StepStatus::Failed,
668 output: None,
669 error: last_error,
670 retries_used,
671 execution_time_ms: Some(start_time.elapsed().as_millis() as u64),
672 })
673}
674
675pub fn interpolate_template(template: &str, context: &WorkflowContext) -> String {
694 let mut result = template.to_string();
695
696 for (step_id, output) in &context.step_outputs {
699 let long_pattern = "{{".to_string() + step_id + "_output}}";
702 result = result.replace(&long_pattern, output);
703 }
704 for (step_id, output) in &context.step_outputs {
705 let short_pattern = "{{".to_string() + step_id + "}}";
708 result = result.replace(&short_pattern, output);
709 }
710
711 for (key, value) in &context.variables {
713 result = result.replace(&format!("{{{{{}}}}}", key), value);
714 }
715
716 result
717}
718
719#[cfg(test)]
720mod interpolation_tests {
721 use super::*;
722
723 #[test]
724 fn test_interpolate_step_outputs() {
725 let mut context = WorkflowContext::from_config(&WorkflowConfig::new("test", vec![]));
726 context.set_step_output("step1", "result of step1");
727 context.set_step_output("step2", "result of step2");
728
729 let result = interpolate_template("{{step1}} then {{step2}}", &context);
731 assert_eq!(result, "result of step1 then result of step2");
732
733 let result = interpolate_template("{{step1_output}} and {{step2_output}}", &context);
735 assert_eq!(result, "result of step1 and result of step2");
736 }
737
738 #[test]
739 fn test_interpolate_variables() {
740 let mut context = WorkflowContext::from_config(&WorkflowConfig::new("test", vec![]));
741 context.set_variable("focus_area", "security");
742 context.set_variable("language", "Rust");
743
744 let result = interpolate_template("Focus on {{focus_area}} in {{language}}", &context);
745 assert_eq!(result, "Focus on security in Rust");
746 }
747
748 #[test]
749 fn test_interpolate_mixed() {
750 let mut context = WorkflowContext::from_config(&WorkflowConfig::new("test", vec![]));
751 context.set_step_output("read_diff", "auth.rs changes");
752 context.set_variable("focus", "security");
753
754 let result = interpolate_template(
755 "Review {{read_diff_output}} with focus on {{focus}}",
756 &context,
757 );
758 assert_eq!(result, "Review auth.rs changes with focus on security");
759 }
760
761 #[test]
762 fn test_interpolate_no_match() {
763 let context = WorkflowContext::from_config(&WorkflowConfig::new("test", vec![]));
764 let result = interpolate_template("No variables here {{unknown}}", &context);
765 assert_eq!(result, "No variables here {{unknown}}");
766 }
767}
768
769#[cfg(test)]
770mod permission_resolver_tests {
771 use super::*;
776 use crate::compiler::compile;
777 use crate::config::StepConfig;
778 use oneai_core::error::Result;
779 use oneai_core::traits::{InteractionGate, PermissionResolver, Tool};
780 use oneai_core::{
781 InteractionRequest, InteractionResponse, PermissionAction, RiskLevel, ToolOutput,
782 };
783
784 struct EchoTool;
786 #[async_trait::async_trait]
787 impl Tool for EchoTool {
788 fn name(&self) -> &str {
789 "echo"
790 }
791 fn description(&self) -> &str {
792 "echo the value"
793 }
794 fn parameters_schema(&self) -> serde_json::Value {
795 serde_json::json!({"type": "object"})
796 }
797 fn risk_level(&self) -> RiskLevel {
798 RiskLevel::Low
799 }
800 async fn execute(&self, args: serde_json::Value) -> Result<ToolOutput> {
801 Ok(ToolOutput {
802 success: true,
803 content: args
804 .get("value")
805 .and_then(|v| v.as_str())
806 .unwrap_or("ok")
807 .to_string(),
808 error: None,
809 ..Default::default()
810 })
811 }
812 }
813
814 struct NoopGate;
817 #[async_trait::async_trait]
818 impl InteractionGate for NoopGate {
819 async fn request(&self, _req: InteractionRequest) -> Result<InteractionResponse> {
820 Ok(InteractionResponse::Proceed)
821 }
822 fn enabled(&self, _point: oneai_core::InteractionPoint) -> bool {
823 false
824 }
825 }
826
827 struct DenyResolver {
829 denied: String,
830 }
831 impl PermissionResolver for DenyResolver {
832 fn resolve(&self, tool_name: &str, _args: &serde_json::Value) -> PermissionAction {
833 if tool_name == self.denied {
834 PermissionAction::Deny {
835 reason: "forbidden by domain policy".to_string(),
836 }
837 } else {
838 PermissionAction::AutoApprove
839 }
840 }
841 }
842
843 fn step(id: &str, tool: &str) -> StepConfig {
844 StepConfig {
845 id: id.to_string(),
846 description: String::new(),
847 depends_on: vec![],
848 tool: Some(tool.to_string()),
849 tool_args: Some(serde_json::json!({"value": "ran"})),
850 prompt: None,
851 requires_approval: false,
852 timeout_secs: None,
853 retry_policy: None,
854 metadata: HashMap::new(),
855 }
856 }
857
858 #[tokio::test]
859 async fn deny_by_default_short_circuits_a_tool_step() {
860 let config = WorkflowConfig::new("deny-test", vec![step("s1", "echo")]);
862 let dag = compile(&config);
863
864 let executor = WorkflowExecutor::new_empty(Arc::new(NoopGate)).with_permission_resolver(
865 Arc::new(DenyResolver {
866 denied: "echo".to_string(),
867 }),
868 );
869 executor.register_tool(Arc::new(EchoTool)).await;
873
874 let result = executor.execute(&dag, &config).await.unwrap();
875 let step_result = result.step_results.get("s1").expect("step result present");
876 assert_eq!(step_result.status, StepStatus::Failed);
877 let err = step_result.error.as_ref().expect("error present");
878 assert!(err.contains("Denied by domain policy"));
879 assert!(err.contains("forbidden by domain policy"));
880 }
881
882 #[tokio::test]
883 async fn auto_approve_allows_a_tool_step_to_run() {
884 let config = WorkflowConfig::new("allow-test", vec![step("s1", "echo")]);
887 let dag = compile(&config);
888
889 let executor = WorkflowExecutor::new_empty(Arc::new(NoopGate)).with_permission_resolver(
890 Arc::new(DenyResolver {
891 denied: "not_echo".to_string(),
892 }),
893 );
894 executor.register_tool(Arc::new(EchoTool)).await;
895
896 let result = executor.execute(&dag, &config).await.unwrap();
897 let step_result = result.step_results.get("s1").expect("step result present");
898 assert_eq!(step_result.status, StepStatus::Completed);
899 assert_eq!(step_result.output.as_deref(), Some("ran"));
900 }
901
902 #[tokio::test]
903 async fn no_resolver_preserves_legacy_behaviour() {
904 let config = WorkflowConfig::new("legacy-test", vec![step("s1", "echo")]);
907 let dag = compile(&config);
908
909 let executor = WorkflowExecutor::new_empty(Arc::new(NoopGate));
910 executor.register_tool(Arc::new(EchoTool)).await;
911
912 let result = executor.execute(&dag, &config).await.unwrap();
913 let step_result = result.step_results.get("s1").expect("step result present");
914 assert_eq!(step_result.status, StepStatus::Completed);
915 assert_eq!(step_result.output.as_deref(), Some("ran"));
916 }
917}