Skip to main content

oneai_workflow/
executor.rs

1//! Workflow executor — runs compiled DAG with level-based parallel execution.
2//!
3//! The executor runs a WorkflowDag by:
4//! 1. Processing levels sequentially (level 0 → level 1 → ... → level N)
5//! 2. Within each level, running all steps concurrently (since they have no mutual dependencies)
6//! 3. Tracking step results and passing context between levels
7//! 4. Handling timeouts, retries, and approval gates
8//! 5. Supporting `{{variable}}` template interpolation in tool_args and prompt fields
9//! 6. Executing LLM inference for prompt-based steps when a provider is available
10//!
11//! Step results are accumulated in a WorkflowContext that flows between levels.
12//! Template interpolation replaces `{{step_id_output}}` and `{{step_id}}` with
13//! previous step outputs, and `{{variable_name}}` with context variables.
14
15use 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/// The status of a workflow step execution.
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
29#[non_exhaustive]
30pub enum StepStatus {
31    /// Not yet started.
32    Pending,
33    /// Currently running.
34    Running,
35    /// Completed successfully.
36    Completed,
37    /// Failed (with optional error message).
38    Failed,
39    /// Skipped (a dependency failed and continue_on_failure is false).
40    Skipped,
41}
42
43/// The result of a single workflow step execution.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct StepResult {
46    /// The step ID.
47    pub step_id: String,
48
49    /// The status of this step.
50    pub status: StepStatus,
51
52    /// The output content (if completed).
53    pub output: Option<String>,
54
55    /// Error message (if failed).
56    pub error: Option<String>,
57
58    /// How many retries were used.
59    pub retries_used: usize,
60
61    /// Execution time in milliseconds.
62    pub execution_time_ms: Option<u64>,
63}
64
65/// The result of the entire workflow execution.
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct WorkflowResult {
68    /// The workflow name.
69    pub name: String,
70
71    /// The results of each step.
72    pub step_results: HashMap<String, StepResult>,
73
74    /// The final context variables after all steps.
75    pub variables: HashMap<String, String>,
76
77    /// Whether the workflow completed successfully.
78    pub success: bool,
79
80    /// Total execution time in milliseconds.
81    pub total_time_ms: u64,
82
83    /// Which level was being executed when the workflow ended.
84    pub completed_levels: usize,
85}
86
87impl WorkflowResult {
88    /// Get a step result by ID.
89    pub fn get_step_result(&self, step_id: &str) -> Option<&StepResult> {
90        self.step_results.get(step_id)
91    }
92
93    /// Get all completed step IDs.
94    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    /// Get all failed step IDs.
103    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/// Workflow execution context — shared state that flows between levels.
113#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct WorkflowContext {
115    /// Context variables (initially from WorkflowConfig, updated by steps).
116    pub variables: HashMap<String, String>,
117    /// Results from completed steps (available to subsequent steps).
118    pub step_outputs: HashMap<String, String>,
119}
120
121impl WorkflowContext {
122    /// Create a context from workflow config variables.
123    pub fn from_config(config: &WorkflowConfig) -> Self {
124        Self {
125            variables: config.variables.clone(),
126            step_outputs: HashMap::new(),
127        }
128    }
129
130    /// Set a variable.
131    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    /// Get a variable.
136    pub fn get_variable(&self, key: &str) -> Option<&String> {
137        self.variables.get(key)
138    }
139
140    /// Store a step's output.
141    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
146/// Workflow executor — runs a compiled DAG.
147///
148/// The executor processes the DAG level by level:
149/// - Level 0: all root nodes run concurrently
150/// - Level 1: nodes that depend only on level 0 nodes run concurrently
151/// - Level N: nodes that depend only on earlier levels run concurrently
152///
153/// Between levels, the WorkflowContext is updated with step results.
154/// Template interpolation (`{{variable}}`) is applied to tool_args and
155/// prompt fields before step execution, using context variables and
156/// previous step outputs.
157///
158/// When a provider is available, prompt-based steps (steps with a `prompt`
159/// field but no `tool`) execute actual LLM inference instead of just
160/// returning the prompt text.
161pub struct WorkflowExecutor {
162    /// The tool registry for executing tool-based steps.
163    /// Uses RwLock so tools can be registered after construction.
164    tools: Arc<tokio::sync::RwLock<HashMap<String, Arc<dyn Tool>>>>,
165    /// The approval gate for high-risk tool approval.
166    interaction_gate: Arc<dyn InteractionGate>,
167    /// Optional LLM provider for executing prompt-based steps.
168    /// When set, steps with a `prompt` field but no `tool` will call
169    /// the provider for actual inference. When None, prompt steps
170    /// just return the interpolated prompt text.
171    provider: Option<Arc<dyn LlmProvider>>,
172    /// Optional domain permission resolver. When present, a tool step is
173    /// checked against domain policy (`deny_by_default` / `require_confirmation`
174    /// / `auto_approve`) BEFORE dispatch — closing the workflow-path bypass of
175    /// gap-analysis P1, where tool steps previously called `tool.execute()`
176    /// directly with no permission check. `None` = pre-existing behaviour.
177    permission_resolver: Option<Arc<dyn PermissionResolver>>,
178}
179
180impl WorkflowExecutor {
181    /// Create a new executor with tools and approval gate.
182    pub fn new(
183        tools: Arc<HashMap<String, Arc<dyn Tool>>>,
184        interaction_gate: Arc<dyn InteractionGate>,
185    ) -> Self {
186        // Convert static HashMap into RwLock-backed HashMap for dynamic registration
187        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    /// Create a new executor with an empty tool registry.
196    /// Tools can be registered later via `register_tool()`.
197    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    /// Create a new executor with tools, approval gate, and LLM provider.
207    /// With a provider, prompt-based steps will execute actual LLM inference.
208    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    /// Attach a domain permission resolver so tool steps honour DomainPack
222    /// `deny_by_default` / `require_confirmation` policy on this path.
223    pub fn with_permission_resolver(mut self, resolver: Arc<dyn PermissionResolver>) -> Self {
224        self.permission_resolver = Some(resolver);
225        self
226    }
227
228    /// Set the LLM provider for prompt-based steps.
229    /// When set, steps with a `prompt` field but no `tool` will call
230    /// the provider for actual inference instead of returning raw text.
231    pub fn set_provider(&mut self, provider: Arc<dyn LlmProvider>) {
232        self.provider = Some(provider);
233    }
234
235    /// Register a tool for workflow step execution.
236    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    /// Get a handle to the internal tool registry (Arc<RwLock<HashMap>>).
244    /// Used by StateGraphExecutor to share the same tool registry.
245    pub fn tools_handle(&self) -> Arc<tokio::sync::RwLock<HashMap<String, Arc<dyn Tool>>>> {
246        self.tools.clone()
247    }
248
249    /// Execute a workflow DAG.
250    ///
251    /// Runs the DAG level by level, with steps at the same level
252    /// executing concurrently. Returns a WorkflowResult with the
253    /// status of each step.
254    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        // Mark all steps as pending initially
265        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        // Execute each level
280        for level_ids in &dag.levels {
281            let _level_start = std::time::Instant::now();
282
283            // Check if any dependencies failed — skip if not continue_on_failure
284            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                // Skip all steps in this level
307                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            // Execute all steps in this level concurrently
317            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            // Collect results from all steps in this level
346            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                        // Step execution error
362                        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/// Execute a single workflow step with retry support and template interpolation.
388///
389/// Template interpolation replaces `{{step_id_output}}` and `{{step_id}}` with
390/// previous step outputs, and `{{variable_name}}` with context variables.
391/// This is applied to `step.tool_args` (for tool steps) and `step.prompt`
392/// (for prompt-based steps) before execution.
393///
394/// When a provider is available, prompt-based steps (no tool, just prompt)
395/// execute actual LLM inference. When no provider is set, they return
396/// the interpolated prompt text.
397#[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    // ─── Template interpolation ──────────────────────────────────────────
414    // Interpolate {{variable}} patterns in step.tool_args and step.prompt
415    // using context.step_outputs and context.variables.
416    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        // Interpolate string values in the JSON
422        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    // ─── Domain permission gate (highest priority) ─────────────────────────
428    // Close the workflow-path bypass of gap-analysis P1: tool steps used to
429    // call `tool.execute()` directly with no permission check, so a DomainPack
430    // `deny_by_default` pattern was silently ignored here. When a resolver is
431    // wired, a `Deny` short-circuits before any approval/execution; other
432    // outcomes fall through to the existing per-step approval flow.
433    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                // Domain has no objection — recorded for audit. The per-step
457                // `requires_approval` flag is an orthogonal, author-requested
458                // confirmation and is left as-is.
459                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                // Domain demands confirmation — force the per-step approval gate
467                // on even if the step author didn't request it.
468                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                // No domain rule — fall through to existing approval flow.
477            }
478        }
479    }
480
481    // If this step requires approval, request it
482    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                // Stateless workflow path can't loop on feedback → treat as denied.
509                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                // Tool args were rewritten by the approver. (In a real
522                // implementation we'd merge _args into tool_args before exec.)
523            }
524            oneai_core::InteractionResponse::ProceedWith { .. }
525            | oneai_core::InteractionResponse::Proceed
526            | oneai_core::InteractionResponse::Choose { .. } => {
527                // Approved as-is (Choose is PlanDecision-only and doesn't apply).
528            }
529            // InteractionResponse is #[non_exhaustive]; unknown variants proceed.
530            _ => {}
531        }
532    }
533
534    // Execute with retry loop
535    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                // Tool-based step — read from RwLock
539                let tools_map = tools.read().await;
540                let tool = tools_map.get(tool_name);
541                if let Some(tool) = tool {
542                    // Use interpolated args if available, otherwise use original args
543                    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, // Result<ToolOutput, OneAIError>
557                        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                // Prompt-based step with LLM provider — execute actual inference
570                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                    // Build a minimal inference request with the interpolated prompt
583                    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![], // No tools for prompt steps
592                        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                // No tool defined and no provider — just return interpolated prompt
617                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            // Wait before retry
657            tokio::time::sleep(std::time::Duration::from_secs(
658                retry_policy.retry_delay_secs,
659            ))
660            .await;
661        }
662    }
663
664    // All retries exhausted
665    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
675// ─── Template Interpolation ────────────────────────────────────────────────
676
677/// Interpolate `{{variable}}` template patterns in a string.
678///
679/// Replaces `{{key}}` patterns using two sources:
680/// 1. `context.step_outputs` — previous step results. Both `{{step_id}}`
681///    and `{{step_id_output}}` resolve to the output of step `step_id`.
682/// 2. `context.variables` — shared workflow variables.
683///
684/// **Order**: Longer patterns are replaced first to avoid substring conflicts.
685/// `{{step_id_output}}` is replaced before `{{step_id}}`.
686///
687/// Example:
688/// ```
689/// let template = "Review {{read_diff_output}} with focus on {{focus_area}}";
690/// // If read_diff has output and focus_area = "security":
691/// // → "Review <output> with focus on security"
692/// ```
693pub fn interpolate_template(template: &str, context: &WorkflowContext) -> String {
694    let mut result = template.to_string();
695
696    // First pass: replace longer patterns (step_id_output) before shorter ones (step_id)
697    // This prevents {{step1_output}} from being partially matched by {{step1}}
698    for (step_id, output) in &context.step_outputs {
699        // Long form: {{step_id_output}} — must be replaced first
700        // Build pattern manually: "{{" + step_id + "_output}}"
701        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        // Short form: {{step_id}} — replaced second
706        // Build pattern manually: "{{" + step_id + "}}"
707        let short_pattern = "{{".to_string() + step_id + "}}";
708        result = result.replace(&short_pattern, output);
709    }
710
711    // Replace variable references: {{variable_name}}
712    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        // Short form {{step_id}}
730        let result = interpolate_template("{{step1}} then {{step2}}", &context);
731        assert_eq!(result, "result of step1 then result of step2");
732
733        // Long form {{step_id_output}}
734        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    //! Regression tests for the gap-analysis P1 fix: workflow tool steps used
772    //! to call `tool.execute()` directly, bypassing DomainPack
773    //! `deny_by_default`. Now a `PermissionResolver` short-circuits a Deny
774    //! before dispatch.
775    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    /// A tool that echoes its `value` arg — stands in for any domain tool.
785    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    /// A no-op gate (Proceed) that disables every point — never reached when
815    /// the resolver denies, but required to construct the executor.
816    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    /// A resolver that denies a named tool, auto-approves everything else.
828    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        // The fix: a domain-deny must stop the step BEFORE tool.execute().
861        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        // Register the tool — the deny check runs before lookup, so even
870        // unregistered tools are denied; we register to prove execution would
871        // have run without the resolver.
872        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        // Positive control: with the resolver not denying `echo`, the step
885        // runs and produces the tool's output.
886        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        // No resolver wired → the step runs exactly as before (the bypass
905        // "fix" doesn't break the no-domain-pack case).
906        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}