Skip to main content

construct/tools/
sop_execute.rs

1use std::sync::{Arc, Mutex};
2
3use async_trait::async_trait;
4use serde_json::json;
5use tracing::warn;
6
7use super::traits::{Tool, ToolResult};
8use crate::sop::types::{SopEvent, SopRunAction, SopTriggerSource};
9use crate::sop::{SopAuditLogger, SopEngine};
10
11/// Manually trigger an SOP by name. Returns the run ID and first step instruction.
12pub struct SopExecuteTool {
13    engine: Arc<Mutex<SopEngine>>,
14    audit: Option<Arc<SopAuditLogger>>,
15}
16
17impl SopExecuteTool {
18    pub fn new(engine: Arc<Mutex<SopEngine>>) -> Self {
19        Self {
20            engine,
21            audit: None,
22        }
23    }
24
25    pub fn with_audit(mut self, audit: Arc<SopAuditLogger>) -> Self {
26        self.audit = Some(audit);
27        self
28    }
29}
30
31#[async_trait]
32impl Tool for SopExecuteTool {
33    fn name(&self) -> &str {
34        "sop_execute"
35    }
36
37    fn description(&self) -> &str {
38        "Manually trigger a Standard Operating Procedure (SOP) by name. Returns the run ID and first step instruction. Use sop_list to see available SOPs."
39    }
40
41    fn parameters_schema(&self) -> serde_json::Value {
42        json!({
43            "type": "object",
44            "properties": {
45                "name": {
46                    "type": "string",
47                    "description": "Name of the SOP to execute"
48                },
49                "payload": {
50                    "type": "string",
51                    "description": "Optional trigger payload (JSON string)"
52                }
53            },
54            "required": ["name"]
55        })
56    }
57
58    async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
59        let sop_name = args
60            .get("name")
61            .and_then(|v| v.as_str())
62            .ok_or_else(|| anyhow::anyhow!("Missing 'name' parameter"))?;
63
64        let payload = args
65            .get("payload")
66            .and_then(|v| v.as_str())
67            .map(String::from);
68
69        let event = SopEvent {
70            source: SopTriggerSource::Manual,
71            topic: None,
72            payload,
73            timestamp: now_iso8601(),
74        };
75
76        // Lock engine, start run, snapshot run for audit, then drop lock
77        let (action, run_snapshot) = {
78            let mut engine = self
79                .engine
80                .lock()
81                .map_err(|e| anyhow::anyhow!("Engine lock poisoned: {e}"))?;
82
83            match engine.start_run(sop_name, event) {
84                Ok(action) => {
85                    let run_id = action_run_id(&action);
86                    let snapshot = run_id.and_then(|id| engine.get_run(id).cloned());
87                    (Ok(action), snapshot)
88                }
89                Err(e) => (Err(e), None),
90            }
91        };
92
93        // Audit log (engine lock dropped, safe to await)
94        if let Some(ref audit) = self.audit {
95            if let Some(ref run) = run_snapshot {
96                if let Err(e) = audit.log_run_start(run).await {
97                    warn!("SOP audit log_run_start failed: {e}");
98                }
99            }
100        }
101
102        match action {
103            Ok(action) => {
104                let output = match action {
105                    SopRunAction::ExecuteStep {
106                        run_id, context, ..
107                    } => {
108                        format!("SOP run started: {run_id}\n\n{context}")
109                    }
110                    SopRunAction::WaitApproval {
111                        run_id, context, ..
112                    } => {
113                        format!("SOP run started: {run_id} (waiting for approval)\n\n{context}")
114                    }
115                    SopRunAction::Completed { run_id, sop_name } => {
116                        format!("SOP '{sop_name}' run {run_id} completed immediately (no steps).")
117                    }
118                    SopRunAction::Failed { run_id, reason, .. } => {
119                        format!("SOP run {run_id} failed: {reason}")
120                    }
121                    SopRunAction::DeterministicStep { run_id, step, .. } => {
122                        format!(
123                            "SOP run started (deterministic): {run_id}\nFirst step: {}",
124                            step.title
125                        )
126                    }
127                    SopRunAction::CheckpointWait { run_id, step, .. } => {
128                        format!(
129                            "SOP run started: {run_id} (paused at checkpoint: {})",
130                            step.title
131                        )
132                    }
133                };
134                Ok(ToolResult {
135                    success: true,
136                    output,
137                    error: None,
138                })
139            }
140            Err(e) => Ok(ToolResult {
141                success: false,
142                output: String::new(),
143                error: Some(format!("Failed to start SOP: {e}")),
144            }),
145        }
146    }
147}
148
149/// Extract run_id from any SopRunAction variant.
150fn action_run_id(action: &SopRunAction) -> Option<&str> {
151    match action {
152        SopRunAction::ExecuteStep { run_id, .. }
153        | SopRunAction::WaitApproval { run_id, .. }
154        | SopRunAction::Completed { run_id, .. }
155        | SopRunAction::Failed { run_id, .. }
156        | SopRunAction::DeterministicStep { run_id, .. }
157        | SopRunAction::CheckpointWait { run_id, .. } => Some(run_id),
158    }
159}
160
161use crate::sop::engine::now_iso8601;
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166    use crate::config::SopConfig;
167    use crate::sop::engine::SopEngine;
168    use crate::sop::types::*;
169
170    fn test_sop(name: &str, mode: SopExecutionMode) -> Sop {
171        Sop {
172            name: name.into(),
173            description: format!("Test SOP: {name}"),
174            version: "1.0.0".into(),
175            priority: SopPriority::Normal,
176            execution_mode: mode,
177            triggers: vec![SopTrigger::Manual],
178            steps: vec![
179                SopStep {
180                    number: 1,
181                    title: "Step one".into(),
182                    body: "Do step one".into(),
183                    suggested_tools: vec!["shell".into()],
184                    requires_confirmation: false,
185                    kind: SopStepKind::default(),
186                    schema: None,
187                },
188                SopStep {
189                    number: 2,
190                    title: "Step two".into(),
191                    body: "Do step two".into(),
192                    suggested_tools: vec![],
193                    requires_confirmation: false,
194                    kind: SopStepKind::default(),
195                    schema: None,
196                },
197            ],
198            cooldown_secs: 0,
199            max_concurrent: 1,
200            location: None,
201            deterministic: false,
202        }
203    }
204
205    fn engine_with_sops(sops: Vec<Sop>) -> Arc<Mutex<SopEngine>> {
206        let mut engine = SopEngine::new(SopConfig::default());
207        engine.set_sops_for_test(sops);
208        Arc::new(Mutex::new(engine))
209    }
210
211    #[tokio::test]
212    async fn execute_auto_sop() {
213        let engine = engine_with_sops(vec![test_sop("test-sop", SopExecutionMode::Auto)]);
214        let tool = SopExecuteTool::new(engine);
215        let result = tool.execute(json!({"name": "test-sop"})).await.unwrap();
216        assert!(result.success);
217        assert!(result.output.contains("run-"));
218        assert!(result.output.contains("Step one"));
219    }
220
221    #[tokio::test]
222    async fn execute_supervised_sop() {
223        let engine = engine_with_sops(vec![test_sop("test-sop", SopExecutionMode::Supervised)]);
224        let tool = SopExecuteTool::new(engine);
225        let result = tool.execute(json!({"name": "test-sop"})).await.unwrap();
226        assert!(result.success);
227        assert!(result.output.contains("waiting for approval"));
228    }
229
230    #[tokio::test]
231    async fn execute_unknown_sop() {
232        let engine = engine_with_sops(vec![]);
233        let tool = SopExecuteTool::new(engine);
234        let result = tool.execute(json!({"name": "nonexistent"})).await.unwrap();
235        assert!(!result.success);
236        assert!(result.error.unwrap().contains("Failed to start SOP"));
237    }
238
239    #[tokio::test]
240    async fn execute_missing_name() {
241        let engine = engine_with_sops(vec![]);
242        let tool = SopExecuteTool::new(engine);
243        let result = tool.execute(json!({})).await;
244        assert!(result.is_err());
245    }
246
247    #[tokio::test]
248    async fn execute_with_payload() {
249        let engine = engine_with_sops(vec![test_sop("test-sop", SopExecutionMode::Auto)]);
250        let tool = SopExecuteTool::new(engine);
251        let result = tool
252            .execute(json!({"name": "test-sop", "payload": "{\"value\": 87.3}"}))
253            .await
254            .unwrap();
255        assert!(result.success);
256        assert!(result.output.contains("87.3"));
257    }
258
259    #[test]
260    fn name_and_schema() {
261        let engine = engine_with_sops(vec![]);
262        let tool = SopExecuteTool::new(engine);
263        assert_eq!(tool.name(), "sop_execute");
264        assert!(tool.parameters_schema()["required"].is_array());
265    }
266}